diff --git a/.changeset/loud-callbacks-listen.md b/.changeset/loud-callbacks-listen.md new file mode 100644 index 00000000000..afb3bafd9d0 --- /dev/null +++ b/.changeset/loud-callbacks-listen.md @@ -0,0 +1,27 @@ +--- +'@clerk/cli-auth': minor +--- + +Add `@clerk/cli-auth`: reusable OAuth 2.0 + PKCE localhost-callback flow for adding Clerk authentication to Node.js CLIs. + +The default export (`@clerk/cli-auth`) ships the CLI-side runtime: browser-based sign-in via a one-shot localhost callback server, token storage (keychain with file fallback, file, or memory), token refresh, revocation, `/oauth/userinfo` lookup, multi-credential resolution via `resolveToken()` (returns `{ token, source }` where `source` is `'arg' | 'env' | 'oauth'`), optional `identityEndpoint`-backed verification, and tunable timeouts (`loginTimeoutMs`, `requestTimeoutMs`). + +The new `@clerk/cli-auth/server` subpath ships a backend route handler that consumers drop into any framework using Web `Request`/`Response` (Next.js App Router, Hono, Cloudflare Workers, Bun, Deno): + +```ts +// lib/clerk-cli.ts +import { cliAuth } from '@clerk/cli-auth/server'; +import { clerkClient } from '@clerk/nextjs/server'; + +export const auth = cliAuth({ client: clerkClient }); + +// app/api/cli/identity/route.ts +import { handle } from '@clerk/cli-auth/server'; +import { auth } from '@/lib/clerk-cli'; + +export const GET = handle({ auth, accepts: ['api_key', 'oauth_token'] }); +``` + +`cliAuth({ client | clientConfig })` binds a `@clerk/backend` client once and returns an instance exposing `verifyToken(token)`, `verifyTokenFromRequest(request)`, `resolveAuthInfo(ctx)`, and `getClerk()`. The standalone `handle({ auth, accepts, verifyToken?, resolveAuthInfo? })` produces a route handler that auto-detects token type, gates against `accepts`, verifies via `@clerk/backend`'s `verifyMachineAuthToken`, and returns an `Identity` JSON payload. Override the verification or resolution steps per-route by passing the corresponding callbacks. + +`accepts` recognizes `'api_key'` (covers user, org, and machine subjects), `'oauth_token'` (opaque `oat_*` or RFC 9068 `at+jwt` JWTs), or `'any'`. The narrowed `TokenKind` excludes Clerk session tokens and M2M tokens — neither is a CLI credential. diff --git a/.github/labeler.yml b/.github/labeler.yml index 642ff9645ad..dd462647c30 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -12,6 +12,10 @@ chrome-extension: - changed-files: - any-glob-to-any-file: packages/chrome-extension/** +cli-auth: + - changed-files: + - any-glob-to-any-file: packages/cli-auth/** + clerk-js: - changed-files: - any-glob-to-any-file: packages/clerk-js/** diff --git a/packages/cli-auth/.gitignore b/packages/cli-auth/.gitignore new file mode 100644 index 00000000000..6a53db05dea --- /dev/null +++ b/packages/cli-auth/.gitignore @@ -0,0 +1,8 @@ +*.log +.DS_Store +.idea +node_modules +dist +coverage +.env +.env.local diff --git a/packages/cli-auth/.npmignore b/packages/cli-auth/.npmignore new file mode 100644 index 00000000000..8b2767ff3b3 --- /dev/null +++ b/packages/cli-auth/.npmignore @@ -0,0 +1,2 @@ +* +!/dist/**/* \ No newline at end of file diff --git a/packages/cli-auth/LICENSE b/packages/cli-auth/LICENSE new file mode 100644 index 00000000000..5713d0938b3 --- /dev/null +++ b/packages/cli-auth/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Clerk, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/cli-auth/README.md b/packages/cli-auth/README.md new file mode 100644 index 00000000000..1db3c9f6a5a --- /dev/null +++ b/packages/cli-auth/README.md @@ -0,0 +1,361 @@ +

+ + + + + + +
+

@clerk/cli-auth

+

+ +
+ +[![Chat on Discord](https://img.shields.io/discord/856971667393609759.svg?logo=discord)](https://clerk.com/discord) +[![Clerk documentation](https://img.shields.io/badge/documentation-clerk-green.svg)](https://clerk.com/docs?utm_source=github&utm_medium=clerk_cli_auth) +[![Follow on Twitter](https://img.shields.io/twitter/follow/Clerk?style=social)](https://x.com/intent/follow?screen_name=Clerk) + +[Changelog](https://github.com/clerk/javascript/blob/main/packages/cli-auth/CHANGELOG.md) +· +[Report a Bug](https://github.com/clerk/javascript/issues/new?assignees=&labels=needs-triage&projects=&template=BUG_REPORT.yml) +· +[Request a Feature](https://feedback.clerk.com/roadmap) +· +[Get help](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_cli_auth) + +
+ +## Getting Started + +`@clerk/cli-auth` is a set of building blocks for adding [Clerk](https://clerk.com) authentication to Node.js command-line tools. + +The package ships two entry points: + +- **`@clerk/cli-auth`** — sign-in and credential management in your CLI. +- **`@clerk/cli-auth/server`** — verify CLI requests on your backend. + +### Prerequisites + +- Node.js `>=20.9.0` or later +- An existing Clerk application. [Create your account for free](https://dashboard.clerk.com/sign-up?utm_source=github&utm_medium=clerk_cli_auth). + +### Installation + +```sh +npm install @clerk/cli-auth +``` + +## Setup + +Create an OAuth Application in your Clerk instance and grab its `client_id` and issuer URL. + +### Create an OAuth Application + +Pick whichever path fits your workflow. + +**Clerk Dashboard** — in your dev instance, go to **Configure → OAuth Applications → Create** and set: + +- **Name** — your CLI's name +- **Redirect URI** — `http://127.0.0.1/callback` (the CLI listens on a dynamic loopback port and sends the actual `http://127.0.0.1:{port}/callback` during authorization) +- **Public client (PKCE)** — enabled +- **Scopes** — `profile email openid offline_access` + +**Clerk CLI** — if you have [`clerk`](https://clerk.com/cli) installed, this is the fastest path. Keychain-based auth, no secret key in the env: + +```sh +clerk api /oauth_applications --instance -X POST --yes -d '{ + "name": "my-cli", + "redirect_uris": ["http://127.0.0.1/callback"], + "public": true, + "pkce_required": true, + "scopes": "profile email openid offline_access" +}' +``` + +**curl against BAPI** — if you'd rather script it directly. Replace `$SK` with your instance's secret key: + +```sh +curl -X POST https://api.clerk.com/v1/oauth_applications \ + -H "Authorization: Bearer $SK" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "my-cli", + "redirect_uris": ["http://127.0.0.1/callback"], + "public": true, + "pkce_required": true, + "scopes": "profile email openid offline_access" + }' +``` + +All three paths return a JSON object with `client_id`. Pair it with your instance's Frontend API URL (the issuer — e.g. `https://clerk.your-subdomain.accounts.dev` or a custom domain like `https://clerk.yourapp.com`). + +### Configure your CLI + +```sh +export CLERK_OAUTH_CLIENT_ID="..." +export CLERK_ISSUER="https://clerk.your-subdomain.accounts.dev" +``` + +## Usage + +### Sign in + +```ts +import { ClerkCliAuth } from '@clerk/cli-auth'; + +const auth = new ClerkCliAuth({ + clientId: process.env.CLERK_OAUTH_CLIENT_ID!, + issuer: process.env.CLERK_ISSUER!, + scopes: ['profile', 'email', 'openid', 'offline_access'], + storage: 'keychain', + keychainService: 'my-cli', +}); + +const { tokens, user } = await auth.login(); +console.log(`Signed in as ${user.email}`); +``` + +`login()` opens the user's browser, starts a one-shot localhost server, exchanges the authorization code for tokens, and stores the result in the OS keychain (with a `chmod 0600` file fallback). Pass `storage: 'memory'` for ephemeral sessions or `storage: 'file'` to skip the keychain entirely. + +### Get the current user + +```ts +const me = await auth.whoami(); +if (me) { + console.log(`${me.name} <${me.email}>`); +} +``` + +`whoami()` returns the live user info from Clerk. It auto-refreshes the access token when it's close to expiring and surfaces revocations immediately — there is no client-side cache. + +### Get an access token for API calls + +```ts +const token = await auth.getAccessToken(); + +const res = await fetch('https://api.example.com/me', { + headers: { Authorization: `Bearer ${token}` }, +}); +``` + +`getAccessToken()` returns the cached access token, refreshing it within 30 seconds of expiry. Returns `null` if the user isn't signed in. + +### Sign out + +`logout()` revokes the refresh token at Clerk's issuer, then clears the stored credentials. Pass `{ revoke: false }` to skip the network call when you're offline or the token is already expired — local credentials are cleared either way. + +```ts +await auth.logout(); +await auth.logout({ revoke: false }); +``` + +### Accept API keys alongside OAuth + +CLIs that run in CI/CD, agents, or scripted environments often need to authenticate with a Clerk API key (`ak_*`) instead of going through the browser. API keys can represent a user, an organization, or a machine identity — one credential type covers all three. Configure `identityEndpoint` (and optionally `tokenEnvVar`) to enable this: + +```ts +const auth = new ClerkCliAuth({ + clientId: process.env.CLERK_OAUTH_CLIENT_ID!, + issuer: process.env.CLERK_ISSUER!, + identityEndpoint: 'https://myapp.com/api/cli/identity', + tokenEnvVar: 'MYAPP_API_KEY', +}); + +// Look up the identity for a specific token (API key or OAuth access token): +const identity = await auth.verifyToken(process.env.MYAPP_API_KEY!); + +// Or let the SDK pick whichever credential is available: +const { token, source } = await auth.resolveToken({ tokenFromArg: argv.token }); +``` + +`resolveToken()` checks for a credential in this order: + +1. The `tokenFromArg` you pass in (typically from a `--token` CLI flag) → `source: 'arg'`. +2. The environment variable named in `tokenEnvVar` → `source: 'env'`. +3. The cached OAuth access token from `login()` → `source: 'oauth'`. + +`source` tells you where the credential came from — refreshable OAuth vs. a static value the user supplied — so you can branch on the trust model without introspecting the bearer's shape: + +```ts +const { token, source } = await auth.resolveToken({ tokenFromArg: argv.token }); + +// One call works for every source — the server-side handler verifies and returns the +// identity: +const identity = await auth.verifyToken(token); + +if (source === 'oauth') { + // ...e.g. surface "logged in as " UI; this credential is revokable via logout() +} +``` + +Server-side verification of API keys and OAuth tokens happens at the `identityEndpoint` you host — see [Server-side](#server-side) for the implementation. + +## Server-side + +The `@clerk/cli-auth/server` entry point provides route handlers that verify incoming tokens against Clerk and return user info. The accepted token types are: + +| `accepts` value | Matching token format | +| --------------- | -------------------------------------------------------------- | +| `'api_key'` | `ak_*` Clerk API keys (user, org, or machine subject) | +| `'oauth_token'` | `oat_*` opaque OAuth access tokens or `at+jwt` JWTs (RFC 9068) | +| `'any'` | Either of the above | + +### Bind a Clerk client + +Create a single `cliAuth` instance for your application and pass it to `handle()` on each route: + +```ts +// lib/clerk-cli.ts +import { cliAuth } from '@clerk/cli-auth/server'; +import { clerkClient } from '@clerk/nextjs/server'; + +export const auth = cliAuth({ client: clerkClient }); +``` + +`client` accepts either a resolved Clerk Backend SDK client or a factory function. Passing `clerkClient` from `@clerk/nextjs/server` directly works — the SDK calls it lazily on the first request and caches the result. You can also pass `clientConfig` (the same options `createClerkClient` accepts) to construct a client from a specific secret key. If neither is provided, the SDK builds one from `CLERK_SECRET_KEY`. + +### Identity endpoint + +Wire the `handle()` route handler to your identity endpoint and pass it the `auth` instance: + +```ts +// app/api/cli/identity/route.ts +import { handle } from '@clerk/cli-auth/server'; +import { auth } from '@/lib/clerk-cli'; + +export const GET = handle({ + auth, + accepts: ['api_key', 'oauth_token'], +}); +``` + +The handler reads `Authorization: Bearer `, detects the token type, verifies it with Clerk, and returns a JSON `Identity` payload. This is what `auth.verifyToken()` calls from the CLI — once it's wired, `verifyToken()` works for API keys and OAuth access tokens alike. + +### Protected resource endpoints + +Use `auth.verifyTokenFromRequest()` to add authentication to any other route your CLI calls: + +```ts +// app/api/cli/projects/route.ts +import { NextResponse } from 'next/server'; +import { auth } from '@/lib/clerk-cli'; + +export async function GET(request: Request) { + const tokenInfo = await auth.verifyTokenFromRequest(request, { + accepts: ['api_key', 'oauth_token'], + }); + + const projects = await getProjectsForSubject(tokenInfo.subject); + return NextResponse.json({ projects }); +} +``` + +`verifyTokenFromRequest` throws on missing, invalid, or unaccepted tokens. The returned `tokenInfo` includes `subject`, `type`, optional `scopes`, and the verified `claims` payload — use any of these to authorize the request. + +### Customize verification and response + +Override `verifyToken` or `resolveAuthInfo` on `handle()` when the defaults aren't enough. Both callbacks receive `clerk` (the bound Clerk Backend SDK client) so you don't need to re-import or re-initialize it. + +Add an allowlist or alternate verifier: + +```ts +export const GET = handle({ + auth, + accepts: 'api_key', + verifyToken: async ({ token, type, request, clerk }) => { + if (!isAllowlisted(token)) { + throw new Error('Token not allowlisted'); + } + const apiKey = await clerk.apiKeys.verify(token); + return { subject: apiKey.subject, type, scopes: apiKey.scopes }; + }, +}); +``` + +Enrich the response with profile and org data: + +```ts +export const GET = handle({ + auth, + accepts: ['api_key', 'oauth_token'], + resolveAuthInfo: async ({ tokenInfo, clerk }) => { + if (tokenInfo.type === 'oauth_token') { + const user = await clerk.users.getUser(tokenInfo.subject); + return { + sub: user.id, + email: user.primaryEmailAddress?.emailAddress, + name: `${user.firstName ?? ''} ${user.lastName ?? ''}`.trim(), + picture: user.imageUrl, + }; + } + return { + sub: tokenInfo.subject, + scopes: tokenInfo.scopes, + org_id: tokenInfo.claims?.org_id as string | undefined, + }; + }, +}); +``` + +## Configuration reference + +### Storage + +`storage` controls how tokens are persisted between CLI invocations: + +- `'keychain'` (default) — OS credential manager (macOS Keychain, Windows Credential Manager, libsecret on Linux). Falls back to a `chmod 0600` JSON file at `~/.config/clerk-cli-auth/.json` if the keychain is unavailable. +- `'file'` — file storage only, no keychain attempt. +- `'memory'` — in-process only. Tokens are lost when the CLI exits. + +Pass a custom `keychainService` to namespace the keychain entries for your CLI, and `environment` to keep credentials for different Clerk instances separate (e.g. `'production'` vs `'staging'`). + +### Timeouts + +| Option | Default | Scope | +| ------------------ | ------------------ | ---------------------------------------------------------------------------------------------------------------- | +| `loginTimeoutMs` | 120000 (2 minutes) | How long `login()` waits for the user to complete browser sign-in. | +| `requestTimeoutMs` | 30000 (30 seconds) | Per-request timeout for token exchange, refresh, revocation, `/oauth/userinfo`, and `identityEndpoint` requests. | + +Both fire `ClerkCliAuthError('timeout', ...)` when exceeded. + +## How the OAuth flow works + +``` +1. The CLI generates a PKCE code verifier, a SHA-256 code challenge, and a CSRF state value. +2. The CLI binds an HTTP server on 127.0.0.1 at a random port. +3. The CLI opens the user's browser to: + {issuer}/oauth/authorize?client_id=...&code_challenge=... + &redirect_uri=http://127.0.0.1:{port}/callback + &code_challenge_method=S256&state=... +4. The user signs in through Clerk's hosted UI and grants consent. +5. Clerk redirects the browser to the localhost server with `code` and `state`. +6. The server validates `state`, responds with a "you can close this tab" page, and shuts down. +7. The CLI exchanges the code for tokens at {issuer}/oauth/token with the PKCE verifier. +8. The CLI stores the tokens in the configured credential store. +``` + +## Support + +For help with `@clerk/cli-auth`, visit [our support page](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_cli_auth) or email [support@clerk.com](mailto:support@clerk.com). + +## Community + +Join the [Clerk community on Discord](https://clerk.com/discord) to chat with other developers and the Clerk team. + +## Contributing + +We're open to all community contributions! If you'd like to contribute in any way, please read [our contribution guidelines](https://github.com/clerk/javascript/blob/main/docs/CONTRIBUTING.md) and [code of conduct](https://github.com/clerk/javascript/blob/main/docs/CODE_OF_CONDUCT.md). + +## Security + +`@clerk/cli-auth` follows good practices of security, but 100% security cannot be assured. + +`@clerk/cli-auth` is provided **"as is"** without any **warranty**. Use at your own risk. + +_For more information and to report security issues, please refer to our [security documentation](https://github.com/clerk/javascript/blob/main/docs/SECURITY.md)._ + +## License + +This project is licensed under the **MIT license**. + +See [LICENSE](https://github.com/clerk/javascript/blob/main/packages/cli-auth/LICENSE) for more information. diff --git a/packages/cli-auth/package.json b/packages/cli-auth/package.json new file mode 100644 index 00000000000..e523f0c9d6b --- /dev/null +++ b/packages/cli-auth/package.json @@ -0,0 +1,80 @@ +{ + "name": "@clerk/cli-auth", + "version": "0.0.0", + "description": "Clerk SDK for adding OAuth 2.0 + PKCE localhost-callback authentication to Node.js CLIs", + "keywords": [ + "clerk", + "sdk", + "cli", + "oauth", + "pkce", + "authentication" + ], + "homepage": "https://clerk.com/", + "bugs": { + "url": "https://github.com/clerk/javascript/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clerk/javascript.git", + "directory": "packages/cli-auth" + }, + "license": "MIT", + "author": { + "name": "Clerk, Inc.", + "email": "support@clerk.com", + "url": "git+https://github.com/clerk/javascript.git" + }, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "./server": { + "import": { + "types": "./dist/server.d.mts", + "default": "./dist/server.mjs" + }, + "require": { + "types": "./dist/server.d.ts", + "default": "./dist/server.js" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.js", + "files": [ + "dist" + ], + "scripts": { + "build": "pnpm clean && tsup", + "clean": "rimraf ./dist", + "dev": "tsup --watch", + "dev:pub": "pnpm dev -- --env.publish", + "format": "node ../../scripts/format-package.mjs", + "format:check": "node ../../scripts/format-package.mjs --check", + "lint": "eslint src", + "lint:attw": "attw --pack . --profile node16", + "lint:publint": "publint", + "test": "vitest run", + "test:integration": "vitest run --config vitest.integration.config.mts", + "test:watch": "vitest watch" + }, + "dependencies": { + "@clerk/backend": "workspace:^", + "@napi-rs/keyring": "^1.1.7", + "tslib": "catalog:repo" + }, + "engines": { + "node": ">=20.9.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/cli-auth/src/__tests__/auth-server.test.ts b/packages/cli-auth/src/__tests__/auth-server.test.ts new file mode 100644 index 00000000000..7787129c0cd --- /dev/null +++ b/packages/cli-auth/src/__tests__/auth-server.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { startAuthServer } from '../lib/auth-server'; + +describe('auth server', () => { + it('returns the authorization code and state from /callback', async () => { + const server = await startAuthServer({ + expectedState: 'state', + timeoutMs: 1_000, + }); + const callback = server.waitForCallback(); + + const response = await fetch(`${server.redirectUri}?code=code-123&state=state`); + + expect(response.status).toBe(200); + await expect(callback).resolves.toEqual({ + code: 'code-123', + state: 'state', + }); + server.close(); + }); + + it('rejects on state mismatch', async () => { + const server = await startAuthServer({ + expectedState: 'expected', + timeoutMs: 1_000, + }); + const callback = server.waitForCallback().catch(error => error); + + const response = await fetch(`${server.redirectUri}?code=code-123&state=wrong`); + + expect(response.status).toBe(400); + await expect(callback).resolves.toMatchObject({ + code: 'state_mismatch', + }); + server.close(); + }); + + it('rejects on timeout', async () => { + const server = await startAuthServer({ + expectedState: 'state', + timeoutMs: 25, + }); + const callback = server.waitForCallback().catch(error => error); + + await expect(callback).resolves.toMatchObject({ code: 'timeout' }); + server.close(); + }); +}); diff --git a/packages/cli-auth/src/__tests__/clerk-cli-auth.test.ts b/packages/cli-auth/src/__tests__/clerk-cli-auth.test.ts new file mode 100644 index 00000000000..f46146f5376 --- /dev/null +++ b/packages/cli-auth/src/__tests__/clerk-cli-auth.test.ts @@ -0,0 +1,414 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import type { AddressInfo, Socket } from 'node:net'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { ClerkCliAuth } from '../clerk-cli-auth'; +import type { CredentialStore } from '../types'; + +function seededStore(entries: Record): CredentialStore { + const data = new Map(); + for (const [key, value] of Object.entries(entries)) { + data.set(key, JSON.stringify(value)); + } + return { + async get(key) { + await Promise.resolve(); + return data.get(key) ?? null; + }, + async set(key, value) { + await Promise.resolve(); + data.set(key, value); + }, + async delete(key) { + await Promise.resolve(); + data.delete(key); + }, + }; +} + +/** Adapt an async `createServer` handler so it returns `void`, not a Promise. */ +function asyncServerHandler( + fn: (req: IncomingMessage, res: ServerResponse) => Promise, +): (req: IncomingMessage, res: ServerResponse) => void { + return (req, res) => { + void fn(req, res); + }; +} + +async function readBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('utf8'); +} + +function json(res: ServerResponse, statusCode: number, body: unknown): void { + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +describe('ClerkCliAuth', () => { + const servers: ReturnType[] = []; + + afterEach(async () => { + await Promise.all( + servers.splice(0).map( + server => + new Promise(resolve => { + server.close(() => resolve()); + }), + ), + ); + }); + + it('runs the localhost callback flow against stubbed OAuth endpoints', async () => { + let tokenRequest: URLSearchParams | undefined; + let userinfoAuthorization: string | undefined; + let authorizeUrl: URL | undefined; + + const issuerServer = createServer( + asyncServerHandler(async (req, res) => { + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + + if (req.method === 'POST' && url.pathname === '/oauth/token') { + tokenRequest = new URLSearchParams(await readBody(req)); + json(res, 200, { + access_token: 'access-token', + refresh_token: 'refresh-token', + id_token: 'id-token', + expires_in: 3600, + scope: 'profile email openid offline_access', + token_type: 'Bearer', + }); + return; + } + + if (req.method === 'GET' && url.pathname === '/oauth/userinfo') { + userinfoAuthorization = req.headers.authorization; + json(res, 200, { + sub: 'user_123', + email: 'test@example.com', + name: 'Test User', + }); + return; + } + + json(res, 404, { error: 'not_found' }); + }), + ); + servers.push(issuerServer); + + await new Promise(resolve => issuerServer.listen(0, '127.0.0.1', resolve)); + const issuerPort = (issuerServer.address() as AddressInfo).port; + const issuer = `http://127.0.0.1:${issuerPort}`; + + const auth = new ClerkCliAuth({ + clientId: 'client_123', + issuer, + storage: 'memory', + openBrowser: async url => { + authorizeUrl = new URL(url); + const redirectUri = authorizeUrl.searchParams.get('redirect_uri'); + const state = authorizeUrl.searchParams.get('state'); + expect(redirectUri).toBeTruthy(); + expect(state).toBeTruthy(); + + const callbackUrl = new URL(redirectUri ?? ''); + callbackUrl.searchParams.set('code', 'auth-code'); + callbackUrl.searchParams.set('state', state ?? ''); + + const response = await fetch(callbackUrl); + expect(response.status).toBe(200); + }, + }); + + const result = await auth.login(); + + expect(authorizeUrl?.pathname).toBe('/oauth/authorize'); + expect(authorizeUrl?.searchParams.get('response_type')).toBe('code'); + expect(authorizeUrl?.searchParams.get('client_id')).toBe('client_123'); + expect(authorizeUrl?.searchParams.get('code_challenge_method')).toBe('S256'); + expect(authorizeUrl?.searchParams.get('scope')).toBe('profile email openid offline_access'); + + expect(tokenRequest?.get('grant_type')).toBe('authorization_code'); + expect(tokenRequest?.get('client_id')).toBe('client_123'); + expect(tokenRequest?.get('code')).toBe('auth-code'); + expect(tokenRequest?.get('code_verifier')).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(tokenRequest?.get('redirect_uri')).toBe(authorizeUrl?.searchParams.get('redirect_uri')); + + expect(userinfoAuthorization).toBe('Bearer access-token'); + expect(result.tokens.accessToken).toBe('access-token'); + expect(result.tokens.refreshToken).toBe('refresh-token'); + expect(result.user).toMatchObject({ + sub: 'user_123', + email: 'test@example.com', + }); + + await expect(auth.getAccessToken()).resolves.toBe('access-token'); + await expect(auth.whoami()).resolves.toMatchObject({ sub: 'user_123' }); + }); + + it('cascades revocation: access token is rejected by userinfo after logout', async () => { + const revoked = new Set(); + const refreshToAccess = new Map([['refresh-token', 'access-token']]); + + const issuerServer = createServer( + asyncServerHandler(async (req, res) => { + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + const authHeader = req.headers.authorization ?? ''; + const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''; + + if (req.method === 'GET' && url.pathname === '/oauth/userinfo') { + if (revoked.has(bearer)) { + json(res, 401, { error: 'invalid_token' }); + return; + } + json(res, 200, { sub: 'user_123' }); + return; + } + + if (req.method === 'POST' && url.pathname === '/oauth/token/revoke') { + const body = new URLSearchParams(await readBody(req)); + const token = body.get('token') ?? ''; + revoked.add(token); + const cascaded = refreshToAccess.get(token); + if (cascaded) { + revoked.add(cascaded); + } + res.writeHead(200); + res.end(); + return; + } + + json(res, 404, { error: 'not_found' }); + }), + ); + servers.push(issuerServer); + await new Promise(resolve => issuerServer.listen(0, '127.0.0.1', resolve)); + const issuer = `http://127.0.0.1:${(issuerServer.address() as AddressInfo).port}`; + + const auth = new ClerkCliAuth({ + clientId: 'client_123', + issuer, + storage: seededStore({ + tokens: { + accessToken: 'access-token', + refreshToken: 'refresh-token', + }, + }), + }); + + const preLogout = await fetch(`${issuer}/oauth/userinfo`, { + headers: { Authorization: 'Bearer access-token' }, + }); + expect(preLogout.status).toBe(200); + + await auth.logout(); + + const postLogout = await fetch(`${issuer}/oauth/userinfo`, { + headers: { Authorization: 'Bearer access-token' }, + }); + expect(postLogout.status).toBe(401); + }); + + it('does NOT cascade revocation when logout({ revoke: false }) is called', async () => { + const revoked = new Set(); + const refreshToAccess = new Map([['refresh-token', 'access-token']]); + + const issuerServer = createServer( + asyncServerHandler(async (req, res) => { + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + const authHeader = req.headers.authorization ?? ''; + const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''; + + if (req.method === 'GET' && url.pathname === '/oauth/userinfo') { + if (revoked.has(bearer)) { + json(res, 401, { error: 'invalid_token' }); + return; + } + json(res, 200, { sub: 'user_123' }); + return; + } + + if (req.method === 'POST' && url.pathname === '/oauth/token/revoke') { + const body = new URLSearchParams(await readBody(req)); + const token = body.get('token') ?? ''; + revoked.add(token); + const cascaded = refreshToAccess.get(token); + if (cascaded) { + revoked.add(cascaded); + } + res.writeHead(200); + res.end(); + return; + } + + json(res, 404, { error: 'not_found' }); + }), + ); + servers.push(issuerServer); + await new Promise(resolve => issuerServer.listen(0, '127.0.0.1', resolve)); + const issuer = `http://127.0.0.1:${(issuerServer.address() as AddressInfo).port}`; + + const auth = new ClerkCliAuth({ + clientId: 'client_123', + issuer, + storage: seededStore({ + tokens: { + accessToken: 'access-token', + refreshToken: 'refresh-token', + }, + }), + }); + + await auth.logout({ revoke: false }); + + const postLogout = await fetch(`${issuer}/oauth/userinfo`, { + headers: { Authorization: 'Bearer access-token' }, + }); + expect(postLogout.status).toBe(200); + }); + + it('revokes the refresh token on logout and clears local state', async () => { + let revokeRequest: URLSearchParams | undefined; + + const issuerServer = createServer( + asyncServerHandler(async (req, res) => { + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + if (req.method === 'POST' && url.pathname === '/oauth/token/revoke') { + revokeRequest = new URLSearchParams(await readBody(req)); + res.writeHead(200); + res.end(); + return; + } + json(res, 404, { error: 'not_found' }); + }), + ); + servers.push(issuerServer); + await new Promise(resolve => issuerServer.listen(0, '127.0.0.1', resolve)); + const issuer = `http://127.0.0.1:${(issuerServer.address() as AddressInfo).port}`; + + const auth = new ClerkCliAuth({ + clientId: 'client_123', + issuer, + storage: seededStore({ + tokens: { + accessToken: 'access-token', + refreshToken: 'refresh-token', + }, + user: { sub: 'user_123' }, + }), + }); + + await auth.logout(); + + expect(revokeRequest?.get('client_id')).toBe('client_123'); + expect(revokeRequest?.get('token')).toBe('refresh-token'); + expect(revokeRequest?.get('token_type_hint')).toBe('refresh_token'); + await expect(auth.getTokenSet()).resolves.toBeNull(); + await expect(auth.whoami()).resolves.toBeNull(); + }); + + it('still clears local state when the revoke endpoint fails', async () => { + let revokeCalls = 0; + + const issuerServer = createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + if (req.method === 'POST' && url.pathname === '/oauth/token/revoke') { + revokeCalls += 1; + json(res, 500, { error: 'server_error' }); + return; + } + json(res, 404, { error: 'not_found' }); + }); + servers.push(issuerServer); + await new Promise(resolve => issuerServer.listen(0, '127.0.0.1', resolve)); + const issuer = `http://127.0.0.1:${(issuerServer.address() as AddressInfo).port}`; + + const auth = new ClerkCliAuth({ + clientId: 'client_123', + issuer, + storage: seededStore({ + tokens: { + accessToken: 'access-token', + refreshToken: 'refresh-token', + }, + }), + }); + + await expect(auth.logout()).resolves.toBeUndefined(); + expect(revokeCalls).toBe(1); + await expect(auth.getTokenSet()).resolves.toBeNull(); + }); + + it('skips the revoke call when revoke: false is passed', async () => { + let revokeCalls = 0; + + const issuerServer = createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + if (req.method === 'POST' && url.pathname === '/oauth/token/revoke') { + revokeCalls += 1; + res.writeHead(200); + res.end(); + return; + } + json(res, 404, { error: 'not_found' }); + }); + servers.push(issuerServer); + await new Promise(resolve => issuerServer.listen(0, '127.0.0.1', resolve)); + const issuer = `http://127.0.0.1:${(issuerServer.address() as AddressInfo).port}`; + + const auth = new ClerkCliAuth({ + clientId: 'client_123', + issuer, + storage: seededStore({ + tokens: { + accessToken: 'access-token', + refreshToken: 'refresh-token', + }, + }), + }); + + await auth.logout({ revoke: false }); + expect(revokeCalls).toBe(0); + await expect(auth.getTokenSet()).resolves.toBeNull(); + }); + + it('aborts with ClerkCliAuthError("timeout") when /oauth/userinfo exceeds requestTimeoutMs', async () => { + const hangingSockets: Socket[] = []; + + const issuerServer = createServer((req, _res) => { + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + if (url.pathname === '/oauth/userinfo') { + // Hold the socket open so the client times out instead of getting a response. + hangingSockets.push(req.socket); + return; + } + _res.writeHead(404).end(); + }); + servers.push(issuerServer); + await new Promise(resolve => issuerServer.listen(0, '127.0.0.1', resolve)); + const issuer = `http://127.0.0.1:${(issuerServer.address() as AddressInfo).port}`; + + const auth = new ClerkCliAuth({ + clientId: 'client_123', + issuer, + requestTimeoutMs: 50, + storage: seededStore({ + tokens: { accessToken: 'access-token' }, + }), + }); + + await expect(auth.whoami()).rejects.toMatchObject({ + name: 'ClerkCliAuthError', + code: 'timeout', + }); + + // Drop the held sockets so the server can close cleanly in afterEach. + for (const socket of hangingSockets) { + socket.destroy(); + } + }); +}); diff --git a/packages/cli-auth/src/__tests__/credential-store.test.ts b/packages/cli-auth/src/__tests__/credential-store.test.ts new file mode 100644 index 00000000000..9ce6effdd20 --- /dev/null +++ b/packages/cli-auth/src/__tests__/credential-store.test.ts @@ -0,0 +1,57 @@ +import { mkdtemp, rm, stat } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { createCredentialStore } from '../lib/credential-store'; + +const tempDirs: string[] = []; + +async function makeTempDir(): Promise { + const dir = await mkdtemp(join(process.cwd(), '.tmp-credential-store-')); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))); +}); + +describe('credential store', () => { + it('round-trips values in memory', async () => { + const store = createCredentialStore('memory'); + + await store.set('tokens', 'value'); + await expect(store.get('tokens')).resolves.toBe('value'); + + await store.delete('tokens'); + await expect(store.get('tokens')).resolves.toBeNull(); + }); + + it('creates a chmod 600 JSON file', async () => { + const dir = await makeTempDir(); + const filePath = join(dir, 'credentials.json'); + const store = createCredentialStore('file', { filePath }); + + await store.set('tokens', 'secret'); + + await expect(store.get('tokens')).resolves.toBe('secret'); + const mode = (await stat(filePath)).mode & 0o777; + expect(mode).toBe(0o600); + }); + + it('serializes concurrent file writes and reads', async () => { + const dir = await makeTempDir(); + const filePath = join(dir, 'credentials.json'); + const store = createCredentialStore('file', { filePath }); + const entries: Array<[string, string]> = Array.from({ length: 20 }, (_, index) => [ + `key-${index}`, + `value-${index}`, + ]); + + await Promise.all(entries.map(([key, value]) => store.set(key, value))); + + const values = await Promise.all(entries.map(([key]) => store.get(key))); + expect(values).toEqual(entries.map(([, value]) => value)); + }); +}); diff --git a/packages/cli-auth/src/__tests__/integration/server.test.ts b/packages/cli-auth/src/__tests__/integration/server.test.ts new file mode 100644 index 00000000000..f6ab8aabca3 --- /dev/null +++ b/packages/cli-auth/src/__tests__/integration/server.test.ts @@ -0,0 +1,163 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { handle } from '../../server'; +import type { Identity } from '../../types'; +import { + bearerRequest, + type IntegrationFixtures, + provisionFixtures, + skipWhenNoSecret, + teardownFixtures, +} from './setup'; + +describe.skipIf(skipWhenNoSecret)('cli-auth server integration', () => { + let fx: IntegrationFixtures; + + beforeAll(async () => { + fx = await provisionFixtures(); + }); + + afterAll(async () => { + await teardownFixtures(fx); + }); + + describe('auth.verifyToken — API key per subject kind', () => { + it('verifies a user-scoped API key (subject = user_*)', async () => { + const info = await fx.auth.verifyToken(fx.userApiKey.secret!); + expect(info.type).toBe('api_key'); + expect(info.subject).toBe(fx.user.id); + expect(info.subject).toMatch(/^user_/); + expect(info.scopes).toEqual(expect.arrayContaining(['cli:read'])); + }); + + it('verifies an org-scoped API key (subject = org_*)', async () => { + const info = await fx.auth.verifyToken(fx.orgApiKey.secret!); + expect(info.type).toBe('api_key'); + expect(info.subject).toBe(fx.org.id); + expect(info.subject).toMatch(/^org_/); + }); + + it('verifies a machine-scoped API key (subject = mch_*)', async () => { + const info = await fx.auth.verifyToken(fx.machineApiKey.secret!); + expect(info.type).toBe('api_key'); + expect(info.subject).toBe(fx.machine.id); + expect(info.subject).toMatch(/^mch_/); + }); + }); + + describe('auth.verifyTokenFromRequest', () => { + it('reads the Bearer header and verifies', async () => { + const req = bearerRequest(fx.userApiKey.secret!); + const info = await fx.auth.verifyTokenFromRequest(req, { accepts: 'api_key' }); + expect(info.subject).toBe(fx.user.id); + expect(info.type).toBe('api_key'); + }); + + it('throws on a missing Authorization header', async () => { + const req = new Request('http://test.local/cli'); + await expect(fx.auth.verifyTokenFromRequest(req)).rejects.toThrow(/Authorization/); + }); + }); + + describe('rejects credentials that are not API keys or OAuth tokens', () => { + it('rejects a raw unrecognized credential', async () => { + await expect(fx.auth.verifyToken('not-a-real-token')).rejects.toThrow(); + }); + + it('rejects an unknown prefix', async () => { + await expect(fx.auth.verifyToken('xyz_unknown_prefix_token')).rejects.toThrow(); + }); + + it('rejects an M2M-prefixed token (mt_*) — m2m is not a CLI credential', async () => { + // Fake mt_ value; we don't need a real M2M token to prove the gate. BAPI rejects it, + // and our code surfaces the rejection. + await expect(fx.auth.verifyToken('mt_not_a_real_m2m_token_value')).rejects.toThrow(); + }); + }); + + describe('auth.resolveAuthInfo (default)', () => { + it('projects subject + claims into Identity', async () => { + const info = await fx.auth.verifyToken(fx.userApiKey.secret!); + const identity = await fx.auth.resolveAuthInfo({ + tokenInfo: info, + request: bearerRequest(fx.userApiKey.secret!), + }); + expect(identity.sub).toBe(fx.user.id); + }); + }); + + describe('handle() end-to-end', () => { + it('200 with Identity body for a user-scoped API key', async () => { + const route = handle({ auth: fx.auth, accepts: 'api_key' }); + const res = await route(bearerRequest(fx.userApiKey.secret!)); + expect(res.status).toBe(200); + const body = (await res.json()) as Identity; + expect(body.sub).toBe(fx.user.id); + }); + + it('200 with Identity body for an org-scoped API key', async () => { + const route = handle({ auth: fx.auth, accepts: 'api_key' }); + const res = await route(bearerRequest(fx.orgApiKey.secret!)); + expect(res.status).toBe(200); + const body = (await res.json()) as Identity; + expect(body.sub).toBe(fx.org.id); + }); + + it('200 with Identity body for a machine-scoped API key', async () => { + const route = handle({ auth: fx.auth, accepts: 'api_key' }); + const res = await route(bearerRequest(fx.machineApiKey.secret!)); + expect(res.status).toBe(200); + const body = (await res.json()) as Identity; + expect(body.sub).toBe(fx.machine.id); + }); + + it('401 when no Authorization header is present', async () => { + const route = handle({ auth: fx.auth, accepts: 'any' }); + const res = await route(new Request('http://test.local/cli')); + expect(res.status).toBe(401); + }); + + it('401 for unsupported credential types', async () => { + const route = handle({ auth: fx.auth, accepts: 'any' }); + const res = await route(bearerRequest('not-a-real-token')); + expect(res.status).toBe(401); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe('not_authenticated'); + }); + + it('honors a custom resolveAuthInfo override', async () => { + const route = handle({ + auth: fx.auth, + accepts: 'api_key', + resolveAuthInfo: ({ tokenInfo }) => ({ + sub: tokenInfo.subject, + custom_field: 'enriched', + }), + }); + const res = await route(bearerRequest(fx.userApiKey.secret!)); + expect(res.status).toBe(200); + const body = (await res.json()) as Identity & { custom_field?: string }; + expect(body.sub).toBe(fx.user.id); + expect(body.custom_field).toBe('enriched'); + }); + + it('honors a custom verifyToken override', async () => { + const route = handle({ + auth: fx.auth, + accepts: 'api_key', + verifyToken: async ({ token, type, clerk }) => { + // Replace the verifier with our own — still using the bound `clerk` to keep this + // real. Asserts the override path correctly receives token + auto-detected type. + expect(type).toBe('api_key'); + const verified = await clerk.apiKeys.verify(token); + return { subject: verified.subject, type, scopes: verified.scopes }; + }, + }); + + const res = await route(bearerRequest(fx.userApiKey.secret!)); + expect(res.status).toBe(200); + const body = (await res.json()) as Identity; + expect(body.sub).toBe(fx.user.id); + }); + }); +}); diff --git a/packages/cli-auth/src/__tests__/integration/setup.ts b/packages/cli-auth/src/__tests__/integration/setup.ts new file mode 100644 index 00000000000..2472d10dc0d --- /dev/null +++ b/packages/cli-auth/src/__tests__/integration/setup.ts @@ -0,0 +1,157 @@ +import { + type APIKey, + type ClerkClient, + createClerkClient, + type Machine, + type Organization, + type User, +} from '@clerk/backend'; + +import { cliAuth, type CliAuthInstance } from '../../server'; + +export const INTEGRATION_SECRET_KEY = process.env.CLERK_SECRET_KEY; +const INTEGRATION_PUBLISHABLE_KEY = process.env.CLERK_PUBLISHABLE_KEY; + +/** Skip the suite when keys aren't configured — CI without them is a no-op. */ +export const skipWhenNoSecret = !INTEGRATION_SECRET_KEY || !INTEGRATION_PUBLISHABLE_KEY; + +/** + * Module-scope Clerk client + cliAuth instance. `createClerkClient` doesn't validate + * keys at construction (only when methods are called), so this is safe to build even + * when the suite is skipped — the factory helpers below just never get called. + * + * `clerk.authenticateRequest` requires *both* keys (publishable for JWKs and audience + * resolution, secret for BAPI calls), which is why we set them here rather than relying + * on the env at call-time. + */ +const clerk: ClerkClient = createClerkClient({ + secretKey: INTEGRATION_SECRET_KEY ?? '', + publishableKey: INTEGRATION_PUBLISHABLE_KEY ?? '', +}); +const auth: CliAuthInstance = cliAuth({ client: clerk }); + +export interface IntegrationFixtures { + clerk: ClerkClient; + auth: CliAuthInstance; + /** Identities used as API key subjects. */ + user: User; + org: Organization; + machine: Machine; + /** API keys, one per supported subject kind. */ + userApiKey: APIKey; + orgApiKey: APIKey; + machineApiKey: APIKey; +} + +// --------------------------------------------------------------------------- +// Factory helpers — small, named primitives. Each call generates its own +// unique slug so reruns in parallel CI shards don't collide on names. Inputs +// other than slug are explicit. +// --------------------------------------------------------------------------- + +function uniqueSlug(): string { + return `${Date.now()}${Math.random().toString(36).slice(2, 8)}`; +} + +async function createTestUser(): Promise { + const slug = uniqueSlug(); + try { + return await clerk.users.createUser({ + username: `cliauthint${slug}`, + password: `Test_${slug}_${Date.now()}`, + skipPasswordChecks: true, + }); + } catch (err) { + // Surface BAPI validation errors so failures are actionable, not opaque. + const detail = (err as { errors?: unknown }).errors; + throw new Error( + `clerk.users.createUser failed: ${(err as Error).message}${detail ? `\n${JSON.stringify(detail, null, 2)}` : ''}`, + ); + } +} + +async function createTestOrg(createdBy: string): Promise { + return clerk.organizations.createOrganization({ + name: `cli-auth integration ${uniqueSlug()}`, + createdBy, + }); +} + +async function createTestMachine(): Promise { + return clerk.machines.create({ name: `cli-auth integration ${uniqueSlug()}` }); +} + +async function createTestApiKey(opts: { subject: string; scopes?: string[] }): Promise { + return clerk.apiKeys.create({ + name: `cli-auth integration ${uniqueSlug()}`, + subject: opts.subject, + scopes: opts.scopes ?? [], + }); +} + +// --------------------------------------------------------------------------- + +/** + * Provision the throwaway Clerk resources we verify against: a user, an org owned by that + * user, and a machine — plus one API key per subject kind (user/org/machine). Shared + * across the suite via `beforeAll` and torn down in `afterAll`. + */ +export async function provisionFixtures(): Promise { + if (!INTEGRATION_SECRET_KEY) { + throw new Error('CLERK_SECRET_KEY is required for integration tests'); + } + + // Wave 1: user and machine are independent; provision in parallel. + const [user, machine] = await Promise.all([createTestUser(), createTestMachine()]); + + // Wave 2: org needs an owner (createdBy = user.id). + const org = await createTestOrg(user.id); + + // Wave 3: one API key per subject kind, all independent. + const [userApiKey, orgApiKey, machineApiKey] = await Promise.all([ + createTestApiKey({ subject: user.id, scopes: ['cli:read'] }), + createTestApiKey({ subject: org.id, scopes: ['cli:read'] }), + createTestApiKey({ subject: machine.id, scopes: ['cli:read'] }), + ]); + + return { clerk, auth, user, org, machine, userApiKey, orgApiKey, machineApiKey }; +} + +/** Tear down everything `provisionFixtures` created. Surfaces any cleanup failures. */ +export async function teardownFixtures(fixtures: IntegrationFixtures | undefined): Promise { + if (!fixtures) { + return; + } + const { user, org, machine, userApiKey, orgApiKey, machineApiKey } = fixtures; + + const results = await Promise.allSettled([ + clerk.apiKeys.delete(userApiKey.id), + clerk.apiKeys.delete(orgApiKey.id), + clerk.apiKeys.delete(machineApiKey.id), + clerk.organizations.deleteOrganization(org.id), + clerk.machines.delete(machine.id), + clerk.users.deleteUser(user.id), + ]); + + const labels = [ + 'userApiKey delete', + 'orgApiKey delete', + 'machineApiKey delete', + 'org delete', + 'machine delete', + 'user delete', + ]; + const failures = results.map((r, i) => ({ r, label: labels[i] })).filter(({ r }) => r.status === 'rejected'); + if (failures.length) { + for (const { r, label } of failures) { + const reason = (r as PromiseRejectedResult).reason; + console.error(`[cli-auth integration] ${label} failed:`, reason); + } + throw new Error(`Integration teardown failed: ${failures.map(f => f.label).join(', ')}`); + } +} + +/** Build a Web `Request` with `Authorization: Bearer ` for handle() tests. */ +export function bearerRequest(token: string, url = 'http://test.local/cli'): Request { + return new Request(url, { headers: { Authorization: `Bearer ${token}` } }); +} diff --git a/packages/cli-auth/src/__tests__/pkce.test.ts b/packages/cli-auth/src/__tests__/pkce.test.ts new file mode 100644 index 00000000000..8ff2cd0f1b5 --- /dev/null +++ b/packages/cli-auth/src/__tests__/pkce.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { generateCodeChallenge, generateCodeVerifier, generateState } from '../lib/pkce'; + +describe('pkce', () => { + it('generates a 43 character base64url verifier', () => { + const verifier = generateCodeVerifier(); + + expect(verifier).toHaveLength(43); + expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it('generates the RFC 7636 S256 challenge vector', async () => { + await expect(generateCodeChallenge('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk')).resolves.toBe( + 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', + ); + }); + + it('generates unique state values', () => { + const states = new Set(Array.from({ length: 100 }, () => generateState())); + + expect(states.size).toBe(100); + for (const state of states) { + expect(state).toMatch(/^[A-Za-z0-9_-]+$/); + } + }); +}); diff --git a/packages/cli-auth/src/__tests__/server-oauth.test.ts b/packages/cli-auth/src/__tests__/server-oauth.test.ts new file mode 100644 index 00000000000..d40ff05ea99 --- /dev/null +++ b/packages/cli-auth/src/__tests__/server-oauth.test.ts @@ -0,0 +1,87 @@ +import type { ClerkClient } from '@clerk/backend'; +import { describe, expect, it, vi } from 'vitest'; + +import { cliAuth } from '../server/cli-auth'; +import { handle } from '../server/handle'; + +function bearer(token: string) { + return new Request('http://test.local/cli', { headers: { Authorization: `Bearer ${token}` } }); +} + +/** + * Build a fake `ClerkClient` whose `authenticateRequest` returns a stub `RequestState` — + * matching the discriminated shape `verify-token.ts` consumes. We only need to model the + * surface the cli-auth verifier touches: `isAuthenticated`, `toAuth()`, `reason`. + */ +function fakeClerkClient( + authResult: + | { isAuthenticated: true; auth: { subject: string; scopes: string[]; tokenType: string; claims?: unknown } } + | { isAuthenticated: false; reason?: string }, +): ClerkClient { + return { + authenticateRequest: vi.fn(async () => { + await Promise.resolve(); + if (authResult.isAuthenticated) { + return { + isAuthenticated: true, + toAuth: () => authResult.auth, + }; + } + return { + isAuthenticated: false, + reason: authResult.reason ?? 'rejected', + }; + }), + } as unknown as ClerkClient; +} + +const FAKE_OAUTH_TOKEN = 'oat_fake_test_token_value_here'; +const FAKE_SUBJECT = 'user_2abcDEFghiJKLmnoPQRstuVWXyz'; + +describe('cli-auth server: oauth_token path (mocked)', () => { + it('verifyToken returns TokenInfo with type=oauth_token', async () => { + const client = fakeClerkClient({ + isAuthenticated: true, + auth: { subject: FAKE_SUBJECT, scopes: ['profile', 'email'], tokenType: 'oauth_token' }, + }); + const auth = cliAuth({ client }); + + const info = await auth.verifyToken(FAKE_OAUTH_TOKEN); + expect(info.type).toBe('oauth_token'); + expect(info.subject).toBe(FAKE_SUBJECT); + expect(info.scopes).toEqual(['profile', 'email']); + }); + + it('handle() returns 200 + Identity body for a verified OAuth token', async () => { + const client = fakeClerkClient({ + isAuthenticated: true, + auth: { subject: FAKE_SUBJECT, scopes: ['profile'], tokenType: 'oauth_token' }, + }); + const auth = cliAuth({ client }); + + const route = handle({ auth, accepts: 'oauth_token' }); + const res = await route(bearer(FAKE_OAUTH_TOKEN)); + expect(res.status).toBe(200); + const body = (await res.json()) as { sub: string }; + expect(body.sub).toBe(FAKE_SUBJECT); + }); + + it('surfaces clerk.authenticateRequest rejection as not_authenticated', async () => { + const client = fakeClerkClient({ isAuthenticated: false, reason: 'OAuth token expired' }); + const auth = cliAuth({ client }); + + await expect(auth.verifyToken(FAKE_OAUTH_TOKEN)).rejects.toThrow(/OAuth token expired/); + }); + + it('preserves api_key claims field when present', async () => { + const client = fakeClerkClient({ + isAuthenticated: true, + auth: { subject: 'user_abc', scopes: ['cli:read'], tokenType: 'api_key', claims: { tenant_id: 'org_xyz' } }, + }); + const auth = cliAuth({ client }); + + const info = await auth.verifyToken('ak_fake_value'); + expect(info.type).toBe('api_key'); + expect(info.claims).toEqual({ tenant_id: 'org_xyz' }); + }); +}); diff --git a/packages/cli-auth/src/clerk-cli-auth.ts b/packages/cli-auth/src/clerk-cli-auth.ts new file mode 100644 index 00000000000..7480e6bb87d --- /dev/null +++ b/packages/cli-auth/src/clerk-cli-auth.ts @@ -0,0 +1,288 @@ +import { spawn } from 'node:child_process'; + +import { ClerkCliAuthError } from './errors'; +import { startAuthServer } from './lib/auth-server'; +import { createCredentialStore } from './lib/credential-store'; +import { generateCodeChallenge, generateCodeVerifier, generateState } from './lib/pkce'; +import { exchangeCodeForTokens, fetchIdentity, refreshAccessToken, revokeToken } from './lib/token-exchange'; +import { verifyToken as verifyTokenRequest } from './lib/verify-token'; +import type { + ClerkCliAuthConfig, + CredentialStore, + Identity, + LoginResult, + OAuthScope, + TokenSet, + TokenSource, + UserIdentity, +} from './types'; + +const DEFAULT_SCOPES: OAuthScope[] = ['profile', 'email', 'openid', 'offline_access']; + +function normalizeIssuer(issuer: string): string { + const normalized = issuer.trim().replace(/\/+$/, ''); + try { + const url = new URL(normalized); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error('issuer must use http or https'); + } + return normalized; + } catch (error) { + throw new ClerkCliAuthError('config', `issuer must be a valid URL: ${(error as Error).message}`); + } +} + +function storageError(operation: string, error: unknown): ClerkCliAuthError { + if (error instanceof ClerkCliAuthError) { + return error; + } + const detail = error instanceof Error ? error.message : String(error); + return new ClerkCliAuthError('storage', `Failed to ${operation}: ${detail}`); +} + +async function openBrowserFallback(url: string): Promise { + console.log(`Open this URL to sign in:\n${url}`); + + const platform = process.platform; + const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open'; + const args = platform === 'win32' ? ['/c', 'start', '', url] : [url]; + + await new Promise(resolve => { + const child = spawn(command, args, { detached: true, stdio: 'ignore' }); + child.once('error', () => resolve()); + child.unref(); + resolve(); + }); +} + +export class ClerkCliAuth { + private readonly config: Required< + Omit + > & { + storage: CredentialStore; + openBrowser?: (url: string) => Promise; + identityEndpoint?: string; + tokenEnvVar?: string; + }; + + constructor(config: ClerkCliAuthConfig) { + if (!config.clientId) { + throw new ClerkCliAuthError('config', 'clientId is required'); + } + if (!config.issuer) { + throw new ClerkCliAuthError('config', 'issuer is required'); + } + + const environment = config.environment ?? 'default'; + const storage = + typeof config.storage === 'object' && config.storage !== null + ? config.storage + : createCredentialStore(config.storage ?? 'keychain', { + environment, + keychainService: config.keychainService, + }); + + this.config = { + clientId: config.clientId, + issuer: normalizeIssuer(config.issuer), + scopes: config.scopes ?? DEFAULT_SCOPES, + storage, + keychainService: config.keychainService ?? 'clerk-cli-auth', + environment, + callbackPort: config.callbackPort ?? 0, + loginTimeoutMs: config.loginTimeoutMs ?? 120_000, + requestTimeoutMs: config.requestTimeoutMs ?? 30_000, + openBrowser: config.openBrowser, + identityEndpoint: config.identityEndpoint, + tokenEnvVar: config.tokenEnvVar, + }; + } + + async login(): Promise { + const codeVerifier = generateCodeVerifier(); + const codeChallenge = await generateCodeChallenge(codeVerifier); + const state = generateState(); + + const server = await startAuthServer({ + expectedState: state, + port: this.config.callbackPort, + timeoutMs: this.config.loginTimeoutMs, + }); + + try { + const authorizeUrl = new URL(`${this.config.issuer}/oauth/authorize`); + authorizeUrl.searchParams.set('response_type', 'code'); + authorizeUrl.searchParams.set('client_id', this.config.clientId); + authorizeUrl.searchParams.set('redirect_uri', server.redirectUri); + authorizeUrl.searchParams.set('scope', this.config.scopes.join(' ')); + authorizeUrl.searchParams.set('state', state); + authorizeUrl.searchParams.set('code_challenge', codeChallenge); + authorizeUrl.searchParams.set('code_challenge_method', 'S256'); + + try { + await (this.config.openBrowser ?? openBrowserFallback)(authorizeUrl.toString()); + } catch (error) { + throw new ClerkCliAuthError('config', `Failed to open authorization URL: ${(error as Error).message}`); + } + + const { code } = await server.waitForCallback(); + const tokens = await exchangeCodeForTokens({ + issuer: this.config.issuer, + clientId: this.config.clientId, + code, + codeVerifier, + redirectUri: server.redirectUri, + timeoutMs: this.config.requestTimeoutMs, + }); + + await this.setJson('tokens', tokens); + const user = await fetchIdentity({ + issuer: this.config.issuer, + accessToken: tokens.accessToken, + timeoutMs: this.config.requestTimeoutMs, + }); + + return { tokens, user }; + } finally { + server.close(); + } + } + + async getAccessToken(): Promise { + const tokens = await this.getTokenSet(); + if (!tokens) { + return null; + } + + const expiresAt = tokens.expiresAt ?? Number.POSITIVE_INFINITY; + if (expiresAt >= Date.now() + 30_000) { + return tokens.accessToken; + } + + if (!tokens.refreshToken) { + return null; + } + + const refreshed = await refreshAccessToken({ + issuer: this.config.issuer, + clientId: this.config.clientId, + refreshToken: tokens.refreshToken, + scopes: this.config.scopes, + timeoutMs: this.config.requestTimeoutMs, + }); + const nextTokens = { + ...tokens, + ...refreshed, + refreshToken: refreshed.refreshToken ?? tokens.refreshToken, + }; + await this.setJson('tokens', nextTokens); + return nextTokens.accessToken; + } + + async whoami(): Promise { + const accessToken = await this.getAccessToken(); + if (!accessToken) { + return null; + } + + return fetchIdentity({ + issuer: this.config.issuer, + accessToken, + timeoutMs: this.config.requestTimeoutMs, + }); + } + + async verifyToken(token: string): Promise { + if (!this.config.identityEndpoint) { + throw new ClerkCliAuthError('config', 'identityEndpoint is not configured.'); + } + return verifyTokenRequest({ + endpoint: this.config.identityEndpoint, + token, + timeoutMs: this.config.requestTimeoutMs, + }); + } + + /** + * Pick the credential the CLI should send. Returns the token plus where it came from, + * which lets callers branch on the *trust model* (refreshable OAuth vs. user-supplied + * env/arg) instead of having to introspect the bearer's shape. + * + * Resolution order: `tokenFromArg` → `tokenEnvVar` env var → cached OAuth session. + */ + async resolveToken(opts: { tokenFromArg?: string } = {}): Promise<{ token: string; source: TokenSource }> { + if (opts.tokenFromArg) { + return { token: opts.tokenFromArg, source: 'arg' }; + } + if (this.config.tokenEnvVar) { + const fromEnv = process.env[this.config.tokenEnvVar]; + if (fromEnv) { + return { token: fromEnv, source: 'env' }; + } + } + const accessToken = await this.getAccessToken(); + if (accessToken) { + return { token: accessToken, source: 'oauth' }; + } + + const envHint = this.config.tokenEnvVar ? ` or set $${this.config.tokenEnvVar}` : ''; + throw new ClerkCliAuthError('not_authenticated', `Not logged in. Run \`auth login\`${envHint}.`); + } + + async logout(options: { revoke?: boolean } = {}): Promise { + const shouldRevoke = options.revoke ?? true; + if (shouldRevoke) { + const tokens = await this.getTokenSet().catch(() => null); + const token = tokens?.refreshToken ?? tokens?.accessToken; + if (tokens && token) { + try { + await revokeToken({ + issuer: this.config.issuer, + clientId: this.config.clientId, + token, + tokenTypeHint: tokens.refreshToken ? 'refresh_token' : 'access_token', + timeoutMs: this.config.requestTimeoutMs, + }); + } catch { + // Revoke is best-effort — local cleanup proceeds regardless. + } + } + } + + try { + await this.config.storage.delete('tokens'); + } catch (error) { + throw storageError('clear stored credentials', error); + } + } + + async getTokenSet(): Promise { + return this.getJson('tokens'); + } + + private async getJson(key: string): Promise { + let raw: string | null; + try { + raw = await this.config.storage.get(key); + } catch (error) { + throw storageError(`read ${key}`, error); + } + if (!raw) { + return null; + } + + try { + return JSON.parse(raw) as T; + } catch (error) { + throw storageError(`parse stored ${key}`, error); + } + } + + private async setJson(key: string, value: unknown): Promise { + try { + await this.config.storage.set(key, JSON.stringify(value)); + } catch (error) { + throw storageError(`write ${key}`, error); + } + } +} diff --git a/packages/cli-auth/src/errors.ts b/packages/cli-auth/src/errors.ts new file mode 100644 index 00000000000..096d0d50f91 --- /dev/null +++ b/packages/cli-auth/src/errors.ts @@ -0,0 +1,38 @@ +export const EXIT_CODE = { + SUCCESS: 0, + GENERAL: 1, + USAGE: 2, + SIGINT: 130, +} as const; + +export type ExitCode = (typeof EXIT_CODE)[keyof typeof EXIT_CODE]; + +export type ErrorCode = + | 'not_authenticated' + | 'config' + | 'storage' + | 'token_exchange' + | 'userinfo' + | 'revoke' + | 'timeout' + | 'verify_api_key'; + +export interface ClerkCliAuthErrorOptions { + exitCode?: ExitCode; + cause?: unknown; +} + +export class ClerkCliAuthError extends Error { + code: ErrorCode | (string & {}); + exitCode: ExitCode; + constructor(code: ErrorCode | (string & {}), message: string, options?: ClerkCliAuthErrorOptions) { + super(message, options?.cause ? { cause: options.cause } : undefined); + this.name = 'ClerkCliAuthError'; + this.code = code; + this.exitCode = options?.exitCode ?? EXIT_CODE.GENERAL; + } +} + +export const isClerkCliAuthError = (error: unknown): error is ClerkCliAuthError => { + return error instanceof ClerkCliAuthError; +}; diff --git a/packages/cli-auth/src/index.ts b/packages/cli-auth/src/index.ts new file mode 100644 index 00000000000..a54599f1d8b --- /dev/null +++ b/packages/cli-auth/src/index.ts @@ -0,0 +1,17 @@ +/** + * Public entry point for @clerk/cli-auth. + * + * The main class is `ClerkCliAuth` — instantiate with config, then: + * - await auth.login() → opens browser, starts localhost callback, exchanges code, stores tokens + * - await auth.getAccessToken() → returns cached token, refreshes if expired + * - await auth.whoami() → calls /oauth/userinfo with cached token + * - await auth.logout() → clears stored tokens (and revokes at issuer by default) + * + * Implementation lives in ./clerk-cli-auth.ts and ./lib/*. + */ + +export { ClerkCliAuth } from './clerk-cli-auth'; +export * from './types'; +export * from './errors'; +export { fetchIdentity, revokeToken } from './lib/token-exchange'; +export { verifyToken } from './lib/verify-token'; diff --git a/packages/cli-auth/src/lib/auth-server.ts b/packages/cli-auth/src/lib/auth-server.ts new file mode 100644 index 00000000000..1bdd79e3c9b --- /dev/null +++ b/packages/cli-auth/src/lib/auth-server.ts @@ -0,0 +1,170 @@ +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { ClerkCliAuthError } from '../errors'; + +export interface AuthServerOptions { + expectedState: string; + port?: number; + timeoutMs?: number; + successHtml?: string; + errorHtml?: string; +} + +export interface AuthServerHandle { + port: number; + redirectUri: string; + waitForCallback(): Promise<{ code: string; state: string }>; + close(): void; +} + +const DEFAULT_SUCCESS_HTML = ` + +Authentication complete +

Authentication complete

You can close this tab and return to your terminal.

+`; + +const DEFAULT_ERROR_HTML = ` + +Authentication failed +

Authentication failed

You can close this tab and return to your terminal.

+`; + +function oauthCallbackError(code: string, message: string): ClerkCliAuthError { + return new ClerkCliAuthError(code, message); +} + +export function startAuthServer(options: AuthServerOptions): Promise { + const { + expectedState, + port = 0, + timeoutMs = 120_000, + successHtml = DEFAULT_SUCCESS_HTML, + errorHtml = DEFAULT_ERROR_HTML, + } = options; + + let timeout: NodeJS.Timeout | undefined; + let settled = false; + let closed = false; + let resolveCallback!: (value: { code: string; state: string }) => void; + let rejectCallback!: (reason: ClerkCliAuthError) => void; + + const callbackPromise = new Promise<{ code: string; state: string }>((resolve, reject) => { + resolveCallback = resolve; + rejectCallback = reject; + }); + + const closeListening = (server: Server) => { + if (closed) { + return; + } + closed = true; + server.close(); + }; + + const server = createServer((req, res) => { + if (settled) { + res.writeHead(410, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Authentication callback already handled.'); + return; + } + + if (req.method !== 'GET' || !req.url) { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not found'); + return; + } + + const url = new URL(req.url, 'http://127.0.0.1'); + if (url.pathname !== '/callback') { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Clerk CLI auth server is waiting for /callback.'); + return; + } + + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const error = url.searchParams.get('error'); + const errorDescription = url.searchParams.get('error_description') ?? error; + + const settle = (statusCode: number, html: string, errorToReject?: ClerkCliAuthError) => { + settled = true; + if (timeout) { + clearTimeout(timeout); + } + res.writeHead(statusCode, { + 'Content-Type': 'text/html; charset=utf-8', + }); + res.end(html, () => { + if (errorToReject) { + rejectCallback(errorToReject); + } else if (code && state) { + resolveCallback({ code, state }); + } + closeListening(server); + }); + }; + + if (error) { + settle( + 400, + errorHtml, + oauthCallbackError('token_exchange', `OAuth authorization failed: ${errorDescription ?? 'unknown error'}`), + ); + return; + } + + if (state !== expectedState) { + settle(400, errorHtml, oauthCallbackError('state_mismatch', 'OAuth callback state did not match.')); + return; + } + + if (!code) { + settle( + 400, + errorHtml, + oauthCallbackError('token_exchange', 'OAuth callback did not include an authorization code.'), + ); + return; + } + + settle(200, successHtml); + }); + + return new Promise((resolve, reject) => { + server.once('error', error => { + reject(new ClerkCliAuthError('config', `Failed to start local auth callback server: ${error.message}`)); + }); + + server.listen(port, '127.0.0.1', () => { + const address = server.address() as AddressInfo; + const actualPort = address.port; + const redirectUri = `http://127.0.0.1:${actualPort}/callback`; + + timeout = setTimeout(() => { + if (settled) { + return; + } + settled = true; + rejectCallback(new ClerkCliAuthError('timeout', `OAuth callback timed out after ${timeoutMs}ms.`)); + closeListening(server); + }, timeoutMs); + + resolve({ + port: actualPort, + redirectUri, + waitForCallback: () => callbackPromise, + close: () => { + if (timeout) { + clearTimeout(timeout); + } + if (!settled) { + settled = true; + rejectCallback(new ClerkCliAuthError('timeout', 'OAuth callback server was closed.')); + } + closeListening(server); + }, + }); + }); + }); +} diff --git a/packages/cli-auth/src/lib/classify-token.ts b/packages/cli-auth/src/lib/classify-token.ts new file mode 100644 index 00000000000..2c0380eb237 --- /dev/null +++ b/packages/cli-auth/src/lib/classify-token.ts @@ -0,0 +1,56 @@ +import { type MachineTokenType, TokenType } from '@clerk/backend/internal'; +import { decodeJwt } from '@clerk/backend/jwt'; + +import { ClerkCliAuthError } from '../errors'; + +/** + * Token kinds cli-auth accepts as CLI credentials. Narrower than `@clerk/backend`'s + * `MachineTokenType` — we drop `m2m_token` because M2M tokens are minted by machines for + * server-to-server BAPI calls, not for a CLI user to send to a backend. For machine + * identities, use an API key with a `mch_*` subject instead. + */ +export type TokenKind = Exclude; + +const API_KEY_PREFIX = 'ak_'; +const OAUTH_TOKEN_PREFIX = 'oat_'; +const JWT_FORMAT = /^[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+$/; +/** RFC 9068 OAuth 2.0 access token `typ` header values. */ +const OAUTH_JWT_TYP_VALUES = ['at+jwt', 'application/at+jwt'] as const; + +function isJwtFormat(token: string): boolean { + return JWT_FORMAT.test(token); +} + +/** + * Classify a credential as an API key or an OAuth access token by prefix / JWT shape: + * + * - `ak_*` → `'api_key'` (user/org/machine API keys all share this prefix) + * - `oat_*` or JWT with `typ: at+jwt` (RFC 9068) → `'oauth_token'` + * + * Throws on M2M tokens (`mt_*` or `mch_` subject JWT) and on any unknown shape — M2M is + * intentionally rejected at the CLI boundary. + */ +export function classifyToken(token: string): TokenKind { + if (token.startsWith(API_KEY_PREFIX)) { + return TokenType.ApiKey; + } + if (token.startsWith(OAUTH_TOKEN_PREFIX)) { + return TokenType.OAuthToken; + } + if (isJwtFormat(token)) { + const result = decodeJwt(token) as { + data?: { header: { typ?: unknown } }; + errors?: unknown; + }; + if (!result.errors && result.data) { + const typ = result.data.header.typ; + if (typeof typ === 'string' && (OAUTH_JWT_TYP_VALUES as readonly string[]).includes(typ)) { + return TokenType.OAuthToken; + } + } + } + throw new ClerkCliAuthError( + 'not_authenticated', + 'Unsupported credential. Expected an API key or OAuth access token.', + ); +} diff --git a/packages/cli-auth/src/lib/credential-store.ts b/packages/cli-auth/src/lib/credential-store.ts new file mode 100644 index 00000000000..5ffd8936e15 --- /dev/null +++ b/packages/cli-auth/src/lib/credential-store.ts @@ -0,0 +1,216 @@ +import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { ClerkCliAuthError } from '../errors'; +import type { CredentialStore, StorageKind } from '../types'; + +export interface CreateStoreOptions { + keychainService?: string; + environment?: string; + filePath?: string; +} + +// keyring is imported lazily in loadKeyring() so the native binding can fail soft. +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +type KeyringModule = typeof import('@napi-rs/keyring'); + +function storageError(message: string, error: unknown): ClerkCliAuthError { + if (error instanceof ClerkCliAuthError) { + return error; + } + const detail = error instanceof Error ? error.message : String(error); + return new ClerkCliAuthError('storage', `${message}: ${detail}`); +} + +function environmentName(options?: CreateStoreOptions): string { + return options?.environment ?? 'default'; +} + +function defaultFilePath(environment: string): string { + return join(homedir(), '.config', 'clerk-cli-auth', `${environment}.json`); +} + +class MemoryCredentialStore implements CredentialStore { + private readonly values = new Map(); + + async get(key: string): Promise { + await Promise.resolve(); + return this.values.get(key) ?? null; + } + + async set(key: string, value: string): Promise { + await Promise.resolve(); + this.values.set(key, value); + } + + async delete(key: string): Promise { + await Promise.resolve(); + this.values.delete(key); + } +} + +class FileCredentialStore implements CredentialStore { + private queue: Promise = Promise.resolve(); + + constructor(private readonly filePath: string) {} + + private enqueue(operation: () => Promise): Promise { + const next = this.queue.then(operation, operation); + this.queue = next.catch(() => undefined); + return next; + } + + private async readAll(): Promise> { + try { + const content = await readFile(this.filePath, 'utf8'); + if (!content.trim()) { + return {}; + } + const parsed = JSON.parse(content) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {}; + } + + const result: Record = {}; + for (const [key, value] of Object.entries(parsed)) { + if (typeof value === 'string') { + result[key] = value; + } + } + return result; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return {}; + } + throw storageError(`Failed to read credential file ${this.filePath}`, error); + } + } + + private async writeAll(values: Record): Promise { + try { + await mkdir(dirname(this.filePath), { recursive: true }); + await writeFile(this.filePath, `${JSON.stringify(values, null, 2)}\n`, { + mode: 0o600, + }); + await chmod(this.filePath, 0o600); + } catch (error) { + throw storageError(`Failed to write credential file ${this.filePath}`, error); + } + } + + async get(key: string): Promise { + return this.enqueue(async () => { + const values = await this.readAll(); + return values[key] ?? null; + }); + } + + async set(key: string, value: string): Promise { + return this.enqueue(async () => { + const values = await this.readAll(); + values[key] = value; + await this.writeAll(values); + }); + } + + async delete(key: string): Promise { + return this.enqueue(async () => { + const values = await this.readAll(); + delete values[key]; + await this.writeAll(values); + }); + } +} + +class KeychainCredentialStore implements CredentialStore { + private keyringPromise: Promise | null = null; + + constructor( + private readonly service: string, + private readonly environment: string, + private readonly fallback: CredentialStore, + ) {} + + private account(key: string): string { + return `${this.environment}:${key}`; + } + + private warnFallback(operation: string, error: unknown): void { + const detail = error instanceof Error ? error.message : String(error); + console.warn(`Keychain ${operation} failed; falling back to file store. ${detail}`); + } + + private async loadKeyring(): Promise { + this.keyringPromise ??= import('@napi-rs/keyring').catch((error: unknown) => { + this.warnFallback('initialization', error); + return null; + }); + return this.keyringPromise; + } + + async get(key: string): Promise { + try { + const keyring = await this.loadKeyring(); + if (!keyring) { + return this.fallback.get(key); + } + + const entry = new keyring.Entry(this.service, this.account(key)); + return entry.getPassword() ?? (await this.fallback.get(key)); + } catch (error) { + this.warnFallback('get', error); + return this.fallback.get(key); + } + } + + async set(key: string, value: string): Promise { + try { + const keyring = await this.loadKeyring(); + if (!keyring) { + await this.fallback.set(key, value); + return; + } + + const entry = new keyring.Entry(this.service, this.account(key)); + entry.setPassword(value); + // Clear any stale fallback so a later keychain failure can't resurrect an old value. + await this.fallback.delete(key).catch(() => undefined); + } catch (error) { + this.warnFallback('set', error); + await this.fallback.set(key, value); + } + } + + async delete(key: string): Promise { + try { + const keyring = await this.loadKeyring(); + if (keyring) { + const entry = new keyring.Entry(this.service, this.account(key)); + entry.deletePassword(); + } + } catch (error) { + this.warnFallback('delete', error); + } + await this.fallback.delete(key); + } +} + +function createFileStore(options?: CreateStoreOptions): CredentialStore { + const environment = environmentName(options); + return new FileCredentialStore(options?.filePath ?? defaultFilePath(environment)); +} + +export function createCredentialStore(kind: StorageKind, options?: CreateStoreOptions): CredentialStore { + if (kind === 'memory') { + return new MemoryCredentialStore(); + } + if (kind === 'file') { + return createFileStore(options); + } + + const fallback = createFileStore(options); + const service = options?.keychainService ?? 'clerk-cli-auth'; + const environment = environmentName(options); + return new KeychainCredentialStore(service, environment, fallback); +} diff --git a/packages/cli-auth/src/lib/http.ts b/packages/cli-auth/src/lib/http.ts new file mode 100644 index 00000000000..90297523d99 --- /dev/null +++ b/packages/cli-auth/src/lib/http.ts @@ -0,0 +1,95 @@ +import { ClerkCliAuthError, type ErrorCode } from '../errors'; + +export interface RequestOptions extends Omit { + /** Error code to attach when the request fails (network, timeout, non-2xx, parse). */ + errorCode: ErrorCode; + /** Per-request timeout in ms. Defaults to 30_000. */ + timeoutMs?: number; + /** Optional caller signal — aborts merge with the internal timeout. */ + signal?: AbortSignal; +} + +const DEFAULT_TIMEOUT_MS = 30_000; + +async function parseBody(response: Response): Promise { + const contentType = response.headers.get('content-type') ?? ''; + if (contentType.includes('application/json')) { + return response.json(); + } + return response.text(); +} + +function messageFromBody(body: unknown, fallback: string): string { + if (typeof body === 'string' && body.trim()) { + return body.trim(); + } + if (body && typeof body === 'object') { + const record = body as Record; + for (const key of ['error_description', 'message', 'error']) { + const value = record[key]; + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + } + } + return fallback; +} + +function linkSignals(externalSignal: AbortSignal | undefined, controller: AbortController): () => void { + if (!externalSignal) { + return () => undefined; + } + if (externalSignal.aborted) { + controller.abort(externalSignal.reason); + return () => undefined; + } + const onAbort = () => controller.abort(externalSignal.reason); + externalSignal.addEventListener('abort', onAbort, { once: true }); + return () => externalSignal.removeEventListener('abort', onAbort); +} + +/** + * Shared HTTP helper: applies a per-request timeout via AbortController, parses the body, + * and maps every failure mode to a ClerkCliAuthError tagged with the caller's errorCode. + * Timeout aborts always surface as ClerkCliAuthError('timeout'). + */ +export async function request(url: string, options: RequestOptions): Promise<{ response: Response; body: unknown }> { + const { errorCode, timeoutMs = DEFAULT_TIMEOUT_MS, signal, ...init } = options; + + const controller = new AbortController(); + const timeoutHandle = setTimeout( + () => controller.abort(new Error(`Request timed out after ${timeoutMs}ms`)), + timeoutMs, + ); + const unlinkSignal = linkSignals(signal, controller); + + let response: Response; + try { + response = await fetch(url, { ...init, signal: controller.signal }); + } catch (error) { + const aborted = controller.signal.aborted && signal?.aborted !== true; + if (aborted) { + throw new ClerkCliAuthError('timeout', `Request to ${url} timed out after ${timeoutMs}ms.`); + } + throw new ClerkCliAuthError(errorCode, `Request to ${url} failed: ${(error as Error).message}`); + } finally { + clearTimeout(timeoutHandle); + unlinkSignal(); + } + + let body: unknown; + try { + body = await parseBody(response); + } catch (error) { + throw new ClerkCliAuthError(errorCode, `Response from ${url} could not be parsed: ${(error as Error).message}`); + } + + if (!response.ok) { + throw new ClerkCliAuthError( + errorCode, + messageFromBody(body, `Request to ${url} failed with HTTP ${response.status}.`), + ); + } + + return { response, body }; +} diff --git a/packages/cli-auth/src/lib/pkce.ts b/packages/cli-auth/src/lib/pkce.ts new file mode 100644 index 00000000000..0e2063f68da --- /dev/null +++ b/packages/cli-auth/src/lib/pkce.ts @@ -0,0 +1,18 @@ +import { createHash, randomBytes } from 'node:crypto'; + +function base64Url(buffer: Buffer): string { + return buffer.toString('base64url'); +} + +export function generateCodeVerifier(): string { + return base64Url(randomBytes(32)); +} + +export async function generateCodeChallenge(verifier: string): Promise { + await Promise.resolve(); + return base64Url(createHash('sha256').update(verifier).digest()); +} + +export function generateState(): string { + return base64Url(randomBytes(32)); +} diff --git a/packages/cli-auth/src/lib/token-exchange.ts b/packages/cli-auth/src/lib/token-exchange.ts new file mode 100644 index 00000000000..5f267e8a59c --- /dev/null +++ b/packages/cli-auth/src/lib/token-exchange.ts @@ -0,0 +1,155 @@ +import { ClerkCliAuthError } from '../errors'; +import type { TokenSet, UserIdentity } from '../types'; +import { request } from './http'; + +export interface ExchangeParams { + issuer: string; + clientId: string; + code: string; + codeVerifier: string; + redirectUri: string; + timeoutMs?: number; +} + +export interface RefreshParams { + issuer: string; + clientId: string; + refreshToken: string; + scopes?: string[]; + timeoutMs?: number; +} + +export interface FetchIdentityParams { + issuer: string; + accessToken: string; + timeoutMs?: number; +} + +export interface RevokeParams { + issuer: string; + clientId: string; + token: string; + tokenTypeHint?: 'access_token' | 'refresh_token'; + timeoutMs?: number; +} + +interface OAuthTokenResponse { + access_token?: unknown; + refresh_token?: unknown; + id_token?: unknown; + expires_in?: unknown; + scope?: unknown; + token_type?: unknown; +} + +function endpoint(issuer: string, path: string): string { + return `${issuer.replace(/\/+$/, '')}${path}`; +} + +function mapTokenResponse(data: OAuthTokenResponse): TokenSet { + if (typeof data.access_token !== 'string' || data.access_token.length === 0) { + throw new ClerkCliAuthError('token_exchange', 'Token response did not include access_token.'); + } + + const tokenSet: TokenSet = { + accessToken: data.access_token, + }; + + if (typeof data.refresh_token === 'string') { + tokenSet.refreshToken = data.refresh_token; + } + if (typeof data.id_token === 'string') { + tokenSet.idToken = data.id_token; + } + if (typeof data.scope === 'string') { + tokenSet.scope = data.scope; + } + if (typeof data.token_type === 'string') { + tokenSet.tokenType = data.token_type; + } + if (typeof data.expires_in === 'number') { + tokenSet.expiresAt = Date.now() + data.expires_in * 1000; + } + + return tokenSet; +} + +async function requestTokens(issuer: string, body: URLSearchParams, timeoutMs?: number): Promise { + const { body: parsed } = await request(endpoint(issuer, '/oauth/token'), { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + errorCode: 'token_exchange', + timeoutMs, + }); + + if (!parsed || typeof parsed !== 'object') { + throw new ClerkCliAuthError('token_exchange', 'Token response was not JSON.'); + } + + return mapTokenResponse(parsed as OAuthTokenResponse); +} + +export async function exchangeCodeForTokens(params: ExchangeParams): Promise { + const body = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: params.clientId, + code: params.code, + code_verifier: params.codeVerifier, + redirect_uri: params.redirectUri, + }); + + return requestTokens(params.issuer, body, params.timeoutMs); +} + +export async function refreshAccessToken(params: RefreshParams): Promise { + const body = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: params.clientId, + refresh_token: params.refreshToken, + }); + + if (params.scopes?.length) { + body.set('scope', params.scopes.join(' ')); + } + + return requestTokens(params.issuer, body, params.timeoutMs); +} + +export async function revokeToken(params: RevokeParams): Promise { + const body = new URLSearchParams({ + client_id: params.clientId, + token: params.token, + }); + if (params.tokenTypeHint) { + body.set('token_type_hint', params.tokenTypeHint); + } + + await request(endpoint(params.issuer, '/oauth/token/revoke'), { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + errorCode: 'revoke', + timeoutMs: params.timeoutMs, + }); +} + +export async function fetchIdentity(params: FetchIdentityParams): Promise { + const { body: parsed } = await request(endpoint(params.issuer, '/oauth/userinfo'), { + headers: { Authorization: `Bearer ${params.accessToken}` }, + errorCode: 'userinfo', + timeoutMs: params.timeoutMs, + }); + + if (!parsed || typeof parsed !== 'object') { + throw new ClerkCliAuthError('userinfo', 'Userinfo response was not JSON.'); + } + + // `/oauth/userinfo` returns a user subject per OAuth/OIDC spec. + const identity = parsed as UserIdentity; + if (typeof identity.sub !== 'string' || identity.sub.length === 0) { + throw new ClerkCliAuthError('userinfo', 'Userinfo response did not include sub.'); + } + + return identity; +} diff --git a/packages/cli-auth/src/lib/verify-token.ts b/packages/cli-auth/src/lib/verify-token.ts new file mode 100644 index 00000000000..7fd80bbd5ea --- /dev/null +++ b/packages/cli-auth/src/lib/verify-token.ts @@ -0,0 +1,33 @@ +import { ClerkCliAuthError } from '../errors'; +import type { Identity } from '../types'; +import { request } from './http'; + +export interface VerifyTokenParams { + endpoint: string; + token: string; + timeoutMs?: number; +} + +/** + * POST a credential (API key or OAuth access token) to a consumer-hosted `identityEndpoint` + * and return the verified `Identity`. The endpoint is responsible for verifying the + * credential server-side. + */ +export async function verifyToken(params: VerifyTokenParams): Promise { + const { body: parsed } = await request(params.endpoint, { + headers: { Authorization: `Bearer ${params.token}` }, + errorCode: 'verify_api_key', + timeoutMs: params.timeoutMs, + }); + + if (!parsed || typeof parsed !== 'object') { + throw new ClerkCliAuthError('verify_api_key', 'Identity endpoint response was not a JSON object.'); + } + + const identity = parsed as Identity; + if (typeof identity.sub !== 'string' || !identity.sub) { + throw new ClerkCliAuthError('verify_api_key', 'Identity endpoint response did not include sub.'); + } + + return identity; +} diff --git a/packages/cli-auth/src/server/cli-auth.ts b/packages/cli-auth/src/server/cli-auth.ts new file mode 100644 index 00000000000..73023a14e79 --- /dev/null +++ b/packages/cli-auth/src/server/cli-auth.ts @@ -0,0 +1,123 @@ +import { type ClerkClient, type ClerkOptions, createClerkClient } from '@clerk/backend'; + +import { ClerkCliAuthError } from '../errors'; +import type { TokenKind } from '../lib/classify-token'; +import { resolveAuthInfo as defaultResolveAuthInfo } from './resolve-auth'; +import type { + AcceptsToken, + CliAuthFactoryOptions, + CliAuthInstance, + ClientArg, + ResolveAuthInfoContext, + TokenInfo, +} from './types'; +import { verifyTokenWithClerk } from './verify-token'; + +/** Synthesize a Request so the bare-token verifier can reuse `clerk.authenticateRequest`. */ +function requestForToken(token: string): Request { + return new Request('http://cli-auth.local/verify', { headers: { Authorization: `Bearer ${token}` } }); +} + +/** Build a getClerk thunk for the given factory options, with a single-flight cache. */ +function makeClerkGetter( + client: ClientArg | undefined, + clientConfig: ClerkOptions | undefined, +): () => Promise { + let cached: ClerkClient | null = null; + let pending: Promise | null = null; + + return async function getClerk(): Promise { + if (cached) { + return cached; + } + if (pending) { + return pending; + } + + pending = (async () => { + if (client) { + const resolved = typeof client === 'function' ? await client() : client; + cached = resolved; + return resolved; + } + if (clientConfig) { + cached = createClerkClient(clientConfig); + return cached; + } + const secretKey = process.env.CLERK_SECRET_KEY; + if (!secretKey) { + throw new ClerkCliAuthError( + 'config', + 'cliAuth() needs a Clerk Backend client. Pass `client`, `clientConfig`, or set CLERK_SECRET_KEY in the env.', + ); + } + cached = createClerkClient({ secretKey }); + return cached; + })(); + + try { + return await pending; + } finally { + pending = null; + } + }; +} + +/** + * Factory: bind a Clerk Backend client (via `client`, `clientConfig`, or auto-built from + * `CLERK_SECRET_KEY`) and return an instance with verifier helpers. Pair the instance with + * the standalone {@link handle} export to wire up route handlers. + * + * `client` accepts either a resolved `ClerkClient` or a factory function (sync or async) + * — pass `@clerk/nextjs/server`'s `clerkClient` directly, no top-level `await` required. + * + * @example + * ```ts + * // lib/clerk-cli.ts + * import { cliAuth } from '@clerk/cli-auth/server'; + * import { clerkClient } from '@clerk/nextjs/server'; + * + * export const auth = cliAuth({ client: clerkClient }); + * + * // app/api/cli/verify/route.ts + * import { handle } from '@clerk/cli-auth/server'; + * import { auth } from '@/lib/clerk-cli'; + * + * export const GET = handle({ auth, accepts: ['api_key', 'oauth_token'] }); + * + * // Or use the primitive directly inside a custom protected route: + * const tokenInfo = await auth.verifyTokenFromRequest(request, { accepts: 'api_key' }); + * ``` + */ +export function cliAuth(options: CliAuthFactoryOptions = {}): CliAuthInstance { + const getClerk = makeClerkGetter(options.client, options.clientConfig); + + async function verifyTokenFromRequest( + request: Request, + verifyOptions?: { accepts?: AcceptsToken }, + ): Promise> { + const clerk = await getClerk(); + const info = await verifyTokenWithClerk(request, { accepts: verifyOptions?.accepts, clerk }); + return info as TokenInfo; + } + + async function verifyToken( + token: string, + verifyOptions?: { accepts?: AcceptsToken }, + ): Promise> { + return verifyTokenFromRequest(requestForToken(token), verifyOptions); + } + + function resolveAuthInfo( + ctx: Omit, 'clerk'> & { clerk?: ClerkClient }, + ): ReturnType { + return defaultResolveAuthInfo(ctx as ResolveAuthInfoContext); + } + + return { + verifyToken, + verifyTokenFromRequest, + resolveAuthInfo, + getClerk, + }; +} diff --git a/packages/cli-auth/src/server/handle.ts b/packages/cli-auth/src/server/handle.ts new file mode 100644 index 00000000000..7ecac9331ba --- /dev/null +++ b/packages/cli-auth/src/server/handle.ts @@ -0,0 +1,87 @@ +import { ClerkCliAuthError, EXIT_CODE } from '../errors'; +import { classifyToken, type TokenKind } from '../lib/classify-token'; +import { resolveAuthInfo as defaultResolveAuthInfo, validateIdentity } from './resolve-auth'; +import type { HandleOptions, TokenInfo } from './types'; +import { readBearer } from './verify-token'; + +function statusFor(code: string): number { + switch (code) { + case 'not_authenticated': + case 'verify_api_key': + case 'userinfo': + return 401; + case 'config': + return 500; + case 'timeout': + return 504; + default: + return 500; + } +} + +function jsonError(error: ClerkCliAuthError): Response { + return Response.json( + { error: error.code, error_description: error.message, exit_code: error.exitCode ?? EXIT_CODE.GENERAL }, + { status: statusFor(error.code) }, + ); +} + +async function verifyForHandle( + request: Request, + options: HandleOptions, +): Promise> { + if (!options.verifyToken) { + return options.auth.verifyTokenFromRequest(request, { accepts: options.accepts }); + } + // Custom verifier: classify, then pass the bound Clerk client through. + const token = readBearer(request); + const type = classifyToken(token) as T; + const clerk = await options.auth.getClerk(); + return options.verifyToken({ token, type, request, clerk }); +} + +/** + * Standalone route handler: wraps a `cliAuth()` instance into a Web `Request → Response` + * function for any framework that speaks the Fetch API (Next.js App Router, Hono, + * SvelteKit, Remix, plain Node `fetch`, etc.). + * + * @example + * ```ts + * // lib/clerk-cli.ts + * import { cliAuth } from '@clerk/cli-auth/server'; + * import { clerkClient } from '@clerk/nextjs/server'; + * + * export const auth = cliAuth({ client: clerkClient }); + * + * // app/api/cli/identity/route.ts + * import { handle } from '@clerk/cli-auth/server'; + * import { auth } from '@/lib/clerk-cli'; + * + * export const GET = handle({ + * auth, + * accepts: ['api_key', 'oauth_token'], + * // Optional overrides: + * // verifyToken: ({ token, type, request, clerk }) => ... + * // resolveAuthInfo: ({ tokenInfo, request, clerk }) => ... + * }); + * ``` + */ +export function handle( + options: HandleOptions, +): (request: Request) => Promise { + return async function routeHandler(request: Request): Promise { + try { + const tokenInfo = await verifyForHandle(request, options); + const clerk = await options.auth.getClerk(); + const resolver = options.resolveAuthInfo ?? defaultResolveAuthInfo; + const raw = await resolver({ tokenInfo, request, clerk }); + const info = validateIdentity(raw); + return Response.json(info, { status: 200 }); + } catch (error) { + if (error instanceof ClerkCliAuthError) { + return jsonError(error); + } + return jsonError(new ClerkCliAuthError('config', `Unexpected error: ${(error as Error).message}`)); + } + }; +} diff --git a/packages/cli-auth/src/server/index.ts b/packages/cli-auth/src/server/index.ts new file mode 100644 index 00000000000..946d63200a7 --- /dev/null +++ b/packages/cli-auth/src/server/index.ts @@ -0,0 +1,17 @@ +export { cliAuth } from './cli-auth'; +export { handle } from './handle'; + +export type { + AcceptsToken, + CliAuthFactoryOptions, + CliAuthInstance, + HandleOptions, + ResolveAuthInfoContext, + ResolveAuthInfoFn, + TokenInfo, + VerifyTokenContext, + VerifyTokenFn, +} from './types'; + +// Cli-auth's narrowed `TokenKind` ('api_key' | 'oauth_token') — m2m is intentionally excluded. +export type { TokenKind } from '../lib/classify-token'; diff --git a/packages/cli-auth/src/server/resolve-auth.ts b/packages/cli-auth/src/server/resolve-auth.ts new file mode 100644 index 00000000000..9a91a145d14 --- /dev/null +++ b/packages/cli-auth/src/server/resolve-auth.ts @@ -0,0 +1,75 @@ +import { ClerkCliAuthError } from '../errors'; +import type { TokenKind } from '../lib/classify-token'; +import type { Identity } from '../types'; +import type { ResolveAuthInfoContext, TokenInfo } from './types'; + +/** + * Canonical Clerk subject formats: `user_*`, `org_*`, `mch_*`, `scim_*` — each + * followed by 27 word chars. Verified tokens should always match. + */ +const SUBJECT_PATTERN = /^(user|org|mch|scim)_\w{27}$/; + +/** Extract a canonical Clerk subject from `tokenInfo.subject` or any claim that holds one. */ +function extractSubject(tokenInfo: TokenInfo): string { + if (SUBJECT_PATTERN.test(tokenInfo.subject)) { + return tokenInfo.subject; + } + // Fallback: scan claims for the first canonical subject value. + if (tokenInfo.claims) { + for (const value of Object.values(tokenInfo.claims)) { + if (typeof value === 'string' && SUBJECT_PATTERN.test(value)) { + return value; + } + } + } + throw new ClerkCliAuthError( + 'verify_api_key', + `Verified token has no canonical Clerk subject (got "${tokenInfo.subject}").`, + ); +} + +/** + * Default info resolver — runs on a verified `TokenInfo` and projects it into a + * `Identity`. Validates the subject against `^(user|org|mch|scim)_\w{27}$` + * to catch verifier output that doesn't look like a Clerk resource ID. + * + * Consumers override this by passing `resolveAuthInfo` to `handle()` when they want + * richer profile/org data (e.g. fetching the user via the bound Clerk client, or + * pulling org claims out of an API key's `claims` bag). + */ +export function resolveAuthInfo(ctx: ResolveAuthInfoContext): Identity { + const { tokenInfo } = ctx; + const sub = extractSubject(tokenInfo); + + const info: Identity = { sub }; + + // Pass through anything the verifier surfaced under `claims` — keeps the response useful + // without forcing every consumer to write a resolver. + if (tokenInfo.claims) { + for (const [key, value] of Object.entries(tokenInfo.claims)) { + if (info[key] === undefined) { + info[key] = value; + } + } + } + + return info; +} + +/** Internal: enforces the `Identity` contract on whatever the resolver returned. */ +export function validateIdentity(info: unknown): Identity { + if (!info || typeof info !== 'object') { + throw new ClerkCliAuthError( + 'verify_api_key', + 'resolveAuthInfo must return an Identity object with a non-empty `sub`.', + ); + } + const candidate = info as Partial; + if (typeof candidate.sub !== 'string' || !candidate.sub) { + throw new ClerkCliAuthError( + 'verify_api_key', + 'resolveAuthInfo returned an object without a non-empty `sub` field.', + ); + } + return candidate as Identity; +} diff --git a/packages/cli-auth/src/server/types.ts b/packages/cli-auth/src/server/types.ts new file mode 100644 index 00000000000..d0cff5458c3 --- /dev/null +++ b/packages/cli-auth/src/server/types.ts @@ -0,0 +1,147 @@ +import type { ClerkClient, ClerkOptions } from '@clerk/backend'; + +import type { TokenKind } from '../lib/classify-token'; +import type { Identity } from '../types'; + +/** + * Which token types this endpoint accepts: a single {@link TokenKind}, a readonly tuple of + * them, or the literal `'any'`. Narrower than `@clerk/backend`'s `acceptsToken` — m2m tokens + * and session tokens are both intentionally excluded from the CLI threat model. + */ +export type AcceptsToken = TokenKind | readonly TokenKind[] | 'any'; + +/** + * A Clerk Backend client, either resolved or wrapped in a factory. Passing the factory + * lets you forward `@clerk/nextjs/server`'s `clerkClient` directly — the SDK calls it + * lazily on first use, so consumers don't need to `await` at module scope. + */ +export type ClientArg = ClerkClient | (() => ClerkClient | Promise); + +/** + * Verified token payload. Returned by `verifyToken` / `verifyTokenFromRequest` + * and passed into `resolveAuthInfo` as `tokenInfo`. + */ +export interface TokenInfo { + /** The verified token's subject — `user_*`, `org_*`, `mch_*`, or `scim_*`. */ + subject: string; + /** The verified token type (`api_key` | `oauth_token`). */ + type: T; + /** Scopes attached to the token, when applicable. */ + scopes?: string[]; + /** Raw verified claims/data from `@clerk/backend`. Shape varies by token type. */ + claims?: Record; +} + +/** + * Context passed to a `verifyToken` override. `token` is the raw, unverified bearer. + * `type` is the auto-detected token type; `clerk` is the resolved Clerk Backend client. + */ +export interface VerifyTokenContext { + /** Raw bearer token from the `Authorization` header. */ + token: string; + /** Token type auto-detected from the token's prefix / JWT shape. */ + type: T; + /** Original incoming `Request`. */ + request: Request; + /** Clerk Backend client, ready to use (`clerk.apiKeys.verify(...)`, `clerk.authenticateRequest(...)`, etc.). */ + clerk: ClerkClient; +} + +/** + * Context passed to a `resolveAuthInfo` callback. The token has already been verified; + * downstream code reads `tokenInfo.subject` / `.claims`, the original `request`, and the + * resolved Clerk Backend client. + */ +export interface ResolveAuthInfoContext { + /** The verified token, including subject, type, scopes, and claims. */ + tokenInfo: TokenInfo; + /** Original incoming `Request`. */ + request: Request; + /** Clerk Backend client, ready to use (e.g. `clerk.users.getUser(tokenInfo.subject)`). */ + clerk: ClerkClient; +} + +/** A `verifyToken` callback returns a verified `TokenInfo` or throws on rejection. */ +export type VerifyTokenFn = ( + ctx: VerifyTokenContext, +) => Promise> | TokenInfo; + +/** A `resolveAuthInfo` callback shapes the verified token into a `Identity` payload. */ +export type ResolveAuthInfoFn = ( + ctx: ResolveAuthInfoContext, +) => Promise | Identity; + +/** + * Options for the `cliAuth()` factory. Pass either a Clerk Backend client (or a factory + * returning one) via `client`, or the config used to construct one via `clientConfig`. + * If neither is given, the factory builds a client from `CLERK_SECRET_KEY` at first use. + * + * `clientConfig.secretKey` (or `CLERK_SECRET_KEY`) is also what `verifyToken` / + * `verifyTokenFromRequest` pass to `@clerk/backend`'s `verifyMachineAuthToken`. + */ +export interface CliAuthFactoryOptions { + /** Pre-configured Clerk Backend client, or a thunk returning one (e.g. `@clerk/nextjs/server`'s `clerkClient`). */ + client?: ClientArg; + /** Or: the `ClerkOptions` shape `createClerkClient(...)` accepts. */ + clientConfig?: ClerkOptions; +} + +/** + * Options for the standalone `handle()` export. Pass the `cliAuth()` instance you want to + * bind the route to via `auth`. Per-route Clerk client overrides aren't supported — make + * another `cliAuth()` instance instead. + */ +export interface HandleOptions { + /** `cliAuth()` instance whose bound Clerk client and verifier/resolver this route should use. */ + auth: CliAuthInstance; + /** Which token types this endpoint accepts. Rejects every other type with 401. Defaults to `'any'`. */ + accepts?: AcceptsToken; + /** Override the verification step. Defaults to a `verifyMachineAuthToken`-backed verifier. */ + verifyToken?: VerifyTokenFn; + /** Override the info-resolution step. Defaults to extracting subject + claims into `Identity`. */ + resolveAuthInfo?: ResolveAuthInfoFn; +} + +/** + * Shape returned by `cliAuth({ client })`. Pair it with the standalone `handle()` export, or + * call its instance methods directly inside your own route logic. + * + * @example + * ```ts + * // lib/clerk-cli.ts + * import { cliAuth } from '@clerk/cli-auth/server'; + * import { clerkClient } from '@clerk/nextjs/server'; + * + * export const auth = cliAuth({ client: clerkClient }); + * + * // app/api/cli/verify/route.ts + * import { handle } from '@clerk/cli-auth/server'; + * import { auth } from '@/lib/clerk-cli'; + * + * export const GET = handle({ auth, accepts: ['api_key', 'oauth_token'] }); + * ``` + */ +export interface CliAuthInstance { + /** + * Primitive verifier — raw bearer in, verified `TokenInfo` out. Auto-detects token type + * via `@clerk/backend`'s `verifyMachineAuthToken`, optionally gating against `accepts`. + */ + verifyToken: ( + token: string, + options?: { accepts?: AcceptsToken }, + ) => Promise>; + /** + * Request-level verifier — reads `Authorization: Bearer `, then defers to + * `verifyToken(token, options)`. + */ + verifyTokenFromRequest: ( + request: Request, + options?: { accepts?: AcceptsToken }, + ) => Promise>; + /** Default resolver: project a verified `TokenInfo` into `Identity`. Sync today, but typed `Identity | Promise` so consumer-supplied resolvers can be async. */ + resolveAuthInfo: ( + ctx: Omit, 'clerk'> & { clerk?: ClerkClient }, + ) => Identity | Promise; + /** Resolve the bound Clerk Backend SDK client. Cached after the first call. */ + getClerk: () => Promise; +} diff --git a/packages/cli-auth/src/server/verify-token.ts b/packages/cli-auth/src/server/verify-token.ts new file mode 100644 index 00000000000..f7dd1d6c719 --- /dev/null +++ b/packages/cli-auth/src/server/verify-token.ts @@ -0,0 +1,73 @@ +import type { ClerkClient } from '@clerk/backend'; + +import { ClerkCliAuthError } from '../errors'; +import type { TokenKind } from '../lib/classify-token'; +import type { AcceptsToken, TokenInfo } from './types'; + +const BEARER_PREFIX = /^Bearer\s+/i; + +/** cli-auth's canonical accepted set — m2m and session are intentionally never accepted. */ +const DEFAULT_ACCEPTS: readonly TokenKind[] = ['api_key', 'oauth_token'] as const; + +export function readBearer(request: Request): string { + const header = request.headers.get('authorization'); + if (!header) { + throw new ClerkCliAuthError('not_authenticated', 'Missing Authorization header.'); + } + const token = header.replace(BEARER_PREFIX, '').trim(); + if (!token) { + throw new ClerkCliAuthError('not_authenticated', 'Authorization header is empty.'); + } + return token; +} + +/** + * Normalize cli-auth's {@link AcceptsToken} to the `acceptsToken` array + * `@clerk/backend` expects. `'any'` collapses to the canonical narrowed set so session + * and m2m tokens are never accidentally accepted even when the caller asks for "any". + */ +function normalizeAccepts(accepts: AcceptsToken | undefined): readonly TokenKind[] { + if (!accepts || accepts === 'any') { + return DEFAULT_ACCEPTS; + } + return Array.isArray(accepts) ? accepts : [accepts as TokenKind]; +} + +/** + * Default token verifier — delegates to `clerk.authenticateRequest`, which detects the + * token type, looks up the right verification primitive (BAPI for opaque, JWKs for JWTs), + * and returns an `AuthObject`. We narrow the result to the {@link TokenInfo} shape that + * downstream consumers (`resolveAuthInfo`, custom `verifyToken` overrides) work with. + * + * Consumers who want richer claims for JWT-shaped tokens (e.g. the full RFC 9068 payload) + * can decode the bearer inside a custom `resolveAuthInfo` callback — that hook receives + * the original `Request`, so the raw token is recoverable via `readBearer(request)`. + */ +export async function verifyTokenWithClerk( + request: Request, + options: { accepts?: AcceptsToken; clerk: ClerkClient }, +): Promise { + // Cli-auth is bearer-only — require the header up front so consumers get a clear + // "Missing Authorization header" rather than `authenticateRequest`'s + // `token-type-mismatch` reason after it falls through to cookie auth and fails. + readBearer(request); + + const acceptsToken = normalizeAccepts(options.accepts); + const state = await options.clerk.authenticateRequest(request, { acceptsToken }); + if (state.isAuthenticated === false) { + throw new ClerkCliAuthError('not_authenticated', state.reason ?? 'Token rejected.'); + } + + const authObj = state.toAuth(); + // `subject`, `scopes`, `tokenType` are top-level on every AuthenticatedMachineObject. + // `claims` is only present on the `api_key` arm of MachineObjectExtendedProperties — + // check the property at runtime to avoid leaking the type machinery here. + const claims = 'claims' in authObj && authObj.claims ? (authObj.claims as Record) : undefined; + + return { + subject: authObj.subject, + type: authObj.tokenType as TokenKind, + scopes: authObj.scopes, + claims, + }; +} diff --git a/packages/cli-auth/src/types.ts b/packages/cli-auth/src/types.ts new file mode 100644 index 00000000000..0de0a08403e --- /dev/null +++ b/packages/cli-auth/src/types.ts @@ -0,0 +1,158 @@ +/** + * Public types for @clerk/cli-auth. + * This file is the CONTRACT — Codex agents building the implementation + * and the demo consumer both import from here. Keep signatures stable. + */ + +export type StorageKind = 'keychain' | 'file' | 'memory'; + +export interface CredentialStore { + get(key: string): Promise; + set(key: string, value: string): Promise; + delete(key: string): Promise; +} + +export type OAuthScope = + | 'profile' + | 'email' + | 'openid' + | 'offline_access' + | 'user:org:read' + | 'public_metadata' + | (string & {}); + +export interface ClerkCliAuthConfig { + /** OAuth Application client_id from Clerk Dashboard (public client, PKCE). */ + clientId: string; + /** Frontend API base URL, e.g. https://clerk.myapp.com (no trailing slash). */ + issuer: string; + /** OAuth scopes to request. Default: ["profile", "email", "openid", "offline_access"]. */ + scopes?: OAuthScope[]; + /** Credential storage strategy. Default: "keychain" (with file fallback). */ + storage?: StorageKind | CredentialStore; + /** Keychain service name (macOS/Linux/Windows credential manager). Default: "clerk-cli-auth". */ + keychainService?: string; + /** Environment label to namespace stored tokens. Default: "default". */ + environment?: string; + /** Override the port the ephemeral callback server binds to. Default: 0 (random). */ + callbackPort?: number; + /** How long `login()` waits for the user to complete browser sign-in and redirect back to the localhost callback. Default: 120000 (2min). */ + loginTimeoutMs?: number; + /** Per-HTTP-request timeout in ms for token exchange, refresh, revoke, userinfo, and API key verification. Default: 30000 (30s). */ + requestTimeoutMs?: number; + /** Injected opener for the browser step (for testing). Default: auto-detect. */ + openBrowser?: (url: string) => Promise; + /** + * Backend endpoint that returns the verified `Identity` for a credential (API key + * or OAuth access token). Called with `Authorization: Bearer `. The server is + * responsible for verifying the credential (e.g. via `clerk.apiKeys.verify()` from + * `@clerk/nextjs/server`) and responding with an `Identity` payload — verification + * stays server-side, never in the CLI. Setting this enables `verifyToken()`. + */ + identityEndpoint?: string; + /** + * Env var to read a non-OAuth credential from (e.g. `'MYAPP_API_KEY'`). When set, + * `resolveToken()` falls back to this env var before the cached OAuth session. + */ + tokenEnvVar?: string; +} + +export interface TokenSet { + accessToken: string; + refreshToken?: string; + idToken?: string; + expiresAt?: number; + scope?: string; + tokenType?: string; +} + +/** + * Where a credential resolved by {@link ClerkCliAuth.resolveToken} came from. Lets + * callers branch on the *trust model* rather than the wire-level token type: + * + * - `'arg'` — passed in directly via `tokenFromArg` (typically a `--token` CLI flag) + * - `'env'` — read from the env var named by `tokenEnvVar` + * - `'oauth'` — cached from a prior `login()`; refreshable and revokable + */ +export type TokenSource = 'arg' | 'env' | 'oauth'; + +/** + * Verified identity returned by `/oauth/userinfo` or your `identityEndpoint`. Discriminated + * by the `sub` prefix: + * + * - `user_*` → {@link UserIdentity} — a person (OAuth flows + user-scoped API keys) + * - `org_*` → {@link OrgIdentity} — an org (org-scoped API keys) + * - `mch_*` → {@link MachineIdentity} — a machine (M2M tokens, machine-scoped API keys) + * - `scim_*` → {@link ScimIdentity} — a SCIM directory resource + * + * Narrow via the {@link isUserIdentity} / {@link isOrgIdentity} / {@link isMachineIdentity} / + * {@link isScimIdentity} guards. + */ +export type Identity = UserIdentity | OrgIdentity | MachineIdentity | ScimIdentity; + +interface IdentityBase { + sub: string; + /** Arbitrary additional claims surfaced by the issuer or your identity endpoint. */ + [key: string]: unknown; +} + +/** A person — OAuth subjects and user-scoped API keys. */ +export interface UserIdentity extends IdentityBase { + /** Clerk user id. Returned by identity endpoints; OAuth `/oauth/userinfo` puts it in `sub` instead. */ + user_id?: string; + email?: string; + email_verified?: boolean; + name?: string; + given_name?: string; + family_name?: string; + picture?: string; + preferred_username?: string; + username?: string; + public_metadata?: Record; + unsafe_metadata?: Record; + /** Org context. Present when the user authenticated in an org scope. */ + org_id?: string; + org_slug?: string; + org_name?: string; + org_role?: string; + org_permissions?: string[]; +} + +/** An organization — org-scoped API keys. */ +export interface OrgIdentity extends IdentityBase { + org_id?: string; + org_slug?: string; + org_name?: string; +} + +/** A machine — M2M tokens, machine-scoped API keys. */ +export type MachineIdentity = IdentityBase; + +/** A SCIM directory resource. */ +export type ScimIdentity = IdentityBase; + +/** True if `identity.sub` starts with `user_`. Narrows to {@link UserIdentity}. */ +export function isUserIdentity(identity: Identity): identity is UserIdentity { + return identity.sub.startsWith('user_'); +} + +/** True if `identity.sub` starts with `org_`. Narrows to {@link OrgIdentity}. */ +export function isOrgIdentity(identity: Identity): identity is OrgIdentity { + return identity.sub.startsWith('org_'); +} + +/** True if `identity.sub` starts with `mch_`. Narrows to {@link MachineIdentity}. */ +export function isMachineIdentity(identity: Identity): identity is MachineIdentity { + return identity.sub.startsWith('mch_'); +} + +/** True if `identity.sub` starts with `scim_`. Narrows to {@link ScimIdentity}. */ +export function isScimIdentity(identity: Identity): identity is ScimIdentity { + return identity.sub.startsWith('scim_'); +} + +export interface LoginResult { + tokens: TokenSet; + /** OAuth subjects are always users; typed accordingly. */ + user: UserIdentity; +} diff --git a/packages/cli-auth/tsconfig.json b/packages/cli-auth/tsconfig.json new file mode 100644 index 00000000000..b5ddee18eed --- /dev/null +++ b/packages/cli-auth/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "declaration": true, + "declarationMap": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "importHelpers": true, + "isolatedModules": true, + "moduleResolution": "NodeNext", + "module": "NodeNext", + "noImplicitReturns": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "sourceMap": false, + "strict": true, + "target": "ES2020", + "outDir": "dist" + }, + "exclude": ["node_modules"], + "include": ["src"] +} diff --git a/packages/cli-auth/tsup.config.ts b/packages/cli-auth/tsup.config.ts new file mode 100644 index 00000000000..b3fe802effb --- /dev/null +++ b/packages/cli-auth/tsup.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'tsup'; + +import { name, version } from './package.json'; + +export default defineConfig(overrideOptions => { + const isWatch = !!overrideOptions.watch; + const shouldPublish = !!overrideOptions.env?.publish; + + return { + entry: { + index: './src/index.ts', + server: './src/server/index.ts', + }, + format: ['cjs', 'esm'], + bundle: true, + clean: true, + minify: false, + sourcemap: true, + dts: true, + onSuccess: shouldPublish ? 'pkglab pub --ping' : undefined, + define: { + PACKAGE_NAME: `"${name}"`, + PACKAGE_VERSION: `"${version}"`, + __DEV__: `${isWatch}`, + }, + }; +}); diff --git a/packages/cli-auth/turbo.json b/packages/cli-auth/turbo.json new file mode 100644 index 00000000000..4379d6c1c35 --- /dev/null +++ b/packages/cli-auth/turbo.json @@ -0,0 +1,19 @@ +{ + "extends": ["//"], + "tasks": { + "build": { + "inputs": [ + "*.d.ts", + "**/package.json", + "src/**", + "tsconfig.json", + "tsup.config.ts", + + "!**/__tests__/**", + "!**/__snapshots__/**", + "!coverage/**", + "!node_modules/**" + ] + } + } +} diff --git a/packages/cli-auth/vitest.config.mts b/packages/cli-auth/vitest.config.mts new file mode 100644 index 00000000000..d6435eaaf53 --- /dev/null +++ b/packages/cli-auth/vitest.config.mts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [], + test: { + coverage: { + provider: 'v8', + enabled: true, + reporter: ['text', 'json', 'html'], + }, + setupFiles: './vitest.setup.mts', + // Integration tests live under src/__tests__/integration/ and require a real + // CLERK_SECRET_KEY. Default `vitest run` is unit-only; use `pnpm test:integration`. + exclude: ['**/node_modules/**', '**/dist/**', '**/__tests__/integration/**'], + }, +}); diff --git a/packages/cli-auth/vitest.integration.config.mts b/packages/cli-auth/vitest.integration.config.mts new file mode 100644 index 00000000000..ddd24a5c583 --- /dev/null +++ b/packages/cli-auth/vitest.integration.config.mts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vitest/config'; + +/** + * Integration tests hit a real Clerk instance — they create users, API keys, and M2M + * tokens via the Backend SDK and clean up in `afterAll`. Run with `pnpm test:integration` + * and a `CLERK_SECRET_KEY` set in the environment. Suites `describe.skipIf` themselves + * when the secret is missing, so CI without it is a no-op rather than a failure. + */ +export default defineConfig({ + plugins: [], + test: { + include: ['src/__tests__/integration/**/*.test.ts'], + setupFiles: './vitest.setup.mts', + // Network calls + BAPI rate limits — longer timeouts than the unit suite. + testTimeout: 30_000, + hookTimeout: 30_000, + // Sequential to keep BAPI traffic predictable; we share resources via beforeAll. + fileParallelism: false, + coverage: { + enabled: false, + }, + }, +}); diff --git a/packages/cli-auth/vitest.setup.mts b/packages/cli-auth/vitest.setup.mts new file mode 100644 index 00000000000..81cb45b14f7 --- /dev/null +++ b/packages/cli-auth/vitest.setup.mts @@ -0,0 +1,6 @@ +import { beforeAll } from 'vitest'; + +globalThis.PACKAGE_NAME = '@clerk/cli-auth'; +globalThis.PACKAGE_VERSION = '0.0.0-test'; + +beforeAll(() => {}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce9136a8c0d..8a165b8b2d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -511,6 +511,18 @@ importers: specifier: ^5.10.0 version: 5.10.0 + packages/cli-auth: + dependencies: + '@clerk/backend': + specifier: workspace:^ + version: link:../backend + '@napi-rs/keyring': + specifier: ^1.1.7 + version: 1.3.0 + tslib: + specifier: catalog:repo + version: 2.8.1 + packages/dev-cli: dependencies: commander: @@ -2727,7 +2739,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {node: '>=0.10.0'} + engines: {'0': node >=0.10.0} '@expo/cli@0.22.28': resolution: {integrity: sha512-lvt72KNitGuixYD2l3SZmRKVu2G4zJpmg5V7WfUBNpmUU5oODBw/6qmiJ6kSLAlfDozscUk+BBGknBBzxUrwrA==} @@ -3410,6 +3422,87 @@ packages: resolution: {integrity: sha512-D0nkS5+sx87mYpxFqASImCineYoEl9wGlUPrzkuS0ohzG8wfophLpE+I76qGJ0slLAVI19do5SI9pWJNCVf4fg==} engines: {node: '>=18'} + '@napi-rs/keyring-darwin-arm64@1.3.0': + resolution: {integrity: sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/keyring-darwin-x64@1.3.0': + resolution: {integrity: sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/keyring-freebsd-x64@1.3.0': + resolution: {integrity: sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': + resolution: {integrity: sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/keyring-linux-arm64-gnu@1.3.0': + resolution: {integrity: sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-arm64-musl@1.3.0': + resolution: {integrity: sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': + resolution: {integrity: sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-x64-gnu@1.3.0': + resolution: {integrity: sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-x64-musl@1.3.0': + resolution: {integrity: sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/keyring-win32-arm64-msvc@1.3.0': + resolution: {integrity: sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/keyring-win32-ia32-msvc@1.3.0': + resolution: {integrity: sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/keyring-win32-x64-msvc@1.3.0': + resolution: {integrity: sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/keyring@1.3.0': + resolution: {integrity: sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -17742,6 +17835,57 @@ snapshots: outvariant: 1.4.3 strict-event-emitter: 0.5.1 + '@napi-rs/keyring-darwin-arm64@1.3.0': + optional: true + + '@napi-rs/keyring-darwin-x64@1.3.0': + optional: true + + '@napi-rs/keyring-freebsd-x64@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm64-musl@1.3.0': + optional: true + + '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-x64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-x64-musl@1.3.0': + optional: true + + '@napi-rs/keyring-win32-arm64-msvc@1.3.0': + optional: true + + '@napi-rs/keyring-win32-ia32-msvc@1.3.0': + optional: true + + '@napi-rs/keyring-win32-x64-msvc@1.3.0': + optional: true + + '@napi-rs/keyring@1.3.0': + optionalDependencies: + '@napi-rs/keyring-darwin-arm64': 1.3.0 + '@napi-rs/keyring-darwin-x64': 1.3.0 + '@napi-rs/keyring-freebsd-x64': 1.3.0 + '@napi-rs/keyring-linux-arm-gnueabihf': 1.3.0 + '@napi-rs/keyring-linux-arm64-gnu': 1.3.0 + '@napi-rs/keyring-linux-arm64-musl': 1.3.0 + '@napi-rs/keyring-linux-riscv64-gnu': 1.3.0 + '@napi-rs/keyring-linux-x64-gnu': 1.3.0 + '@napi-rs/keyring-linux-x64-musl': 1.3.0 + '@napi-rs/keyring-win32-arm64-msvc': 1.3.0 + '@napi-rs/keyring-win32-ia32-msvc': 1.3.0 + '@napi-rs/keyring-win32-x64-msvc': 1.3.0 + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.10.0