From c9fbffe115a0076c754c89e53a071a61f2ef16ed Mon Sep 17 00:00:00 2001 From: Nicolas Angelo Date: Tue, 26 May 2026 08:45:14 -0400 Subject: [PATCH 01/20] feat(cli-auth): add @clerk/cli-auth package --- .changeset/loud-callbacks-listen.md | 5 + .github/labeler.yml | 4 + packages/cli-auth/.gitignore | 8 + packages/cli-auth/.npmignore | 2 + packages/cli-auth/LICENSE | 21 + packages/cli-auth/README.md | 155 ++++++++ packages/cli-auth/package.json | 68 ++++ .../src/__tests__/auth-server.test.ts | 49 +++ .../src/__tests__/clerk-cli-auth.test.ts | 358 ++++++++++++++++++ .../src/__tests__/credential-store.test.ts | 57 +++ packages/cli-auth/src/__tests__/pkce.test.ts | 27 ++ packages/cli-auth/src/clerk-cli-auth.ts | 273 +++++++++++++ packages/cli-auth/src/errors.ts | 38 ++ packages/cli-auth/src/index.ts | 18 + packages/cli-auth/src/lib/auth-server.ts | 170 +++++++++ packages/cli-auth/src/lib/classify-token.ts | 6 + packages/cli-auth/src/lib/credential-store.ts | 211 +++++++++++ packages/cli-auth/src/lib/pkce.ts | 17 + packages/cli-auth/src/lib/token-exchange.ts | 216 +++++++++++ packages/cli-auth/src/lib/verify-api-key.ts | 70 ++++ packages/cli-auth/src/types.ts | 95 +++++ packages/cli-auth/tsconfig.json | 22 ++ packages/cli-auth/tsup.config.ts | 26 ++ packages/cli-auth/turbo.json | 19 + packages/cli-auth/vitest.config.mts | 13 + packages/cli-auth/vitest.setup.mts | 6 + pnpm-lock.yaml | 143 ++++++- 27 files changed, 2096 insertions(+), 1 deletion(-) create mode 100644 .changeset/loud-callbacks-listen.md create mode 100644 packages/cli-auth/.gitignore create mode 100644 packages/cli-auth/.npmignore create mode 100644 packages/cli-auth/LICENSE create mode 100644 packages/cli-auth/README.md create mode 100644 packages/cli-auth/package.json create mode 100644 packages/cli-auth/src/__tests__/auth-server.test.ts create mode 100644 packages/cli-auth/src/__tests__/clerk-cli-auth.test.ts create mode 100644 packages/cli-auth/src/__tests__/credential-store.test.ts create mode 100644 packages/cli-auth/src/__tests__/pkce.test.ts create mode 100644 packages/cli-auth/src/clerk-cli-auth.ts create mode 100644 packages/cli-auth/src/errors.ts create mode 100644 packages/cli-auth/src/index.ts create mode 100644 packages/cli-auth/src/lib/auth-server.ts create mode 100644 packages/cli-auth/src/lib/classify-token.ts create mode 100644 packages/cli-auth/src/lib/credential-store.ts create mode 100644 packages/cli-auth/src/lib/pkce.ts create mode 100644 packages/cli-auth/src/lib/token-exchange.ts create mode 100644 packages/cli-auth/src/lib/verify-api-key.ts create mode 100644 packages/cli-auth/src/types.ts create mode 100644 packages/cli-auth/tsconfig.json create mode 100644 packages/cli-auth/tsup.config.ts create mode 100644 packages/cli-auth/turbo.json create mode 100644 packages/cli-auth/vitest.config.mts create mode 100644 packages/cli-auth/vitest.setup.mts diff --git a/.changeset/loud-callbacks-listen.md b/.changeset/loud-callbacks-listen.md new file mode 100644 index 00000000000..c17a7b8e736 --- /dev/null +++ b/.changeset/loud-callbacks-listen.md @@ -0,0 +1,5 @@ +--- +'@clerk/cli-auth': minor +--- + +Add `@clerk/cli-auth`: reusable OAuth 2.0 + PKCE localhost-callback flow for adding Clerk authentication to Node.js CLIs. Provides 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, and optional Clerk API key verification. 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..7a1f1b2b921 --- /dev/null +++ b/packages/cli-auth/README.md @@ -0,0 +1,155 @@ +

+ + + + + + +
+

@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://twitter.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` implements the OAuth 2.0 Authorization Code + PKCE localhost-callback flow for adding [Clerk](https://clerk.com) authentication to Node.js command-line tools. + +### 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). +- An OAuth Application registered with your Clerk instance (see [Setup](#setup) below) + +### Installation + +```sh +npm install @clerk/cli-auth +``` + +## Setup + +You need two things: an **OAuth Application** registered with a Clerk instance, and the `client_id` + issuer URL from it. + +### 1. Create an OAuth Application + +Pick whichever path fits your workflow. + +**Clerk Dashboard (recommended for most devs)** — in your dev instance, go to **Configure → OAuth Applications → Create**. 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` redirect URI during authorization) +- Public client (PKCE): enabled +- Scopes: `profile email openid offline_access` + +**curl against BAPI** — if you prefer scripting. Replace `$SK` with your instance's secret key: + +```bash +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 paths return a JSON object with `client_id`. Grab it along 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`). + +### 2. Configure your CLI + +```bash +export CLERK_OAUTH_CLIENT_ID="..." # from step 1 +export CLERK_ISSUER="https://clerk.your-subdomain.accounts.dev" +``` + +## Usage + +```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', +}); + +// Opens a browser, starts a one-shot localhost listener, exchanges the code, +// stores tokens in the OS keychain. Returns the token set and userinfo. +const { tokens, user } = await auth.login(); + +// Returns the cached access token; auto-refreshes when within 30s of expiry. +const token = await auth.getAccessToken(); + +// Reads the cached user. If no cache, fetches from /oauth/userinfo. +const me = await auth.whoami(); + +// Revokes the refresh token at the issuer, then clears keychain + cached userinfo. +// Pass { revoke: false } to skip the network call (e.g. offline or token already expired). +await auth.logout(); +``` + +## How the flow works + +``` +1. CLI generates PKCE (code_verifier, code_challenge=S256(verifier)) + CSRF state. +2. CLI binds a one-shot HTTP server on 127.0.0.1:0 (random port). +3. CLI opens browser to: + {issuer}/oauth/authorize?client_id=...&code_challenge=... + &redirect_uri=http://127.0.0.1:{port}/callback&state=... + &code_challenge_method=S256 +4. User signs in via Clerk's hosted UI and approves consent. +5. Clerk redirects the browser to http://127.0.0.1:{port}/callback?code=...&state=... +6. Server validates state, responds with "You can close this tab", closes. +7. CLI posts to {issuer}/oauth/token with grant_type=authorization_code + code_verifier. +8. CLI stores the token set in the OS keychain (falls back to chmod 600 JSON file). +``` + +## Known limitations + +- **Keychain path is tested structurally, not in CI.** Keychain access triggers OS credential-manager prompts in headless environments, so automated tests use memory and file stores. The keychain path is exercised end-to-end when consumers run against a real Clerk instance. +- **Device Authorization Grant (RFC 8628) is not implemented.** The localhost-callback flow needs an open port, which doesn't work for CI, containers, or SSH sessions. If you need that, [open an issue](https://github.com/clerk/javascript/issues/new?assignees=&labels=needs-triage&projects=&template=feature_request.yml). + +## Support + +You can get in touch with us in any of the following ways: + +- Join our official community [Discord server](https://clerk.com/discord) +- On [our support page](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_cli_auth) + +## 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..005b2b816ad --- /dev/null +++ b/packages/cli-auth/package.json @@ -0,0 +1,68 @@ +{ + "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" + } + }, + "./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:watch": "vitest watch" + }, + "dependencies": { + "@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..388d0c701dd --- /dev/null +++ b/packages/cli-auth/src/__tests__/clerk-cli-auth.test.ts @@ -0,0 +1,358 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import type { AddressInfo } 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) { + return data.get(key) ?? null; + }, + async set(key, value) { + data.set(key, value); + }, + async delete(key) { + data.delete(key); + }, + }; +} + +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(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(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(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(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(); + }); +}); 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__/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/clerk-cli-auth.ts b/packages/cli-auth/src/clerk-cli-auth.ts new file mode 100644 index 00000000000..94c6f8f3b0a --- /dev/null +++ b/packages/cli-auth/src/clerk-cli-auth.ts @@ -0,0 +1,273 @@ +import { spawn } from 'node:child_process'; + +import { ClerkCliAuthError } from './errors'; +import { startAuthServer } from './lib/auth-server'; +import { classifyToken, type TokenKind } from './lib/classify-token'; +import { createCredentialStore } from './lib/credential-store'; +import { generateCodeChallenge, generateCodeVerifier, generateState } from './lib/pkce'; +import { exchangeCodeForTokens, fetchUserInfo, refreshAccessToken, revokeToken } from './lib/token-exchange'; +import { verifyApiKey as verifyApiKeyRequest } from './lib/verify-api-key'; +import type { ClerkCliAuthConfig, CredentialStore, LoginResult, OAuthScope, TokenSet, UserInfo } 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> & { + storage: CredentialStore; + openBrowser?: (url: string) => Promise; + apiKeys?: ClerkCliAuthConfig['apiKeys']; + }; + + 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, + timeoutMs: config.timeoutMs ?? 120_000, + openBrowser: config.openBrowser, + apiKeys: config.apiKeys, + }; + } + + 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.timeoutMs, + }); + + 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, + }); + + await this.setJson('tokens', tokens); + const user = await fetchUserInfo({ + issuer: this.config.issuer, + accessToken: tokens.accessToken, + }); + await this.setJson('user', user); + + 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, + }); + const nextTokens = { + ...tokens, + ...refreshed, + refreshToken: refreshed.refreshToken ?? tokens.refreshToken, + }; + await this.setJson('tokens', nextTokens); + return nextTokens.accessToken; + } + + async whoami(): Promise { + const cachedUser = await this.getJson('user'); + if (cachedUser) { + return cachedUser; + } + + const accessToken = await this.getAccessToken(); + if (!accessToken) { + return null; + } + + const user = await fetchUserInfo({ + issuer: this.config.issuer, + accessToken, + }); + await this.setJson('user', user); + return user; + } + + async verifyApiKey(apiKey: string): Promise { + if (!this.config.apiKeys?.verifyEndpoint) { + throw new ClerkCliAuthError('config', 'apiKeys.verifyEndpoint is not configured.'); + } + return verifyApiKeyRequest({ + endpoint: this.config.apiKeys.verifyEndpoint, + apiKey, + }); + } + + async resolveToken(opts: { tokenFromArg?: string } = {}): Promise<{ token: string; kind: TokenKind }> { + if (opts.tokenFromArg) { + return { + token: opts.tokenFromArg, + kind: classifyToken(opts.tokenFromArg), + }; + } + if (this.config.apiKeys?.envVar) { + const fromEnv = process.env[this.config.apiKeys.envVar]; + if (fromEnv) { + return { token: fromEnv, kind: 'api_key' }; + } + } + const accessToken = await this.getAccessToken(); + if (accessToken) { + return { token: accessToken, kind: 'oauth' }; + } + + const envHint = this.config.apiKeys?.envVar ? ` or set $${this.config.apiKeys.envVar}` : ''; + 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', + }); + } catch { + // Revoke is best-effort — local cleanup proceeds regardless. + } + } + } + + try { + await Promise.all([this.config.storage.delete('tokens'), this.config.storage.delete('user')]); + } 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..8f0594ea13e --- /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..8bfc83e5c80 --- /dev/null +++ b/packages/cli-auth/src/index.ts @@ -0,0 +1,18 @@ +/** + * 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 { fetchUserInfo, revokeToken } from './lib/token-exchange'; +export { verifyApiKey } from './lib/verify-api-key'; +export { classifyToken, type TokenKind } from './lib/classify-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..7ee6c1c18cd --- /dev/null +++ b/packages/cli-auth/src/lib/classify-token.ts @@ -0,0 +1,6 @@ +export type TokenKind = 'oauth' | 'api_key'; + +/** `ak_*` → API key, anything else → OAuth (opaque or JWT). */ +export function classifyToken(token: string): TokenKind { + return token.startsWith('ak_') ? 'api_key' : 'oauth'; +} 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..716fd8d2f2c --- /dev/null +++ b/packages/cli-auth/src/lib/credential-store.ts @@ -0,0 +1,211 @@ +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 { + return this.values.get(key) ?? null; + } + + async set(key: string, value: string): Promise { + this.values.set(key, value); + } + + async delete(key: string): Promise { + 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); + } 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/pkce.ts b/packages/cli-auth/src/lib/pkce.ts new file mode 100644 index 00000000000..d84becb3d8a --- /dev/null +++ b/packages/cli-auth/src/lib/pkce.ts @@ -0,0 +1,17 @@ +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 { + 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..83dd0ce9672 --- /dev/null +++ b/packages/cli-auth/src/lib/token-exchange.ts @@ -0,0 +1,216 @@ +import { ClerkCliAuthError } from '../errors'; +import type { TokenSet, UserInfo } from '../types'; + +export interface ExchangeParams { + issuer: string; + clientId: string; + code: string; + codeVerifier: string; + redirectUri: string; +} + +export interface RefreshParams { + issuer: string; + clientId: string; + refreshToken: string; + scopes?: string[]; +} + +export interface UserInfoParams { + issuer: string; + accessToken: string; +} + +export interface RevokeParams { + issuer: string; + clientId: string; + token: string; + tokenTypeHint?: 'access_token' | 'refresh_token'; +} + +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}`; +} + +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 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): Promise { + let response: Response; + try { + response = await fetch(endpoint(issuer, '/oauth/token'), { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); + } catch (error) { + throw new ClerkCliAuthError('token_exchange', `Token request failed: ${(error as Error).message}`); + } + + let parsed: unknown; + try { + parsed = await parseBody(response); + } catch (error) { + throw new ClerkCliAuthError('token_exchange', `Token response could not be parsed: ${(error as Error).message}`); + } + if (!response.ok) { + throw new ClerkCliAuthError( + 'token_exchange', + messageFromBody(parsed, `Token request failed with HTTP ${response.status}.`), + ); + } + + 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); +} + +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); +} + +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); + } + + let response: Response; + try { + response = await fetch(endpoint(params.issuer, '/oauth/token/revoke'), { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); + } catch (error) { + throw new ClerkCliAuthError('revoke', `Revoke request failed: ${(error as Error).message}`); + } + + if (!response.ok) { + const parsed = await parseBody(response).catch(() => null); + throw new ClerkCliAuthError( + 'revoke', + messageFromBody(parsed, `Revoke request failed with HTTP ${response.status}.`), + ); + } +} + +export async function fetchUserInfo(params: UserInfoParams): Promise { + let response: Response; + try { + response = await fetch(endpoint(params.issuer, '/oauth/userinfo'), { + headers: { Authorization: `Bearer ${params.accessToken}` }, + }); + } catch (error) { + throw new ClerkCliAuthError('userinfo', `Userinfo request failed: ${(error as Error).message}`); + } + + let parsed: unknown; + try { + parsed = await parseBody(response); + } catch (error) { + throw new ClerkCliAuthError('userinfo', `Userinfo response could not be parsed: ${(error as Error).message}`); + } + if (!response.ok) { + throw new ClerkCliAuthError( + 'userinfo', + messageFromBody(parsed, `Userinfo request failed with HTTP ${response.status}.`), + ); + } + + if (!parsed || typeof parsed !== 'object') { + throw new ClerkCliAuthError('userinfo', 'Userinfo response was not JSON.'); + } + + const user = parsed as UserInfo; + if (typeof user.sub !== 'string' || user.sub.length === 0) { + throw new ClerkCliAuthError('userinfo', 'Userinfo response did not include sub.'); + } + + return user; +} diff --git a/packages/cli-auth/src/lib/verify-api-key.ts b/packages/cli-auth/src/lib/verify-api-key.ts new file mode 100644 index 00000000000..e9cf61afdea --- /dev/null +++ b/packages/cli-auth/src/lib/verify-api-key.ts @@ -0,0 +1,70 @@ +import { ClerkCliAuthError } from '../errors'; +import type { UserInfo } from '../types'; + +export interface VerifyApiKeyParams { + endpoint: string; + apiKey: string; +} + +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; +} + +export async function verifyApiKey(params: VerifyApiKeyParams): Promise { + let response: Response; + try { + response = await fetch(params.endpoint, { + headers: { Authorization: `Bearer ${params.apiKey}` }, + }); + } catch (error) { + throw new ClerkCliAuthError('verify_api_key', `API key verification request failed: ${(error as Error).message}`); + } + + let parsed: unknown; + try { + parsed = await parseBody(response); + } catch (error) { + throw new ClerkCliAuthError( + 'verify_api_key', + `API key verification response could not be parsed: ${(error as Error).message}`, + ); + } + + if (!response.ok) { + throw new ClerkCliAuthError( + 'verify_api_key', + messageFromBody(parsed, `API key verification failed with HTTP ${response.status}.`), + ); + } + + if (!parsed || typeof parsed !== 'object') { + throw new ClerkCliAuthError('verify_api_key', 'API key verification response was not a JSON object.'); + } + + const user = parsed as UserInfo; + if (typeof user.sub !== 'string' || user.sub.length === 0) { + throw new ClerkCliAuthError('verify_api_key', 'API key verification response did not include sub.'); + } + + return user; +} diff --git a/packages/cli-auth/src/types.ts b/packages/cli-auth/src/types.ts new file mode 100644 index 00000000000..6c7afeeb845 --- /dev/null +++ b/packages/cli-auth/src/types.ts @@ -0,0 +1,95 @@ +/** + * 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; + /** Callback server timeout in ms. Default: 120000 (2min). */ + timeoutMs?: number; + /** Injected opener for the browser step (for testing). Default: auto-detect. */ + openBrowser?: (url: string) => Promise; + /** Enables Clerk API key (`ak_*`) auth alongside OAuth. */ + apiKeys?: { + /** + * Backend endpoint that verifies an API key and returns the auth payload. + * Called with `Authorization: Bearer `. API keys can't hit /oauth/userinfo — + * they need server-side verification (e.g. `clerk.apiKeys.verify()` in your backend). + */ + verifyEndpoint: string; + /** Env var to read an API key from (e.g. 'MYAPP_API_KEY'). */ + envVar: string; + }; +} + +export interface TokenSet { + accessToken: string; + refreshToken?: string; + idToken?: string; + expiresAt?: number; + scope?: string; + tokenType?: string; +} + +/** + * Identity payload returned by Clerk's /oauth/userinfo or an API key verify endpoint. + * `sub` is the only guaranteed field; everything else depends on scopes and source. + */ +export interface UserInfo { + sub: string; + /** Clerk user id. Returned by API key verify; 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 for org-scoped API keys, or OAuth sessions where the user picked an org at consent. */ + org_id?: string; + org_slug?: string; + org_name?: string; + org_role?: string; + org_permissions?: string[]; + [key: string]: unknown; +} + +export interface LoginResult { + tokens: TokenSet; + user: UserInfo; +} 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..5a9b32d5501 --- /dev/null +++ b/packages/cli-auth/tsup.config.ts @@ -0,0 +1,26 @@ +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', + }, + 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..ba0697457b9 --- /dev/null +++ b/packages/cli-auth/vitest.config.mts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [], + test: { + coverage: { + provider: 'v8', + enabled: true, + reporter: ['text', 'json', 'html'], + }, + setupFiles: './vitest.setup.mts', + }, +}); 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..d32b2fad858 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -511,6 +511,15 @@ importers: specifier: ^5.10.0 version: 5.10.0 + packages/cli-auth: + dependencies: + '@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 +2736,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 +3419,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 +17832,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 From 9f2ac6c0bbb7f9e6d0db08b05e1aa5dd1f87f4d4 Mon Sep 17 00:00:00 2001 From: Nicolas Angelo Date: Thu, 28 May 2026 08:55:42 -0400 Subject: [PATCH 02/20] fix(cli-auth): address review feedback (keychain cleanup, http helper + timeouts, drop user cache) --- .changeset/loud-callbacks-listen.md | 2 +- .../src/__tests__/clerk-cli-auth.test.ts | 38 +++++- packages/cli-auth/src/clerk-cli-auth.ts | 21 ++-- packages/cli-auth/src/lib/credential-store.ts | 2 + packages/cli-auth/src/lib/http.ts | 95 ++++++++++++++ packages/cli-auth/src/lib/token-exchange.ts | 116 ++++-------------- packages/cli-auth/src/lib/verify-api-key.ts | 56 ++------- packages/cli-auth/src/types.ts | 4 +- 8 files changed, 180 insertions(+), 154 deletions(-) create mode 100644 packages/cli-auth/src/lib/http.ts diff --git a/.changeset/loud-callbacks-listen.md b/.changeset/loud-callbacks-listen.md index c17a7b8e736..deb648ad0af 100644 --- a/.changeset/loud-callbacks-listen.md +++ b/.changeset/loud-callbacks-listen.md @@ -2,4 +2,4 @@ '@clerk/cli-auth': minor --- -Add `@clerk/cli-auth`: reusable OAuth 2.0 + PKCE localhost-callback flow for adding Clerk authentication to Node.js CLIs. Provides 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, and optional Clerk API key verification. +Add `@clerk/cli-auth`: reusable OAuth 2.0 + PKCE localhost-callback flow for adding Clerk authentication to Node.js CLIs. Provides 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, optional Clerk API key verification, and per-request HTTP timeouts via `requestTimeoutMs`. diff --git a/packages/cli-auth/src/__tests__/clerk-cli-auth.test.ts b/packages/cli-auth/src/__tests__/clerk-cli-auth.test.ts index 388d0c701dd..46121cf0657 100644 --- a/packages/cli-auth/src/__tests__/clerk-cli-auth.test.ts +++ b/packages/cli-auth/src/__tests__/clerk-cli-auth.test.ts @@ -1,5 +1,5 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; -import type { AddressInfo } from 'node:net'; +import type { AddressInfo, Socket } from 'node:net'; import { afterEach, describe, expect, it } from 'vitest'; @@ -355,4 +355,40 @@ describe('ClerkCliAuth', () => { 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/clerk-cli-auth.ts b/packages/cli-auth/src/clerk-cli-auth.ts index 94c6f8f3b0a..72496b7fc9b 100644 --- a/packages/cli-auth/src/clerk-cli-auth.ts +++ b/packages/cli-auth/src/clerk-cli-auth.ts @@ -79,7 +79,7 @@ export class ClerkCliAuth { keychainService: config.keychainService ?? 'clerk-cli-auth', environment, callbackPort: config.callbackPort ?? 0, - timeoutMs: config.timeoutMs ?? 120_000, + requestTimeoutMs: config.requestTimeoutMs ?? 30_000, openBrowser: config.openBrowser, apiKeys: config.apiKeys, }; @@ -93,7 +93,6 @@ export class ClerkCliAuth { const server = await startAuthServer({ expectedState: state, port: this.config.callbackPort, - timeoutMs: this.config.timeoutMs, }); try { @@ -119,14 +118,15 @@ export class ClerkCliAuth { code, codeVerifier, redirectUri: server.redirectUri, + timeoutMs: this.config.requestTimeoutMs, }); await this.setJson('tokens', tokens); const user = await fetchUserInfo({ issuer: this.config.issuer, accessToken: tokens.accessToken, + timeoutMs: this.config.requestTimeoutMs, }); - await this.setJson('user', user); return { tokens, user }; } finally { @@ -154,6 +154,7 @@ export class ClerkCliAuth { clientId: this.config.clientId, refreshToken: tokens.refreshToken, scopes: this.config.scopes, + timeoutMs: this.config.requestTimeoutMs, }); const nextTokens = { ...tokens, @@ -165,22 +166,16 @@ export class ClerkCliAuth { } async whoami(): Promise { - const cachedUser = await this.getJson('user'); - if (cachedUser) { - return cachedUser; - } - const accessToken = await this.getAccessToken(); if (!accessToken) { return null; } - const user = await fetchUserInfo({ + return fetchUserInfo({ issuer: this.config.issuer, accessToken, + timeoutMs: this.config.requestTimeoutMs, }); - await this.setJson('user', user); - return user; } async verifyApiKey(apiKey: string): Promise { @@ -190,6 +185,7 @@ export class ClerkCliAuth { return verifyApiKeyRequest({ endpoint: this.config.apiKeys.verifyEndpoint, apiKey, + timeoutMs: this.config.requestTimeoutMs, }); } @@ -227,6 +223,7 @@ export class ClerkCliAuth { 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. @@ -235,7 +232,7 @@ export class ClerkCliAuth { } try { - await Promise.all([this.config.storage.delete('tokens'), this.config.storage.delete('user')]); + await this.config.storage.delete('tokens'); } catch (error) { throw storageError('clear stored credentials', error); } diff --git a/packages/cli-auth/src/lib/credential-store.ts b/packages/cli-auth/src/lib/credential-store.ts index 716fd8d2f2c..7fe0dca451f 100644 --- a/packages/cli-auth/src/lib/credential-store.ts +++ b/packages/cli-auth/src/lib/credential-store.ts @@ -171,6 +171,8 @@ class KeychainCredentialStore implements CredentialStore { 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); 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/token-exchange.ts b/packages/cli-auth/src/lib/token-exchange.ts index 83dd0ce9672..d4418297de9 100644 --- a/packages/cli-auth/src/lib/token-exchange.ts +++ b/packages/cli-auth/src/lib/token-exchange.ts @@ -1,5 +1,6 @@ import { ClerkCliAuthError } from '../errors'; import type { TokenSet, UserInfo } from '../types'; +import { request } from './http'; export interface ExchangeParams { issuer: string; @@ -7,6 +8,7 @@ export interface ExchangeParams { code: string; codeVerifier: string; redirectUri: string; + timeoutMs?: number; } export interface RefreshParams { @@ -14,11 +16,13 @@ export interface RefreshParams { clientId: string; refreshToken: string; scopes?: string[]; + timeoutMs?: number; } export interface UserInfoParams { issuer: string; accessToken: string; + timeoutMs?: number; } export interface RevokeParams { @@ -26,6 +30,7 @@ export interface RevokeParams { clientId: string; token: string; tokenTypeHint?: 'access_token' | 'refresh_token'; + timeoutMs?: number; } interface OAuthTokenResponse { @@ -41,30 +46,6 @@ function endpoint(issuer: string, path: string): string { return `${issuer.replace(/\/+$/, '')}${path}`; } -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 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.'); @@ -93,30 +74,14 @@ function mapTokenResponse(data: OAuthTokenResponse): TokenSet { return tokenSet; } -async function requestTokens(issuer: string, body: URLSearchParams): Promise { - let response: Response; - try { - response = await fetch(endpoint(issuer, '/oauth/token'), { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: body.toString(), - }); - } catch (error) { - throw new ClerkCliAuthError('token_exchange', `Token request failed: ${(error as Error).message}`); - } - - let parsed: unknown; - try { - parsed = await parseBody(response); - } catch (error) { - throw new ClerkCliAuthError('token_exchange', `Token response could not be parsed: ${(error as Error).message}`); - } - if (!response.ok) { - throw new ClerkCliAuthError( - 'token_exchange', - messageFromBody(parsed, `Token request failed with HTTP ${response.status}.`), - ); - } +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.'); @@ -134,7 +99,7 @@ export async function exchangeCodeForTokens(params: ExchangeParams): Promise { @@ -148,7 +113,7 @@ export async function refreshAccessToken(params: RefreshParams): Promise { @@ -160,48 +125,21 @@ export async function revokeToken(params: RevokeParams): Promise { body.set('token_type_hint', params.tokenTypeHint); } - let response: Response; - try { - response = await fetch(endpoint(params.issuer, '/oauth/token/revoke'), { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: body.toString(), - }); - } catch (error) { - throw new ClerkCliAuthError('revoke', `Revoke request failed: ${(error as Error).message}`); - } - - if (!response.ok) { - const parsed = await parseBody(response).catch(() => null); - throw new ClerkCliAuthError( - 'revoke', - messageFromBody(parsed, `Revoke request failed with HTTP ${response.status}.`), - ); - } + 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 fetchUserInfo(params: UserInfoParams): Promise { - let response: Response; - try { - response = await fetch(endpoint(params.issuer, '/oauth/userinfo'), { - headers: { Authorization: `Bearer ${params.accessToken}` }, - }); - } catch (error) { - throw new ClerkCliAuthError('userinfo', `Userinfo request failed: ${(error as Error).message}`); - } - - let parsed: unknown; - try { - parsed = await parseBody(response); - } catch (error) { - throw new ClerkCliAuthError('userinfo', `Userinfo response could not be parsed: ${(error as Error).message}`); - } - if (!response.ok) { - throw new ClerkCliAuthError( - 'userinfo', - messageFromBody(parsed, `Userinfo request failed with HTTP ${response.status}.`), - ); - } + 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.'); diff --git a/packages/cli-auth/src/lib/verify-api-key.ts b/packages/cli-auth/src/lib/verify-api-key.ts index e9cf61afdea..54806349757 100644 --- a/packages/cli-auth/src/lib/verify-api-key.ts +++ b/packages/cli-auth/src/lib/verify-api-key.ts @@ -1,61 +1,19 @@ import { ClerkCliAuthError } from '../errors'; import type { UserInfo } from '../types'; +import { request } from './http'; export interface VerifyApiKeyParams { endpoint: string; apiKey: string; -} - -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; + timeoutMs?: number; } export async function verifyApiKey(params: VerifyApiKeyParams): Promise { - let response: Response; - try { - response = await fetch(params.endpoint, { - headers: { Authorization: `Bearer ${params.apiKey}` }, - }); - } catch (error) { - throw new ClerkCliAuthError('verify_api_key', `API key verification request failed: ${(error as Error).message}`); - } - - let parsed: unknown; - try { - parsed = await parseBody(response); - } catch (error) { - throw new ClerkCliAuthError( - 'verify_api_key', - `API key verification response could not be parsed: ${(error as Error).message}`, - ); - } - - if (!response.ok) { - throw new ClerkCliAuthError( - 'verify_api_key', - messageFromBody(parsed, `API key verification failed with HTTP ${response.status}.`), - ); - } + const { body: parsed } = await request(params.endpoint, { + headers: { Authorization: `Bearer ${params.apiKey}` }, + errorCode: 'verify_api_key', + timeoutMs: params.timeoutMs, + }); if (!parsed || typeof parsed !== 'object') { throw new ClerkCliAuthError('verify_api_key', 'API key verification response was not a JSON object.'); diff --git a/packages/cli-auth/src/types.ts b/packages/cli-auth/src/types.ts index 6c7afeeb845..acad9f980c6 100644 --- a/packages/cli-auth/src/types.ts +++ b/packages/cli-auth/src/types.ts @@ -36,8 +36,8 @@ export interface ClerkCliAuthConfig { environment?: string; /** Override the port the ephemeral callback server binds to. Default: 0 (random). */ callbackPort?: number; - /** Callback server timeout in ms. Default: 120000 (2min). */ - timeoutMs?: 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; /** Enables Clerk API key (`ak_*`) auth alongside OAuth. */ From b0c5e9fcee5de996af74df35cd037c0cfd830d4c Mon Sep 17 00:00:00 2001 From: Nicolas Angelo Date: Thu, 28 May 2026 09:00:13 -0400 Subject: [PATCH 03/20] feat(cli-auth): add optional loginTimeoutMs config (default 120s) --- .changeset/loud-callbacks-listen.md | 2 +- packages/cli-auth/src/clerk-cli-auth.ts | 2 ++ packages/cli-auth/src/types.ts | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.changeset/loud-callbacks-listen.md b/.changeset/loud-callbacks-listen.md index deb648ad0af..63da231ff0e 100644 --- a/.changeset/loud-callbacks-listen.md +++ b/.changeset/loud-callbacks-listen.md @@ -2,4 +2,4 @@ '@clerk/cli-auth': minor --- -Add `@clerk/cli-auth`: reusable OAuth 2.0 + PKCE localhost-callback flow for adding Clerk authentication to Node.js CLIs. Provides 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, optional Clerk API key verification, and per-request HTTP timeouts via `requestTimeoutMs`. +Add `@clerk/cli-auth`: reusable OAuth 2.0 + PKCE localhost-callback flow for adding Clerk authentication to Node.js CLIs. Provides 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, optional Clerk API key verification, and tunable timeouts (`loginTimeoutMs` for the browser sign-in wait, `requestTimeoutMs` for each outbound HTTP call). diff --git a/packages/cli-auth/src/clerk-cli-auth.ts b/packages/cli-auth/src/clerk-cli-auth.ts index 72496b7fc9b..75135a8e06a 100644 --- a/packages/cli-auth/src/clerk-cli-auth.ts +++ b/packages/cli-auth/src/clerk-cli-auth.ts @@ -79,6 +79,7 @@ export class ClerkCliAuth { keychainService: config.keychainService ?? 'clerk-cli-auth', environment, callbackPort: config.callbackPort ?? 0, + loginTimeoutMs: config.loginTimeoutMs ?? 120_000, requestTimeoutMs: config.requestTimeoutMs ?? 30_000, openBrowser: config.openBrowser, apiKeys: config.apiKeys, @@ -93,6 +94,7 @@ export class ClerkCliAuth { const server = await startAuthServer({ expectedState: state, port: this.config.callbackPort, + timeoutMs: this.config.loginTimeoutMs, }); try { diff --git a/packages/cli-auth/src/types.ts b/packages/cli-auth/src/types.ts index acad9f980c6..90d07a2223a 100644 --- a/packages/cli-auth/src/types.ts +++ b/packages/cli-auth/src/types.ts @@ -36,6 +36,8 @@ export interface ClerkCliAuthConfig { 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. */ From 06c2fba4a71a5abe49f139d158c4102e93e46894 Mon Sep 17 00:00:00 2001 From: Nicolas Angelo Date: Thu, 28 May 2026 17:09:27 -0400 Subject: [PATCH 04/20] feat(cli-auth)!: rename UserInfo to Identity and verifyApiKey to verifyToken --- packages/cli-auth/src/clerk-cli-auth.ts | 24 +++++++-------- packages/cli-auth/src/index.ts | 4 +-- packages/cli-auth/src/lib/token-exchange.ts | 12 ++++---- packages/cli-auth/src/lib/verify-api-key.ts | 28 ----------------- packages/cli-auth/src/lib/verify-token.ts | 33 +++++++++++++++++++++ packages/cli-auth/src/types.ts | 25 +++++++++------- 6 files changed, 67 insertions(+), 59 deletions(-) delete mode 100644 packages/cli-auth/src/lib/verify-api-key.ts create mode 100644 packages/cli-auth/src/lib/verify-token.ts diff --git a/packages/cli-auth/src/clerk-cli-auth.ts b/packages/cli-auth/src/clerk-cli-auth.ts index 75135a8e06a..74cf6f99db3 100644 --- a/packages/cli-auth/src/clerk-cli-auth.ts +++ b/packages/cli-auth/src/clerk-cli-auth.ts @@ -5,9 +5,9 @@ import { startAuthServer } from './lib/auth-server'; import { classifyToken, type TokenKind } from './lib/classify-token'; import { createCredentialStore } from './lib/credential-store'; import { generateCodeChallenge, generateCodeVerifier, generateState } from './lib/pkce'; -import { exchangeCodeForTokens, fetchUserInfo, refreshAccessToken, revokeToken } from './lib/token-exchange'; -import { verifyApiKey as verifyApiKeyRequest } from './lib/verify-api-key'; -import type { ClerkCliAuthConfig, CredentialStore, LoginResult, OAuthScope, TokenSet, UserInfo } from './types'; +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 } from './types'; const DEFAULT_SCOPES: OAuthScope[] = ['profile', 'email', 'openid', 'offline_access']; @@ -124,7 +124,7 @@ export class ClerkCliAuth { }); await this.setJson('tokens', tokens); - const user = await fetchUserInfo({ + const user = await fetchIdentity({ issuer: this.config.issuer, accessToken: tokens.accessToken, timeoutMs: this.config.requestTimeoutMs, @@ -167,26 +167,26 @@ export class ClerkCliAuth { return nextTokens.accessToken; } - async whoami(): Promise { + async whoami(): Promise { const accessToken = await this.getAccessToken(); if (!accessToken) { return null; } - return fetchUserInfo({ + return fetchIdentity({ issuer: this.config.issuer, accessToken, timeoutMs: this.config.requestTimeoutMs, }); } - async verifyApiKey(apiKey: string): Promise { - if (!this.config.apiKeys?.verifyEndpoint) { - throw new ClerkCliAuthError('config', 'apiKeys.verifyEndpoint is not configured.'); + async verifyToken(token: string): Promise { + if (!this.config.apiKeys?.identityEndpoint) { + throw new ClerkCliAuthError('config', 'apiKeys.identityEndpoint is not configured.'); } - return verifyApiKeyRequest({ - endpoint: this.config.apiKeys.verifyEndpoint, - apiKey, + return verifyTokenRequest({ + endpoint: this.config.apiKeys.identityEndpoint, + token, timeoutMs: this.config.requestTimeoutMs, }); } diff --git a/packages/cli-auth/src/index.ts b/packages/cli-auth/src/index.ts index 8bfc83e5c80..628f57cd0b6 100644 --- a/packages/cli-auth/src/index.ts +++ b/packages/cli-auth/src/index.ts @@ -13,6 +13,6 @@ export { ClerkCliAuth } from './clerk-cli-auth'; export * from './types'; export * from './errors'; -export { fetchUserInfo, revokeToken } from './lib/token-exchange'; -export { verifyApiKey } from './lib/verify-api-key'; +export { fetchIdentity, revokeToken } from './lib/token-exchange'; +export { verifyToken } from './lib/verify-token'; export { classifyToken, type TokenKind } from './lib/classify-token'; diff --git a/packages/cli-auth/src/lib/token-exchange.ts b/packages/cli-auth/src/lib/token-exchange.ts index d4418297de9..6b6354a670b 100644 --- a/packages/cli-auth/src/lib/token-exchange.ts +++ b/packages/cli-auth/src/lib/token-exchange.ts @@ -1,5 +1,5 @@ import { ClerkCliAuthError } from '../errors'; -import type { TokenSet, UserInfo } from '../types'; +import type { Identity, TokenSet } from '../types'; import { request } from './http'; export interface ExchangeParams { @@ -19,7 +19,7 @@ export interface RefreshParams { timeoutMs?: number; } -export interface UserInfoParams { +export interface FetchIdentityParams { issuer: string; accessToken: string; timeoutMs?: number; @@ -134,7 +134,7 @@ export async function revokeToken(params: RevokeParams): Promise { }); } -export async function fetchUserInfo(params: UserInfoParams): Promise { +export async function fetchIdentity(params: FetchIdentityParams): Promise { const { body: parsed } = await request(endpoint(params.issuer, '/oauth/userinfo'), { headers: { Authorization: `Bearer ${params.accessToken}` }, errorCode: 'userinfo', @@ -145,10 +145,10 @@ export async function fetchUserInfo(params: UserInfoParams): Promise { throw new ClerkCliAuthError('userinfo', 'Userinfo response was not JSON.'); } - const user = parsed as UserInfo; - if (typeof user.sub !== 'string' || user.sub.length === 0) { + const identity = parsed as Identity; + if (typeof identity.sub !== 'string' || identity.sub.length === 0) { throw new ClerkCliAuthError('userinfo', 'Userinfo response did not include sub.'); } - return user; + return identity; } diff --git a/packages/cli-auth/src/lib/verify-api-key.ts b/packages/cli-auth/src/lib/verify-api-key.ts deleted file mode 100644 index 54806349757..00000000000 --- a/packages/cli-auth/src/lib/verify-api-key.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ClerkCliAuthError } from '../errors'; -import type { UserInfo } from '../types'; -import { request } from './http'; - -export interface VerifyApiKeyParams { - endpoint: string; - apiKey: string; - timeoutMs?: number; -} - -export async function verifyApiKey(params: VerifyApiKeyParams): Promise { - const { body: parsed } = await request(params.endpoint, { - headers: { Authorization: `Bearer ${params.apiKey}` }, - errorCode: 'verify_api_key', - timeoutMs: params.timeoutMs, - }); - - if (!parsed || typeof parsed !== 'object') { - throw new ClerkCliAuthError('verify_api_key', 'API key verification response was not a JSON object.'); - } - - const user = parsed as UserInfo; - if (typeof user.sub !== 'string' || user.sub.length === 0) { - throw new ClerkCliAuthError('verify_api_key', 'API key verification response did not include sub.'); - } - - return user; -} 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..a1db47cf184 --- /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, machine token, 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/types.ts b/packages/cli-auth/src/types.ts index 90d07a2223a..f166073f110 100644 --- a/packages/cli-auth/src/types.ts +++ b/packages/cli-auth/src/types.ts @@ -42,15 +42,16 @@ export interface ClerkCliAuthConfig { requestTimeoutMs?: number; /** Injected opener for the browser step (for testing). Default: auto-detect. */ openBrowser?: (url: string) => Promise; - /** Enables Clerk API key (`ak_*`) auth alongside OAuth. */ + /** Enables non-OAuth credential auth (API keys, machine tokens) alongside OAuth. */ apiKeys?: { /** - * Backend endpoint that verifies an API key and returns the auth payload. - * Called with `Authorization: Bearer `. API keys can't hit /oauth/userinfo — - * they need server-side verification (e.g. `clerk.apiKeys.verify()` in your backend). + * Backend endpoint that returns the verified `Identity` for a credential. 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. */ - verifyEndpoint: string; - /** Env var to read an API key from (e.g. 'MYAPP_API_KEY'). */ + identityEndpoint: string; + /** Env var to read a credential from (e.g. 'MYAPP_API_KEY'). */ envVar: string; }; } @@ -65,12 +66,14 @@ export interface TokenSet { } /** - * Identity payload returned by Clerk's /oauth/userinfo or an API key verify endpoint. - * `sub` is the only guaranteed field; everything else depends on scopes and source. + * Verified identity payload returned by `/oauth/userinfo` or your `identityEndpoint`. + * `sub` is the only guaranteed field — it holds the Clerk subject id (`user_*`, `org_*`, + * `mch_*`, or `scim_*`). Every other field is optional and depends on the credential type + * and scopes (user-shaped fields like `email` only show up for user-scoped subjects). */ -export interface UserInfo { +export interface Identity { sub: string; - /** Clerk user id. Returned by API key verify; OAuth userinfo puts it in `sub` instead. */ + /** Clerk user id. Returned by identity endpoints; OAuth userinfo puts it in `sub` instead. */ user_id?: string; email?: string; email_verified?: boolean; @@ -93,5 +96,5 @@ export interface UserInfo { export interface LoginResult { tokens: TokenSet; - user: UserInfo; + user: Identity; } From e456f81e3faa81b9041f5d94d59f1ab77ee1bef5 Mon Sep 17 00:00:00 2001 From: Nicolas Angelo Date: Thu, 28 May 2026 17:09:41 -0400 Subject: [PATCH 05/20] feat(cli-auth)!: expand TokenKind to match @clerk/backend's TokenType --- packages/cli-auth/src/clerk-cli-auth.ts | 4 ++-- packages/cli-auth/src/lib/classify-token.ts | 22 ++++++++++++++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/cli-auth/src/clerk-cli-auth.ts b/packages/cli-auth/src/clerk-cli-auth.ts index 74cf6f99db3..c4e0b6dec5f 100644 --- a/packages/cli-auth/src/clerk-cli-auth.ts +++ b/packages/cli-auth/src/clerk-cli-auth.ts @@ -201,12 +201,12 @@ export class ClerkCliAuth { if (this.config.apiKeys?.envVar) { const fromEnv = process.env[this.config.apiKeys.envVar]; if (fromEnv) { - return { token: fromEnv, kind: 'api_key' }; + return { token: fromEnv, kind: classifyToken(fromEnv) }; } } const accessToken = await this.getAccessToken(); if (accessToken) { - return { token: accessToken, kind: 'oauth' }; + return { token: accessToken, kind: classifyToken(accessToken) }; } const envHint = this.config.apiKeys?.envVar ? ` or set $${this.config.apiKeys.envVar}` : ''; diff --git a/packages/cli-auth/src/lib/classify-token.ts b/packages/cli-auth/src/lib/classify-token.ts index 7ee6c1c18cd..3033d1e2b84 100644 --- a/packages/cli-auth/src/lib/classify-token.ts +++ b/packages/cli-auth/src/lib/classify-token.ts @@ -1,6 +1,22 @@ -export type TokenKind = 'oauth' | 'api_key'; +/** + * Token kind values. Mirror `@clerk/backend`'s `TokenType` vocabulary so consumers can + * route on the same identifiers across the SDK and the backend. + */ +export type TokenKind = 'session_token' | 'api_key' | 'm2m_token' | 'oauth_token'; -/** `ak_*` → API key, anything else → OAuth (opaque or JWT). */ +/** + * Classify a token by its prefix. `ak_*` → `'api_key'`, `mt_*` → `'m2m_token'`, + * `oat_*` → `'oauth_token'`. Anything else (JWT-shaped session tokens) → `'session_token'`. + */ export function classifyToken(token: string): TokenKind { - return token.startsWith('ak_') ? 'api_key' : 'oauth'; + if (token.startsWith('ak_')) { + return 'api_key'; + } + if (token.startsWith('mt_')) { + return 'm2m_token'; + } + if (token.startsWith('oat_')) { + return 'oauth_token'; + } + return 'session_token'; } From 6dec304bf97b895766aab52f39263248ca9643b8 Mon Sep 17 00:00:00 2001 From: Nicolas Angelo Date: Thu, 28 May 2026 17:10:08 -0400 Subject: [PATCH 06/20] feat(cli-auth): add @clerk/cli-auth/server export with cliAuth factory --- packages/cli-auth/package.json | 11 ++ packages/cli-auth/src/server/cli-auth.ts | 181 +++++++++++++++++++ packages/cli-auth/src/server/detect-type.ts | 50 +++++ packages/cli-auth/src/server/index.ts | 17 ++ packages/cli-auth/src/server/resolve-auth.ts | 76 ++++++++ packages/cli-auth/src/server/types.ts | 131 ++++++++++++++ packages/cli-auth/src/server/verify-token.ts | 113 ++++++++++++ packages/cli-auth/tsup.config.ts | 1 + pnpm-lock.yaml | 3 + 9 files changed, 583 insertions(+) create mode 100644 packages/cli-auth/src/server/cli-auth.ts create mode 100644 packages/cli-auth/src/server/detect-type.ts create mode 100644 packages/cli-auth/src/server/index.ts create mode 100644 packages/cli-auth/src/server/resolve-auth.ts create mode 100644 packages/cli-auth/src/server/types.ts create mode 100644 packages/cli-auth/src/server/verify-token.ts diff --git a/packages/cli-auth/package.json b/packages/cli-auth/package.json index 005b2b816ad..88f1fff8885 100644 --- a/packages/cli-auth/package.json +++ b/packages/cli-auth/package.json @@ -36,6 +36,16 @@ "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", @@ -56,6 +66,7 @@ "test:watch": "vitest watch" }, "dependencies": { + "@clerk/backend": "workspace:^", "@napi-rs/keyring": "^1.1.7", "tslib": "catalog:repo" }, 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..82cf817a971 --- /dev/null +++ b/packages/cli-auth/src/server/cli-auth.ts @@ -0,0 +1,181 @@ +import { type ClerkClient, type ClerkOptions, createClerkClient } from '@clerk/backend'; +import type { TokenType } from '@clerk/backend/internal'; + +import { ClerkCliAuthError, EXIT_CODE } from '../errors'; +import { detectTokenType, isTokenTypeAccepted } from './detect-type'; +import { resolveAuthInfo as defaultResolveAuthInfo, validateIdentity } from './resolve-auth'; +import type { + CliAuthFactoryOptions, + CliAuthInstance, + ClientArg, + HandleOptions, + ResolveAuthInfoContext, + TokenInfo, + VerifyTokenContext, + VerifyTokenFn, +} from './types'; +import { defaultVerifyToken, readBearer, runHandlePipeline } 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) }, + ); +} + +/** Build a getClerk thunk for the given factory/route 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 a set of helpers ready to drop into your routes. + * + * `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 { handle, verifyToken, verifyTokenFromRequest, resolveAuthInfo } = + * cliAuth({ client: clerkClient }); + * + * // app/api/cli/verify/route.ts + * import { handle } from '@/lib/clerk-cli'; + * + * export const GET = handle({ + * accepts: ['api_key', 'oauth_token'], + * // Optional per-route overrides: + * // client / clientConfig — different Clerk client for this route only + * // verifyToken: ({ token, type, request, clerk }) => ... + * // resolveAuthInfo: ({ tokenInfo, request, clerk }) => ... + * }); + * ``` + */ +export function cliAuth(options: CliAuthFactoryOptions = {}): CliAuthInstance { + const factoryGetClerk = makeClerkGetter(options.client, options.clientConfig); + + async function verifyToken( + ctx: Omit, 'clerk'> & { clerk?: ClerkClient }, + ): Promise> { + const clerk = ctx.clerk ?? (await factoryGetClerk()); + return defaultVerifyToken({ ...ctx, clerk } as VerifyTokenContext); + } + + async function verifyTokenFromRequest( + request: Request, + routeOptions: { accepts: HandleOptions['accepts'] }, + ): Promise> { + const token = readBearer(request); + const type = detectTokenType(token); + if (!isTokenTypeAccepted(type, routeOptions.accepts)) { + throw new ClerkCliAuthError('not_authenticated', `Token type "${type}" is not accepted by this endpoint.`); + } + return verifyToken({ token, type: type as T, request }); + } + + function resolveAuthInfo( + ctx: Omit, 'clerk'> & { clerk?: ClerkClient }, + ): ReturnType { + return defaultResolveAuthInfo(ctx as ResolveAuthInfoContext); + } + + function handle(routeOptions: HandleOptions): (request: Request) => Promise { + // Per-route client override gets its own getter; falls back to the factory's getter + // when neither override is supplied. + const routeGetClerk = + routeOptions.client || routeOptions.clientConfig + ? makeClerkGetter(routeOptions.client, routeOptions.clientConfig) + : factoryGetClerk; + + return async function routeHandler(request: Request): Promise { + try { + const verifier = (routeOptions.verifyToken ?? defaultVerifyToken) as unknown as VerifyTokenFn; + + const { tokenInfo } = await runHandlePipeline(request, { + accepts: routeOptions.accepts, + verifyToken: verifier, + getClerk: routeGetClerk, + }); + + const clerk = await routeGetClerk(); + const resolver = routeOptions.resolveAuthInfo ?? defaultResolveAuthInfo; + const raw = await resolver({ tokenInfo: tokenInfo as 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}`)); + } + }; + } + + return { + handle, + verifyToken, + verifyTokenFromRequest, + resolveAuthInfo, + }; +} diff --git a/packages/cli-auth/src/server/detect-type.ts b/packages/cli-auth/src/server/detect-type.ts new file mode 100644 index 00000000000..5943f6fa854 --- /dev/null +++ b/packages/cli-auth/src/server/detect-type.ts @@ -0,0 +1,50 @@ +import { isMachineToken, TokenType } from '@clerk/backend/internal'; + +import { ClerkCliAuthError } from '../errors'; +import type { AcceptsToken } from './types'; + +const M2M_TOKEN_PREFIX = 'mt_'; +const OAUTH_TOKEN_PREFIX = 'oat_'; +const API_KEY_PREFIX = 'ak_'; + +const JWT_FORMAT = /^[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+$/; + +/** + * Detect the token type from a raw bearer string. Mirrors `@clerk/backend`'s own + * discrimination: prefix-based for machine tokens, JWT shape for sessions. + * + * `getMachineTokenType` and `isJwtFormat` aren't re-exported from `@clerk/backend/internal`, + * so we inline minimal equivalents using `isMachineToken` for the prefix check. + */ +export function detectTokenType(token: string): TokenType { + if (isMachineToken(token)) { + if (token.startsWith(API_KEY_PREFIX)) { + return TokenType.ApiKey; + } + if (token.startsWith(M2M_TOKEN_PREFIX)) { + return TokenType.M2MToken; + } + if (token.startsWith(OAUTH_TOKEN_PREFIX)) { + return TokenType.OAuthToken; + } + // JWT-shaped machine token (OAuth or M2M based on JWT typ/sub) — treat as oauth_token + // since it's the more common path; consumers can override `verifyToken` if they need M2M JWT. + return TokenType.OAuthToken; + } + if (JWT_FORMAT.test(token)) { + return TokenType.SessionToken; + } + throw new ClerkCliAuthError('verify_api_key', 'Unable to determine token type for credential.'); +} + +/** + * Return true if `detected` is in the consumer's `accepts` list. Mirrors + * `@clerk/backend`'s `isTokenTypeAccepted`. + */ +export function isTokenTypeAccepted(detected: TokenType, accepts: AcceptsToken): boolean { + if (accepts === 'any') { + return true; + } + const list = Array.isArray(accepts) ? accepts : [accepts as TokenType]; + return list.includes(detected); +} diff --git a/packages/cli-auth/src/server/index.ts b/packages/cli-auth/src/server/index.ts new file mode 100644 index 00000000000..bde865e5475 --- /dev/null +++ b/packages/cli-auth/src/server/index.ts @@ -0,0 +1,17 @@ +export { cliAuth } from './cli-auth'; +export { detectTokenType, isTokenTypeAccepted } from './detect-type'; + +export type { + AcceptsToken, + CliAuthFactoryOptions, + CliAuthInstance, + HandleOptions, + ResolveAuthInfoContext, + ResolveAuthInfoFn, + TokenInfo, + VerifyTokenContext, + VerifyTokenFn, +} from './types'; + +// Re-export the canonical TokenType from @clerk/backend so consumers don't have to dual-import. +export { TokenType } from '@clerk/backend/internal'; 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..afa3260ff1c --- /dev/null +++ b/packages/cli-auth/src/server/resolve-auth.ts @@ -0,0 +1,76 @@ +import type { TokenType } from '@clerk/backend/internal'; + +import { ClerkCliAuthError } from '../errors'; +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..7e433c2fe54 --- /dev/null +++ b/packages/cli-auth/src/server/types.ts @@ -0,0 +1,131 @@ +import type { ClerkClient, ClerkOptions } from '@clerk/backend'; +import type { TokenType } from '@clerk/backend/internal'; + +import type { Identity } from '../types'; + +/** + * Which token types this endpoint accepts. Mirrors `@clerk/backend`'s `acceptsToken`: + * a single `TokenType`, a readonly tuple of `TokenType`s, or the literal `'any'`. + */ +export type AcceptsToken = TokenType | readonly TokenType[] | '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 (`session_token` | `api_key` | `m2m_token` | `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` callback. `token` is the raw, unverified bearer. + * `clerk` is the resolved Clerk Backend client (the factory's default, or a route override). + */ +export interface VerifyTokenContext { + /** Raw bearer token from the `Authorization` header. */ + token: string; + /** Token type 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. + */ +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 bound `handle()` returned by `cliAuth()`. */ +export interface HandleOptions { + /** Which token types this endpoint accepts. Rejects every other type with 401. */ + accepts: AcceptsToken; + /** Per-route client override. Falls back to the factory's client when omitted. */ + client?: ClientArg; + /** Per-route `clientConfig` override. Falls back to the factory's config when omitted. */ + clientConfig?: ClerkOptions; + /** Override the verification step. Defaults to a `@clerk/backend`-backed verifier. */ + verifyToken?: VerifyTokenFn; + /** Override the info-resolution step. Defaults to extracting subject + claims into `Identity`. */ + resolveAuthInfo?: ResolveAuthInfoFn; +} + +/** + * Shape returned by `cliAuth({ client })`. Destructure the bits you need. + * + * @example + * ```ts + * // lib/clerk-cli.ts + * import { cliAuth } from '@clerk/cli-auth/server'; + * import { clerkClient } from '@clerk/nextjs/server'; + * + * export const { handle, verifyToken, verifyTokenFromRequest, resolveAuthInfo } = + * cliAuth({ client: clerkClient }); + * + * // app/api/cli/verify/route.ts + * export const GET = handle({ accepts: ['api_key', 'oauth_token'] }); + * ``` + */ +export interface CliAuthInstance { + /** Wrap a route handler for any framework using Web `Request`/`Response`. */ + handle: (opts: HandleOptions) => (request: Request) => Promise; + /** Primitive verifier: raw bearer in, verified `TokenInfo` out. `clerk` is auto-injected from the factory's client. */ + verifyToken: ( + ctx: Omit, 'clerk'> & { clerk?: ClerkClient }, + ) => Promise>; + /** High-level verifier: `Request` in, verified `TokenInfo` out. Uses the factory's client. */ + 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; +} 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..660bd7594cc --- /dev/null +++ b/packages/cli-auth/src/server/verify-token.ts @@ -0,0 +1,113 @@ +import type { ClerkClient } from '@clerk/backend'; +import { TokenType, type TokenType as TokenTypeT } from '@clerk/backend/internal'; + +import { ClerkCliAuthError } from '../errors'; +import { detectTokenType, isTokenTypeAccepted } from './detect-type'; +import type { AcceptsToken, TokenInfo, VerifyTokenContext, VerifyTokenFn } from './types'; + +const BEARER_PREFIX = /^Bearer\s+/i; + +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; +} + +/** + * Default token verifier — uses the `clerk` client from ctx to verify each supported token type. + * + * - `api_key`: `clerk.apiKeys.verify({ secret: token })` + * - `session_token` / `m2m_token` / `oauth_token`: `clerk.authenticateRequest(request, { acceptsToken: [type] })` + * + * Consumers can replace this entirely by passing a `verifyToken` to `cliAuth()` or `handle()`. + */ +export async function defaultVerifyToken(ctx: VerifyTokenContext): Promise> { + const { clerk } = ctx; + + if (ctx.type === TokenType.ApiKey) { + try { + const apiKey = (await clerk.apiKeys.verify({ secret: ctx.token })) as unknown as { + subject: string; + scopes?: string[]; + revoked?: boolean; + expired?: boolean; + claims?: Record; + }; + if (apiKey.revoked || apiKey.expired) { + throw new ClerkCliAuthError('verify_api_key', 'API key revoked or expired.'); + } + return { + subject: apiKey.subject, + type: ctx.type, + scopes: apiKey.scopes, + claims: apiKey.claims, + }; + } catch (error) { + if (error instanceof ClerkCliAuthError) { + throw error; + } + throw new ClerkCliAuthError('verify_api_key', `API key verification failed: ${(error as Error).message}`); + } + } + + // OAuth / M2M / session — delegate to authenticateRequest. + try { + const state = (await clerk.authenticateRequest(ctx.request, { acceptsToken: [ctx.type] })) as unknown as { + isAuthenticated?: boolean; + reason?: string; + toAuth?: () => { subject?: string; userId?: string; scopes?: string[]; claims?: Record } | null; + }; + if (state.isAuthenticated === false) { + throw new ClerkCliAuthError('not_authenticated', state.reason ?? 'Token rejected by Clerk.'); + } + const auth = state.toAuth?.(); + if (!auth) { + throw new ClerkCliAuthError('not_authenticated', 'authenticateRequest returned no auth payload.'); + } + const subject = auth.subject ?? auth.userId; + if (typeof subject !== 'string' || !subject) { + throw new ClerkCliAuthError('not_authenticated', 'Verified token had no subject.'); + } + return { + subject, + type: ctx.type, + scopes: auth.scopes, + claims: auth.claims, + }; + } catch (error) { + if (error instanceof ClerkCliAuthError) { + throw error; + } + throw new ClerkCliAuthError('not_authenticated', `Token verification failed: ${(error as Error).message}`); + } +} + +/** + * Internal: end-to-end pipeline used by `handle()`. Read bearer, detect type, gate on + * `accepts`, run the (default or overridden) verifier with the resolved `clerk` injected. + */ +export async function runHandlePipeline( + request: Request, + options: { + accepts: AcceptsToken; + verifyToken: VerifyTokenFn; + getClerk: () => Promise; + }, +): Promise<{ tokenInfo: TokenInfo; rawToken: string }> { + const token = readBearer(request); + const type = detectTokenType(token); + + if (!isTokenTypeAccepted(type, options.accepts)) { + throw new ClerkCliAuthError('not_authenticated', `Token type "${type}" is not accepted by this endpoint.`); + } + + const clerk = await options.getClerk(); + const tokenInfo = await options.verifyToken({ token, type, request, clerk }); + return { tokenInfo, rawToken: token }; +} diff --git a/packages/cli-auth/tsup.config.ts b/packages/cli-auth/tsup.config.ts index 5a9b32d5501..b3fe802effb 100644 --- a/packages/cli-auth/tsup.config.ts +++ b/packages/cli-auth/tsup.config.ts @@ -9,6 +9,7 @@ export default defineConfig(overrideOptions => { return { entry: { index: './src/index.ts', + server: './src/server/index.ts', }, format: ['cjs', 'esm'], bundle: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d32b2fad858..8a165b8b2d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -513,6 +513,9 @@ importers: packages/cli-auth: dependencies: + '@clerk/backend': + specifier: workspace:^ + version: link:../backend '@napi-rs/keyring': specifier: ^1.1.7 version: 1.3.0 From cb59eefb14fc3a5312f59567335b7c72f15c0700 Mon Sep 17 00:00:00 2001 From: Nicolas Angelo Date: Thu, 28 May 2026 17:10:18 -0400 Subject: [PATCH 07/20] docs(cli-auth): rewrite README and changeset for /server export and multi-token auth --- .changeset/loud-callbacks-listen.md | 20 +- packages/cli-auth/README.md | 284 +++++++++++++++++++++++----- 2 files changed, 252 insertions(+), 52 deletions(-) diff --git a/.changeset/loud-callbacks-listen.md b/.changeset/loud-callbacks-listen.md index 63da231ff0e..da31d4214fc 100644 --- a/.changeset/loud-callbacks-listen.md +++ b/.changeset/loud-callbacks-listen.md @@ -2,4 +2,22 @@ '@clerk/cli-auth': minor --- -Add `@clerk/cli-auth`: reusable OAuth 2.0 + PKCE localhost-callback flow for adding Clerk authentication to Node.js CLIs. Provides 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, optional Clerk API key verification, and tunable timeouts (`loginTimeoutMs` for the browser sign-in wait, `requestTimeoutMs` for each outbound HTTP call). +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-token resolution via `resolveToken()`, optional Clerk API key verification, and tunable timeouts (`loginTimeoutMs`, `requestTimeoutMs`). + +The new `@clerk/cli-auth/server` subpath ships a backend route handler factory 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 { handle, verifyToken, verifyTokenFromRequest, resolveAuthInfo } = + cliAuth({ client: await clerkClient() }); + +// app/api/cli/verify/route.ts +export const GET = handle({ accepts: ['api_key', 'oauth_token'] }); +``` + +`cliAuth({ client | clientConfig })` binds a `@clerk/backend` client once and returns `handle()`, `verifyToken()`, `verifyTokenFromRequest()`, and `resolveAuthInfo()` ready to use. `handle({ accepts, verifyToken?, resolveAuthInfo? })` produces a route handler that detects token type by prefix, gates against `accepts`, verifies via `@clerk/backend`, and returns a `UserInfo` JSON payload. Override the verification or resolution steps per-route by passing the corresponding callbacks. diff --git a/packages/cli-auth/README.md b/packages/cli-auth/README.md index 7a1f1b2b921..b590a4f7708 100644 --- a/packages/cli-auth/README.md +++ b/packages/cli-auth/README.md @@ -27,13 +27,17 @@ ## Getting Started -`@clerk/cli-auth` implements the OAuth 2.0 Authorization Code + PKCE localhost-callback flow for adding [Clerk](https://clerk.com) authentication to Node.js command-line tools. +`@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). -- An OAuth Application registered with your Clerk instance (see [Setup](#setup) below) ### Installation @@ -43,45 +47,32 @@ npm install @clerk/cli-auth ## Setup -You need two things: an **OAuth Application** registered with a Clerk instance, and the `client_id` + issuer URL from it. - -### 1. Create an OAuth Application +Create an OAuth Application in your Clerk instance and grab its `client_id` and issuer URL. -Pick whichever path fits your workflow. +### Create an OAuth Application -**Clerk Dashboard (recommended for most devs)** — in your dev instance, go to **Configure → OAuth Applications → Create**. Set: +In the [Clerk Dashboard](https://dashboard.clerk.com), 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` redirect URI during authorization) -- Public client (PKCE): enabled -- Scopes: `profile email openid offline_access` +- **Name** — your CLI's name +- **Redirect URI** — `http://127.0.0.1/callback` +- **Public client (PKCE)** — enabled +- **Scopes** — `profile email openid offline_access` -**curl against BAPI** — if you prefer scripting. Replace `$SK` with your instance's secret key: +The dashboard returns a `client_id`. Pair it with your instance's Frontend API URL (e.g. `https://clerk.your-subdomain.accounts.dev` or your custom domain). -```bash -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 paths return a JSON object with `client_id`. Grab it along 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`). +If you prefer scripting, the same can be done with a `POST` to `https://api.clerk.com/v1/oauth_applications` — see the [Clerk Backend API reference](https://clerk.com/docs/reference/backend-api). -### 2. Configure your CLI +### Configure your CLI -```bash -export CLERK_OAUTH_CLIENT_ID="..." # from step 1 +```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'; @@ -93,41 +84,232 @@ const auth = new ClerkCliAuth({ keychainService: 'my-cli', }); -// Opens a browser, starts a one-shot localhost listener, exchanges the code, -// stores tokens in the OS keychain. Returns the token set and userinfo. const { tokens, user } = await auth.login(); +console.log(`Signed in as ${user.email}`); +``` -// Returns the cached access token; auto-refreshes when within 30s of expiry. -const token = await auth.getAccessToken(); +`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. -// Reads the cached user. If no cache, fetches from /oauth/userinfo. +### 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. -// Revokes the refresh token at the issuer, then clears keychain + cached userinfo. -// Pass { revoke: false } to skip the network call (e.g. offline or token already expired). +```ts await auth.logout(); +await auth.logout({ revoke: false }); ``` -## How the flow works +### Accept API keys and machine tokens alongside OAuth + +CLIs that run in CI/CD, agents, or scripted environments often need to authenticate with a Clerk API key (`ak_*`) or machine-to-machine token (`mt_*`) instead of going through the browser. Configure the `apiKeys` block to enable this: +```ts +const auth = new ClerkCliAuth({ + clientId: process.env.CLERK_OAUTH_CLIENT_ID!, + issuer: process.env.CLERK_ISSUER!, + apiKeys: { + identityEndpoint: 'https://myapp.com/api/cli/identity', + envVar: 'MYAPP_API_KEY', + }, +}); + +// Look up the identity for a specific token (API key, machine token, 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, kind } = await auth.resolveToken({ tokenFromArg: argv.token }); ``` -1. CLI generates PKCE (code_verifier, code_challenge=S256(verifier)) + CSRF state. -2. CLI binds a one-shot HTTP server on 127.0.0.1:0 (random port). -3. CLI opens browser to: - {issuer}/oauth/authorize?client_id=...&code_challenge=... - &redirect_uri=http://127.0.0.1:{port}/callback&state=... - &code_challenge_method=S256 -4. User signs in via Clerk's hosted UI and approves consent. -5. Clerk redirects the browser to http://127.0.0.1:{port}/callback?code=...&state=... -6. Server validates state, responds with "You can close this tab", closes. -7. CLI posts to {issuer}/oauth/token with grant_type=authorization_code + code_verifier. -8. CLI stores the token set in the OS keychain (falls back to chmod 600 JSON file). + +`resolveToken()` checks for a credential in this order: + +1. The `tokenFromArg` you pass in (typically from a `--token` CLI flag). +2. The environment variable named in `apiKeys.envVar`. +3. The cached OAuth access token from `login()`. + +It returns `{ token, kind }`, where `kind` is one of `'session_token'`, `'api_key'`, `'m2m_token'`, or `'oauth_token'` (matching the Clerk Backend SDK's `TokenType`). Use it to branch logic per credential type: + +```ts +const { token, kind } = await auth.resolveToken({ tokenFromArg: argv.token }); + +// One call works for every kind — the server-side handler resolves the identity via the +// matching verification path: +const identity = await auth.verifyToken(token); + +if (kind === 'm2m_token') { + // ...e.g. attach the machine actor's org context to a log line +} +``` + +Server-side verification of API keys and machine 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. It accepts every [Clerk Backend SDK](https://clerk.com/docs/references/backend/overview) token type: + +| `accepts` value | Matching token format | +| ----------------- | -------------------------------- | +| `'session_token'` | Clerk session JWTs | +| `'api_key'` | `ak_*` Clerk API keys | +| `'m2m_token'` | `mt_*` machine-to-machine tokens | +| `'oauth_token'` | `oat_*` OAuth access tokens | +| `'any'` | Any of the above | + +### Bind a Clerk client + +Create a single `cliAuth` instance for your application: + +```ts +// lib/clerk-cli.ts +import { cliAuth } from '@clerk/cli-auth/server'; +import { clerkClient } from '@clerk/nextjs/server'; + +export const { handle, verifyToken, verifyTokenFromRequest } = cliAuth({ + client: clerkClient, +}); ``` -## Known limitations +`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 + +Set `apiKeys.identityEndpoint` in your `ClerkCliAuth` constructor config to a backend route that returns the verified `Identity` for a token: + +```ts +// app/api/cli/identity/route.ts +import { handle } from '@/lib/clerk-cli'; + +export const GET = handle({ + accepts: ['api_key', 'm2m_token', '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, machine tokens, and OAuth access tokens alike. + +### Protected resource endpoints + +Use `verifyTokenFromRequest` to add authentication to any other route your CLI calls: + +```ts +// app/api/cli/projects/route.ts +import { NextResponse } from 'next/server'; +import { verifyTokenFromRequest } from '@/lib/clerk-cli'; + +export async function GET(request: Request) { + const tokenInfo = await verifyTokenFromRequest(request, { + accepts: ['api_key', 'm2m_token', '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: -- **Keychain path is tested structurally, not in CI.** Keychain access triggers OS credential-manager prompts in headless environments, so automated tests use memory and file stores. The keychain path is exercised end-to-end when consumers run against a real Clerk instance. -- **Device Authorization Grant (RFC 8628) is not implemented.** The localhost-callback flow needs an open port, which doesn't work for CI, containers, or SSH sessions. If you need that, [open an issue](https://github.com/clerk/javascript/issues/new?assignees=&labels=needs-triage&projects=&template=feature_request.yml). +```ts +export const GET = handle({ + accepts: 'api_key', + verifyToken: async ({ token, type, request, clerk }) => { + if (!isAllowlisted(token)) { + throw new Error('Token not allowlisted'); + } + const apiKey = await clerk.apiKeys.verify({ secret: token }); + return { subject: apiKey.subject, type, scopes: apiKey.scopes }; + }, +}); +``` + +Enrich the response with profile and org data: + +```ts +export const GET = handle({ + 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, + }; + }, +}); +``` + +`handle()` also accepts `client` and `clientConfig` to override the factory's Clerk client on a per-route basis — useful for multi-tenant applications. + +## 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 From dda694f51fdf866312e851873720a6ad5964357f Mon Sep 17 00:00:00 2001 From: Nicolas Angelo Date: Thu, 28 May 2026 17:12:08 -0400 Subject: [PATCH 08/20] fix(cli-auth): preserve generic T through handle() so verifyToken callback type narrows on accepts --- packages/cli-auth/src/server/cli-auth.ts | 6 +++--- packages/cli-auth/src/server/verify-token.ts | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/cli-auth/src/server/cli-auth.ts b/packages/cli-auth/src/server/cli-auth.ts index 82cf817a971..f05fe8f38b1 100644 --- a/packages/cli-auth/src/server/cli-auth.ts +++ b/packages/cli-auth/src/server/cli-auth.ts @@ -149,9 +149,9 @@ export function cliAuth(options: CliAuthFactoryOptions = {}): CliAuthInstance { return async function routeHandler(request: Request): Promise { try { - const verifier = (routeOptions.verifyToken ?? defaultVerifyToken) as unknown as VerifyTokenFn; + const verifier: VerifyTokenFn = routeOptions.verifyToken ?? defaultVerifyToken; - const { tokenInfo } = await runHandlePipeline(request, { + const { tokenInfo } = await runHandlePipeline(request, { accepts: routeOptions.accepts, verifyToken: verifier, getClerk: routeGetClerk, @@ -159,7 +159,7 @@ export function cliAuth(options: CliAuthFactoryOptions = {}): CliAuthInstance { const clerk = await routeGetClerk(); const resolver = routeOptions.resolveAuthInfo ?? defaultResolveAuthInfo; - const raw = await resolver({ tokenInfo: tokenInfo as TokenInfo, request, clerk }); + const raw = await resolver({ tokenInfo, request, clerk }); const info = validateIdentity(raw); return Response.json(info, { status: 200 }); diff --git a/packages/cli-auth/src/server/verify-token.ts b/packages/cli-auth/src/server/verify-token.ts index 660bd7594cc..96052ead24f 100644 --- a/packages/cli-auth/src/server/verify-token.ts +++ b/packages/cli-auth/src/server/verify-token.ts @@ -92,14 +92,14 @@ export async function defaultVerifyToken(ctx: VerifyTokenC * Internal: end-to-end pipeline used by `handle()`. Read bearer, detect type, gate on * `accepts`, run the (default or overridden) verifier with the resolved `clerk` injected. */ -export async function runHandlePipeline( +export async function runHandlePipeline( request: Request, options: { accepts: AcceptsToken; - verifyToken: VerifyTokenFn; + verifyToken: VerifyTokenFn; getClerk: () => Promise; }, -): Promise<{ tokenInfo: TokenInfo; rawToken: string }> { +): Promise<{ tokenInfo: TokenInfo; rawToken: string }> { const token = readBearer(request); const type = detectTokenType(token); @@ -108,6 +108,6 @@ export async function runHandlePipeline( } const clerk = await options.getClerk(); - const tokenInfo = await options.verifyToken({ token, type, request, clerk }); + const tokenInfo = await options.verifyToken({ token, type: type as T, request, clerk }); return { tokenInfo, rawToken: token }; } From 19b55992fdac716a0de2cc825ca73d1cf9bb1ec9 Mon Sep 17 00:00:00 2001 From: Nicolas Angelo Date: Thu, 28 May 2026 17:18:10 -0400 Subject: [PATCH 09/20] fix(cli-auth): detect JWT-shaped machine tokens via typ header and sub prefix --- packages/cli-auth/src/server/detect-type.ts | 50 ++++++++++++++++----- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/packages/cli-auth/src/server/detect-type.ts b/packages/cli-auth/src/server/detect-type.ts index 5943f6fa854..542f0c4d51f 100644 --- a/packages/cli-auth/src/server/detect-type.ts +++ b/packages/cli-auth/src/server/detect-type.ts @@ -1,37 +1,65 @@ +import { decodeJwt } from '@clerk/backend/jwt'; import { isMachineToken, TokenType } from '@clerk/backend/internal'; import { ClerkCliAuthError } from '../errors'; + import type { AcceptsToken } from './types'; +const API_KEY_PREFIX = 'ak_'; const M2M_TOKEN_PREFIX = 'mt_'; const OAUTH_TOKEN_PREFIX = 'oat_'; -const API_KEY_PREFIX = 'ak_'; +const M2M_SUBJECT_PREFIX = 'mch_'; 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); +} + +/** A JWT-shaped OAuth access token, identified by the RFC 9068 `typ` header. */ +function isOAuthJwt(token: string): boolean { + if (!isJwtFormat(token)) return false; + const result = decodeJwt(token) as { data?: { header: { typ?: unknown } }; errors?: unknown }; + if (result.errors || !result.data) return false; + const typ = result.data.header.typ; + return typeof typ === 'string' && (OAUTH_JWT_TYP_VALUES as readonly string[]).includes(typ); +} + +/** A JWT-shaped M2M token, identified by the `mch_` subject prefix. */ +function isM2MJwt(token: string): boolean { + if (!isJwtFormat(token)) return false; + const result = decodeJwt(token) as { data?: { payload: { sub?: unknown } }; errors?: unknown }; + if (result.errors || !result.data) return false; + const sub = result.data.payload.sub; + return typeof sub === 'string' && sub.startsWith(M2M_SUBJECT_PREFIX); +} + /** - * Detect the token type from a raw bearer string. Mirrors `@clerk/backend`'s own - * discrimination: prefix-based for machine tokens, JWT shape for sessions. + * Detect the token type from a raw bearer string. * - * `getMachineTokenType` and `isJwtFormat` aren't re-exported from `@clerk/backend/internal`, - * so we inline minimal equivalents using `isMachineToken` for the prefix check. + * Mirrors `@clerk/backend`'s `getMachineTokenType` + session-JWT fallback. `oat_*` / + * opaque OAuth access tokens, `mt_*` M2M tokens, and `ak_*` API keys are recognized by + * prefix. JWT-shaped tokens are inspected: `typ: at+jwt` (RFC 9068) → OAuth, `sub: mch_*` + * → M2M, anything else → session token. */ export function detectTokenType(token: string): TokenType { if (isMachineToken(token)) { if (token.startsWith(API_KEY_PREFIX)) { return TokenType.ApiKey; } - if (token.startsWith(M2M_TOKEN_PREFIX)) { + if (token.startsWith(M2M_TOKEN_PREFIX) || isM2MJwt(token)) { return TokenType.M2MToken; } - if (token.startsWith(OAUTH_TOKEN_PREFIX)) { + if (token.startsWith(OAUTH_TOKEN_PREFIX) || isOAuthJwt(token)) { return TokenType.OAuthToken; } - // JWT-shaped machine token (OAuth or M2M based on JWT typ/sub) — treat as oauth_token - // since it's the more common path; consumers can override `verifyToken` if they need M2M JWT. - return TokenType.OAuthToken; + // `isMachineToken` matched but no specific branch fired — bail rather than guess. + throw new ClerkCliAuthError('verify_api_key', 'Recognized machine token but could not determine its type.'); } - if (JWT_FORMAT.test(token)) { + if (isJwtFormat(token)) { return TokenType.SessionToken; } throw new ClerkCliAuthError('verify_api_key', 'Unable to determine token type for credential.'); From 42f0bf4e86b90915d35a870127e5c0482cddf68b Mon Sep 17 00:00:00 2001 From: Nicolas Angelo Date: Thu, 28 May 2026 17:19:58 -0400 Subject: [PATCH 10/20] refactor(cli-auth): replace as-unknown-as casts with real @clerk/backend APIKey + AuthObject types --- packages/cli-auth/src/server/verify-token.ts | 35 ++++++++++---------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/packages/cli-auth/src/server/verify-token.ts b/packages/cli-auth/src/server/verify-token.ts index 96052ead24f..c1a68739124 100644 --- a/packages/cli-auth/src/server/verify-token.ts +++ b/packages/cli-auth/src/server/verify-token.ts @@ -2,6 +2,7 @@ import type { ClerkClient } from '@clerk/backend'; import { TokenType, type TokenType as TokenTypeT } from '@clerk/backend/internal'; import { ClerkCliAuthError } from '../errors'; + import { detectTokenType, isTokenTypeAccepted } from './detect-type'; import type { AcceptsToken, TokenInfo, VerifyTokenContext, VerifyTokenFn } from './types'; @@ -32,13 +33,7 @@ export async function defaultVerifyToken(ctx: VerifyTokenC if (ctx.type === TokenType.ApiKey) { try { - const apiKey = (await clerk.apiKeys.verify({ secret: ctx.token })) as unknown as { - subject: string; - scopes?: string[]; - revoked?: boolean; - expired?: boolean; - claims?: Record; - }; + const apiKey = await clerk.apiKeys.verify({ secret: ctx.token }); if (apiKey.revoked || apiKey.expired) { throw new ClerkCliAuthError('verify_api_key', 'API key revoked or expired.'); } @@ -46,7 +41,7 @@ export async function defaultVerifyToken(ctx: VerifyTokenC subject: apiKey.subject, type: ctx.type, scopes: apiKey.scopes, - claims: apiKey.claims, + claims: apiKey.claims ?? undefined, }; } catch (error) { if (error instanceof ClerkCliAuthError) { @@ -58,27 +53,31 @@ export async function defaultVerifyToken(ctx: VerifyTokenC // OAuth / M2M / session — delegate to authenticateRequest. try { - const state = (await clerk.authenticateRequest(ctx.request, { acceptsToken: [ctx.type] })) as unknown as { - isAuthenticated?: boolean; - reason?: string; - toAuth?: () => { subject?: string; userId?: string; scopes?: string[]; claims?: Record } | null; - }; + const state = await clerk.authenticateRequest(ctx.request, { acceptsToken: [ctx.type] }); if (state.isAuthenticated === false) { throw new ClerkCliAuthError('not_authenticated', state.reason ?? 'Token rejected by Clerk.'); } - const auth = state.toAuth?.(); + const auth = state.toAuth(); if (!auth) { throw new ClerkCliAuthError('not_authenticated', 'authenticateRequest returned no auth payload.'); } - const subject = auth.subject ?? auth.userId; - if (typeof subject !== 'string' || !subject) { + // SessionAuthObject exposes `userId`; MachineAuthObject (oauth_token / m2m_token) exposes `subject`. + const subject = + 'subject' in auth && typeof auth.subject === 'string' + ? auth.subject + : 'userId' in auth && typeof auth.userId === 'string' + ? auth.userId + : undefined; + if (!subject) { throw new ClerkCliAuthError('not_authenticated', 'Verified token had no subject.'); } + const scopes = 'scopes' in auth && Array.isArray(auth.scopes) ? auth.scopes : undefined; + const claims = 'claims' in auth && auth.claims ? (auth.claims as Record) : undefined; return { subject, type: ctx.type, - scopes: auth.scopes, - claims: auth.claims, + scopes, + claims, }; } catch (error) { if (error instanceof ClerkCliAuthError) { From 15dc52d4d0d04b4187ebab85501f059b7c7d9055 Mon Sep 17 00:00:00 2001 From: Nicolas Angelo Date: Thu, 28 May 2026 17:22:11 -0400 Subject: [PATCH 11/20] feat(cli-auth)!: flatten apiKeys block to top-level identityEndpoint and tokenEnvVar --- packages/cli-auth/README.md | 12 +++++------- packages/cli-auth/src/clerk-cli-auth.ts | 22 +++++++++++++--------- packages/cli-auth/src/types.ts | 25 +++++++++++++------------ 3 files changed, 31 insertions(+), 28 deletions(-) diff --git a/packages/cli-auth/README.md b/packages/cli-auth/README.md index b590a4f7708..c8feeb1cabf 100644 --- a/packages/cli-auth/README.md +++ b/packages/cli-auth/README.md @@ -124,16 +124,14 @@ await auth.logout({ revoke: false }); ### Accept API keys and machine tokens alongside OAuth -CLIs that run in CI/CD, agents, or scripted environments often need to authenticate with a Clerk API key (`ak_*`) or machine-to-machine token (`mt_*`) instead of going through the browser. Configure the `apiKeys` block to enable this: +CLIs that run in CI/CD, agents, or scripted environments often need to authenticate with a Clerk API key (`ak_*`) or machine-to-machine token (`mt_*`) instead of going through the browser. 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!, - apiKeys: { - identityEndpoint: 'https://myapp.com/api/cli/identity', - envVar: 'MYAPP_API_KEY', - }, + identityEndpoint: 'https://myapp.com/api/cli/identity', + tokenEnvVar: 'MYAPP_API_KEY', }); // Look up the identity for a specific token (API key, machine token, or OAuth access token): @@ -146,7 +144,7 @@ const { token, kind } = 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). -2. The environment variable named in `apiKeys.envVar`. +2. The environment variable named in `tokenEnvVar`. 3. The cached OAuth access token from `login()`. It returns `{ token, kind }`, where `kind` is one of `'session_token'`, `'api_key'`, `'m2m_token'`, or `'oauth_token'` (matching the Clerk Backend SDK's `TokenType`). Use it to branch logic per credential type: @@ -195,7 +193,7 @@ export const { handle, verifyToken, verifyTokenFromRequest } = cliAuth({ ### Identity endpoint -Set `apiKeys.identityEndpoint` in your `ClerkCliAuth` constructor config to a backend route that returns the verified `Identity` for a token: +Set `identityEndpoint` in your `ClerkCliAuth` constructor config to a backend route that returns the verified `Identity` for a token: ```ts // app/api/cli/identity/route.ts diff --git a/packages/cli-auth/src/clerk-cli-auth.ts b/packages/cli-auth/src/clerk-cli-auth.ts index c4e0b6dec5f..a7f0b6e75c3 100644 --- a/packages/cli-auth/src/clerk-cli-auth.ts +++ b/packages/cli-auth/src/clerk-cli-auth.ts @@ -48,10 +48,13 @@ async function openBrowserFallback(url: string): Promise { } export class ClerkCliAuth { - private readonly config: Required> & { + private readonly config: Required< + Omit + > & { storage: CredentialStore; openBrowser?: (url: string) => Promise; - apiKeys?: ClerkCliAuthConfig['apiKeys']; + identityEndpoint?: string; + tokenEnvVar?: string; }; constructor(config: ClerkCliAuthConfig) { @@ -82,7 +85,8 @@ export class ClerkCliAuth { loginTimeoutMs: config.loginTimeoutMs ?? 120_000, requestTimeoutMs: config.requestTimeoutMs ?? 30_000, openBrowser: config.openBrowser, - apiKeys: config.apiKeys, + identityEndpoint: config.identityEndpoint, + tokenEnvVar: config.tokenEnvVar, }; } @@ -181,11 +185,11 @@ export class ClerkCliAuth { } async verifyToken(token: string): Promise { - if (!this.config.apiKeys?.identityEndpoint) { - throw new ClerkCliAuthError('config', 'apiKeys.identityEndpoint is not configured.'); + if (!this.config.identityEndpoint) { + throw new ClerkCliAuthError('config', 'identityEndpoint is not configured.'); } return verifyTokenRequest({ - endpoint: this.config.apiKeys.identityEndpoint, + endpoint: this.config.identityEndpoint, token, timeoutMs: this.config.requestTimeoutMs, }); @@ -198,8 +202,8 @@ export class ClerkCliAuth { kind: classifyToken(opts.tokenFromArg), }; } - if (this.config.apiKeys?.envVar) { - const fromEnv = process.env[this.config.apiKeys.envVar]; + if (this.config.tokenEnvVar) { + const fromEnv = process.env[this.config.tokenEnvVar]; if (fromEnv) { return { token: fromEnv, kind: classifyToken(fromEnv) }; } @@ -209,7 +213,7 @@ export class ClerkCliAuth { return { token: accessToken, kind: classifyToken(accessToken) }; } - const envHint = this.config.apiKeys?.envVar ? ` or set $${this.config.apiKeys.envVar}` : ''; + const envHint = this.config.tokenEnvVar ? ` or set $${this.config.tokenEnvVar}` : ''; throw new ClerkCliAuthError('not_authenticated', `Not logged in. Run \`auth login\`${envHint}.`); } diff --git a/packages/cli-auth/src/types.ts b/packages/cli-auth/src/types.ts index f166073f110..7ba328b2eab 100644 --- a/packages/cli-auth/src/types.ts +++ b/packages/cli-auth/src/types.ts @@ -42,18 +42,19 @@ export interface ClerkCliAuthConfig { requestTimeoutMs?: number; /** Injected opener for the browser step (for testing). Default: auto-detect. */ openBrowser?: (url: string) => Promise; - /** Enables non-OAuth credential auth (API keys, machine tokens) alongside OAuth. */ - apiKeys?: { - /** - * Backend endpoint that returns the verified `Identity` for a credential. 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. - */ - identityEndpoint: string; - /** Env var to read a credential from (e.g. 'MYAPP_API_KEY'). */ - envVar: string; - }; + /** + * Backend endpoint that returns the verified `Identity` for a credential (API key, + * machine token, 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 { From 16e9e49fce7750a3c77f6330c8d5fbeed522aa2c Mon Sep 17 00:00:00 2001 From: Nicolas Angelo Date: Thu, 28 May 2026 17:24:06 -0400 Subject: [PATCH 12/20] feat(cli-auth)!: split Identity into discriminated union by subject prefix --- packages/cli-auth/src/clerk-cli-auth.ts | 12 +++- packages/cli-auth/src/lib/token-exchange.ts | 8 ++- packages/cli-auth/src/types.ts | 65 ++++++++++++++++++--- 3 files changed, 71 insertions(+), 14 deletions(-) diff --git a/packages/cli-auth/src/clerk-cli-auth.ts b/packages/cli-auth/src/clerk-cli-auth.ts index a7f0b6e75c3..203b3293c10 100644 --- a/packages/cli-auth/src/clerk-cli-auth.ts +++ b/packages/cli-auth/src/clerk-cli-auth.ts @@ -7,7 +7,15 @@ 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 } from './types'; +import type { + ClerkCliAuthConfig, + CredentialStore, + Identity, + LoginResult, + OAuthScope, + TokenSet, + UserIdentity, +} from './types'; const DEFAULT_SCOPES: OAuthScope[] = ['profile', 'email', 'openid', 'offline_access']; @@ -171,7 +179,7 @@ export class ClerkCliAuth { return nextTokens.accessToken; } - async whoami(): Promise { + async whoami(): Promise { const accessToken = await this.getAccessToken(); if (!accessToken) { return null; diff --git a/packages/cli-auth/src/lib/token-exchange.ts b/packages/cli-auth/src/lib/token-exchange.ts index 6b6354a670b..2b0c63020f3 100644 --- a/packages/cli-auth/src/lib/token-exchange.ts +++ b/packages/cli-auth/src/lib/token-exchange.ts @@ -1,5 +1,6 @@ import { ClerkCliAuthError } from '../errors'; -import type { Identity, TokenSet } from '../types'; +import type { TokenSet, UserIdentity } from '../types'; + import { request } from './http'; export interface ExchangeParams { @@ -134,7 +135,7 @@ export async function revokeToken(params: RevokeParams): Promise { }); } -export async function fetchIdentity(params: FetchIdentityParams): Promise { +export async function fetchIdentity(params: FetchIdentityParams): Promise { const { body: parsed } = await request(endpoint(params.issuer, '/oauth/userinfo'), { headers: { Authorization: `Bearer ${params.accessToken}` }, errorCode: 'userinfo', @@ -145,7 +146,8 @@ export async function fetchIdentity(params: FetchIdentityParams): Promise; unsafe_metadata?: Record; - /** Org context. Present for org-scoped API keys, or OAuth sessions where the user picked an org at consent. */ + /** 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[]; - [key: string]: unknown; +} + +/** 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; - user: Identity; + /** OAuth subjects are always users; typed accordingly. */ + user: UserIdentity; } From be75fc8ad78b715ce8504e56dc94a12c2ca2232f Mon Sep 17 00:00:00 2001 From: Nicolas Angelo Date: Thu, 28 May 2026 17:39:15 -0400 Subject: [PATCH 13/20] docs(cli-auth): split Discord into Community section and update Twitter URL to x.com --- packages/cli-auth/README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/cli-auth/README.md b/packages/cli-auth/README.md index c8feeb1cabf..7614116fc98 100644 --- a/packages/cli-auth/README.md +++ b/packages/cli-auth/README.md @@ -13,7 +13,7 @@ [![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://twitter.com/intent/follow?screen_name=Clerk) +[![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) · @@ -311,10 +311,11 @@ Both fire `ClerkCliAuthError('timeout', ...)` when exceeded. ## Support -You can get in touch with us in any of the following ways: +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). -- Join our official community [Discord server](https://clerk.com/discord) -- On [our support page](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_cli_auth) +## Community + +Join the [Clerk community on Discord](https://clerk.com/discord) to chat with other developers and the Clerk team. ## Contributing From a6279546c0fbc186dc28ec133fd411828c0d290d Mon Sep 17 00:00:00 2001 From: Nicolas Angelo Date: Thu, 28 May 2026 17:46:55 -0400 Subject: [PATCH 14/20] docs(cli-auth): document Clerk CLI and curl paths for OAuth Application setup --- packages/cli-auth/README.md | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/cli-auth/README.md b/packages/cli-auth/README.md index 7614116fc98..1366e22e481 100644 --- a/packages/cli-auth/README.md +++ b/packages/cli-auth/README.md @@ -51,16 +51,43 @@ Create an OAuth Application in your Clerk instance and grab its `client_id` and ### Create an OAuth Application -In the [Clerk Dashboard](https://dashboard.clerk.com), go to **Configure → OAuth Applications → Create** and set: +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` +- **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` -The dashboard returns a `client_id`. Pair it with your instance's Frontend API URL (e.g. `https://clerk.your-subdomain.accounts.dev` or your custom domain). +**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" + }' +``` -If you prefer scripting, the same can be done with a `POST` to `https://api.clerk.com/v1/oauth_applications` — see the [Clerk Backend API reference](https://clerk.com/docs/reference/backend-api). +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 From 2927d6bda2ffced195af8da8f59bc017704aa81a Mon Sep 17 00:00:00 2001 From: Nicolas Angelo Date: Thu, 28 May 2026 18:43:11 -0400 Subject: [PATCH 15/20] feat(cli-auth)!: extract handle as standalone export, reshape verifyToken to take raw tokens --- packages/cli-auth/README.md | 22 +-- packages/cli-auth/src/lib/classify-token.ts | 64 +++++++-- packages/cli-auth/src/lib/token-exchange.ts | 1 - packages/cli-auth/src/server/cli-auth.ts | 118 ++++----------- packages/cli-auth/src/server/detect-type.ts | 78 ---------- packages/cli-auth/src/server/handle.ts | 89 ++++++++++++ packages/cli-auth/src/server/index.ts | 8 +- packages/cli-auth/src/server/resolve-auth.ts | 4 +- packages/cli-auth/src/server/types.ts | 84 ++++++----- packages/cli-auth/src/server/verify-token.ts | 144 ++++++++----------- 10 files changed, 299 insertions(+), 313 deletions(-) delete mode 100644 packages/cli-auth/src/server/detect-type.ts create mode 100644 packages/cli-auth/src/server/handle.ts diff --git a/packages/cli-auth/README.md b/packages/cli-auth/README.md index 1366e22e481..4f9170846d7 100644 --- a/packages/cli-auth/README.md +++ b/packages/cli-auth/README.md @@ -204,29 +204,29 @@ The `@clerk/cli-auth/server` entry point provides route handlers that verify inc ### Bind a Clerk client -Create a single `cliAuth` instance for your application: +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 { handle, verifyToken, verifyTokenFromRequest } = cliAuth({ - client: clerkClient, -}); +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 -Set `identityEndpoint` in your `ClerkCliAuth` constructor config to a backend route that returns the verified `Identity` for a token: +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 '@/lib/clerk-cli'; +import { handle } from '@clerk/cli-auth/server'; +import { auth } from '@/lib/clerk-cli'; export const GET = handle({ + auth, accepts: ['api_key', 'm2m_token', 'oauth_token'], }); ``` @@ -235,15 +235,15 @@ The handler reads `Authorization: Bearer `, detects the token type, verif ### Protected resource endpoints -Use `verifyTokenFromRequest` to add authentication to any other route your CLI calls: +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 { verifyTokenFromRequest } from '@/lib/clerk-cli'; +import { auth } from '@/lib/clerk-cli'; export async function GET(request: Request) { - const tokenInfo = await verifyTokenFromRequest(request, { + const tokenInfo = await auth.verifyTokenFromRequest(request, { accepts: ['api_key', 'm2m_token', 'oauth_token'], }); @@ -262,6 +262,7 @@ Add an allowlist or alternate verifier: ```ts export const GET = handle({ + auth, accepts: 'api_key', verifyToken: async ({ token, type, request, clerk }) => { if (!isAllowlisted(token)) { @@ -277,6 +278,7 @@ 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') { @@ -297,7 +299,7 @@ export const GET = handle({ }); ``` -`handle()` also accepts `client` and `clientConfig` to override the factory's Clerk client on a per-route basis — useful for multi-tenant applications. +For multi-tenant applications, create one `cliAuth()` instance per tenant (each bound to a different Clerk client) and pass the relevant instance to `handle()` on each route. ## Configuration reference diff --git a/packages/cli-auth/src/lib/classify-token.ts b/packages/cli-auth/src/lib/classify-token.ts index 3033d1e2b84..463f92d78eb 100644 --- a/packages/cli-auth/src/lib/classify-token.ts +++ b/packages/cli-auth/src/lib/classify-token.ts @@ -1,22 +1,62 @@ +import { type MachineTokenType, TokenType } from '@clerk/backend/internal'; +import { decodeJwt } from '@clerk/backend/jwt'; + +import { ClerkCliAuthError } from '../errors'; + /** - * Token kind values. Mirror `@clerk/backend`'s `TokenType` vocabulary so consumers can - * route on the same identifiers across the SDK and the backend. + * Token kind values. Re-export of `@clerk/backend`'s `MachineTokenType` — + * `'api_key' | 'm2m_token' | 'oauth_token'`. Session tokens are intentionally + * excluded; the CLI flow never holds a browser session credential. */ -export type TokenKind = 'session_token' | 'api_key' | 'm2m_token' | 'oauth_token'; +export type TokenKind = MachineTokenType; + +const API_KEY_PREFIX = 'ak_'; +const M2M_TOKEN_PREFIX = 'mt_'; +const OAUTH_TOKEN_PREFIX = 'oat_'; +const M2M_SUBJECT_PREFIX = 'mch_'; +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 token by its prefix. `ak_*` → `'api_key'`, `mt_*` → `'m2m_token'`, - * `oat_*` → `'oauth_token'`. Anything else (JWT-shaped session tokens) → `'session_token'`. + * Classify a token by prefix or JWT claims. Mirrors `@clerk/backend`'s internal + * `getMachineTokenType`: + * + * - `ak_*` → `'api_key'` + * - `mt_*` or JWT with `sub: mch_*` → `'m2m_token'` + * - `oat_*` or JWT with `typ: at+jwt` (RFC 9068) → `'oauth_token'` + * + * Throws when the token doesn't match any known machine-token shape. */ export function classifyToken(token: string): TokenKind { - if (token.startsWith('ak_')) { - return 'api_key'; + if (token.startsWith(API_KEY_PREFIX)) { + return TokenType.ApiKey; + } + if (token.startsWith(M2M_TOKEN_PREFIX)) { + return TokenType.M2MToken; } - if (token.startsWith('mt_')) { - return 'm2m_token'; + if (token.startsWith(OAUTH_TOKEN_PREFIX)) { + return TokenType.OAuthToken; } - if (token.startsWith('oat_')) { - return 'oauth_token'; + if (isJwtFormat(token)) { + const result = decodeJwt(token) as { + data?: { header: { typ?: unknown }; payload: { sub?: unknown } }; + errors?: unknown; + }; + if (!result.errors && result.data) { + const sub = result.data.payload.sub; + if (typeof sub === 'string' && sub.startsWith(M2M_SUBJECT_PREFIX)) { + return TokenType.M2MToken; + } + const typ = result.data.header.typ; + if (typeof typ === 'string' && (OAUTH_JWT_TYP_VALUES as readonly string[]).includes(typ)) { + return TokenType.OAuthToken; + } + } } - return 'session_token'; + throw new ClerkCliAuthError('not_authenticated', 'Unable to determine token type for credential.'); } diff --git a/packages/cli-auth/src/lib/token-exchange.ts b/packages/cli-auth/src/lib/token-exchange.ts index 2b0c63020f3..5f267e8a59c 100644 --- a/packages/cli-auth/src/lib/token-exchange.ts +++ b/packages/cli-auth/src/lib/token-exchange.ts @@ -1,6 +1,5 @@ import { ClerkCliAuthError } from '../errors'; import type { TokenSet, UserIdentity } from '../types'; - import { request } from './http'; export interface ExchangeParams { diff --git a/packages/cli-auth/src/server/cli-auth.ts b/packages/cli-auth/src/server/cli-auth.ts index f05fe8f38b1..8c7d16ecf31 100644 --- a/packages/cli-auth/src/server/cli-auth.ts +++ b/packages/cli-auth/src/server/cli-auth.ts @@ -1,44 +1,19 @@ import { type ClerkClient, type ClerkOptions, createClerkClient } from '@clerk/backend'; -import type { TokenType } from '@clerk/backend/internal'; +import type { MachineTokenType } from '@clerk/backend/internal'; -import { ClerkCliAuthError, EXIT_CODE } from '../errors'; -import { detectTokenType, isTokenTypeAccepted } from './detect-type'; -import { resolveAuthInfo as defaultResolveAuthInfo, validateIdentity } from './resolve-auth'; +import { ClerkCliAuthError } from '../errors'; +import { resolveAuthInfo as defaultResolveAuthInfo } from './resolve-auth'; import type { + AcceptsToken, CliAuthFactoryOptions, CliAuthInstance, ClientArg, - HandleOptions, ResolveAuthInfoContext, TokenInfo, - VerifyTokenContext, - VerifyTokenFn, } from './types'; -import { defaultVerifyToken, readBearer, runHandlePipeline } from './verify-token'; +import { readBearer, verifyTokenWithClerk } 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) }, - ); -} - -/** Build a getClerk thunk for the given factory/route options, with a single-flight cache. */ +/** Build a getClerk thunk for the given factory options, with a single-flight cache. */ function makeClerkGetter( client: ClientArg | undefined, clientConfig: ClerkOptions | undefined, @@ -85,7 +60,8 @@ function makeClerkGetter( /** * Factory: bind a Clerk Backend client (via `client`, `clientConfig`, or auto-built from - * `CLERK_SECRET_KEY`) and return a set of helpers ready to drop into your routes. + * `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. @@ -96,86 +72,50 @@ function makeClerkGetter( * import { cliAuth } from '@clerk/cli-auth/server'; * import { clerkClient } from '@clerk/nextjs/server'; * - * export const { handle, verifyToken, verifyTokenFromRequest, resolveAuthInfo } = - * cliAuth({ client: clerkClient }); + * export const auth = cliAuth({ client: clerkClient }); * * // app/api/cli/verify/route.ts - * import { handle } from '@/lib/clerk-cli'; + * import { handle } from '@clerk/cli-auth/server'; + * import { auth } from '@/lib/clerk-cli'; + * + * export const GET = handle({ auth, accepts: ['api_key', 'oauth_token'] }); * - * export const GET = handle({ - * accepts: ['api_key', 'oauth_token'], - * // Optional per-route overrides: - * // client / clientConfig — different Clerk client for this route only - * // verifyToken: ({ token, type, request, clerk }) => ... - * // resolveAuthInfo: ({ tokenInfo, request, clerk }) => ... - * }); + * // 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 factoryGetClerk = makeClerkGetter(options.client, options.clientConfig); + const getClerk = makeClerkGetter(options.client, options.clientConfig); - async function verifyToken( - ctx: Omit, 'clerk'> & { clerk?: ClerkClient }, + async function verifyToken( + token: string, + verifyOptions?: { accepts?: AcceptsToken }, ): Promise> { - const clerk = ctx.clerk ?? (await factoryGetClerk()); - return defaultVerifyToken({ ...ctx, clerk } as VerifyTokenContext); + const info = await verifyTokenWithClerk(token, { + accepts: verifyOptions?.accepts, + clientConfig: options.clientConfig, + }); + return info as TokenInfo; } - async function verifyTokenFromRequest( + async function verifyTokenFromRequest( request: Request, - routeOptions: { accepts: HandleOptions['accepts'] }, + verifyOptions?: { accepts?: AcceptsToken }, ): Promise> { const token = readBearer(request); - const type = detectTokenType(token); - if (!isTokenTypeAccepted(type, routeOptions.accepts)) { - throw new ClerkCliAuthError('not_authenticated', `Token type "${type}" is not accepted by this endpoint.`); - } - return verifyToken({ token, type: type as T, request }); + return verifyToken(token, verifyOptions); } - function resolveAuthInfo( + function resolveAuthInfo( ctx: Omit, 'clerk'> & { clerk?: ClerkClient }, ): ReturnType { return defaultResolveAuthInfo(ctx as ResolveAuthInfoContext); } - function handle(routeOptions: HandleOptions): (request: Request) => Promise { - // Per-route client override gets its own getter; falls back to the factory's getter - // when neither override is supplied. - const routeGetClerk = - routeOptions.client || routeOptions.clientConfig - ? makeClerkGetter(routeOptions.client, routeOptions.clientConfig) - : factoryGetClerk; - - return async function routeHandler(request: Request): Promise { - try { - const verifier: VerifyTokenFn = routeOptions.verifyToken ?? defaultVerifyToken; - - const { tokenInfo } = await runHandlePipeline(request, { - accepts: routeOptions.accepts, - verifyToken: verifier, - getClerk: routeGetClerk, - }); - - const clerk = await routeGetClerk(); - const resolver = routeOptions.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}`)); - } - }; - } - return { - handle, verifyToken, verifyTokenFromRequest, resolveAuthInfo, + getClerk, }; } diff --git a/packages/cli-auth/src/server/detect-type.ts b/packages/cli-auth/src/server/detect-type.ts deleted file mode 100644 index 542f0c4d51f..00000000000 --- a/packages/cli-auth/src/server/detect-type.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { decodeJwt } from '@clerk/backend/jwt'; -import { isMachineToken, TokenType } from '@clerk/backend/internal'; - -import { ClerkCliAuthError } from '../errors'; - -import type { AcceptsToken } from './types'; - -const API_KEY_PREFIX = 'ak_'; -const M2M_TOKEN_PREFIX = 'mt_'; -const OAUTH_TOKEN_PREFIX = 'oat_'; -const M2M_SUBJECT_PREFIX = 'mch_'; - -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); -} - -/** A JWT-shaped OAuth access token, identified by the RFC 9068 `typ` header. */ -function isOAuthJwt(token: string): boolean { - if (!isJwtFormat(token)) return false; - const result = decodeJwt(token) as { data?: { header: { typ?: unknown } }; errors?: unknown }; - if (result.errors || !result.data) return false; - const typ = result.data.header.typ; - return typeof typ === 'string' && (OAUTH_JWT_TYP_VALUES as readonly string[]).includes(typ); -} - -/** A JWT-shaped M2M token, identified by the `mch_` subject prefix. */ -function isM2MJwt(token: string): boolean { - if (!isJwtFormat(token)) return false; - const result = decodeJwt(token) as { data?: { payload: { sub?: unknown } }; errors?: unknown }; - if (result.errors || !result.data) return false; - const sub = result.data.payload.sub; - return typeof sub === 'string' && sub.startsWith(M2M_SUBJECT_PREFIX); -} - -/** - * Detect the token type from a raw bearer string. - * - * Mirrors `@clerk/backend`'s `getMachineTokenType` + session-JWT fallback. `oat_*` / - * opaque OAuth access tokens, `mt_*` M2M tokens, and `ak_*` API keys are recognized by - * prefix. JWT-shaped tokens are inspected: `typ: at+jwt` (RFC 9068) → OAuth, `sub: mch_*` - * → M2M, anything else → session token. - */ -export function detectTokenType(token: string): TokenType { - if (isMachineToken(token)) { - if (token.startsWith(API_KEY_PREFIX)) { - return TokenType.ApiKey; - } - if (token.startsWith(M2M_TOKEN_PREFIX) || isM2MJwt(token)) { - return TokenType.M2MToken; - } - if (token.startsWith(OAUTH_TOKEN_PREFIX) || isOAuthJwt(token)) { - return TokenType.OAuthToken; - } - // `isMachineToken` matched but no specific branch fired — bail rather than guess. - throw new ClerkCliAuthError('verify_api_key', 'Recognized machine token but could not determine its type.'); - } - if (isJwtFormat(token)) { - return TokenType.SessionToken; - } - throw new ClerkCliAuthError('verify_api_key', 'Unable to determine token type for credential.'); -} - -/** - * Return true if `detected` is in the consumer's `accepts` list. Mirrors - * `@clerk/backend`'s `isTokenTypeAccepted`. - */ -export function isTokenTypeAccepted(detected: TokenType, accepts: AcceptsToken): boolean { - if (accepts === 'any') { - return true; - } - const list = Array.isArray(accepts) ? accepts : [accepts as TokenType]; - return list.includes(detected); -} diff --git a/packages/cli-auth/src/server/handle.ts b/packages/cli-auth/src/server/handle.ts new file mode 100644 index 00000000000..09776e85e60 --- /dev/null +++ b/packages/cli-auth/src/server/handle.ts @@ -0,0 +1,89 @@ +import type { MachineTokenType } from '@clerk/backend/internal'; + +import { ClerkCliAuthError, EXIT_CODE } from '../errors'; +import { classifyToken } 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', 'm2m_token', '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 index bde865e5475..e2e0c3db6f7 100644 --- a/packages/cli-auth/src/server/index.ts +++ b/packages/cli-auth/src/server/index.ts @@ -1,5 +1,5 @@ export { cliAuth } from './cli-auth'; -export { detectTokenType, isTokenTypeAccepted } from './detect-type'; +export { handle } from './handle'; export type { AcceptsToken, @@ -13,5 +13,7 @@ export type { VerifyTokenFn, } from './types'; -// Re-export the canonical TokenType from @clerk/backend so consumers don't have to dual-import. -export { TokenType } from '@clerk/backend/internal'; +// Re-export the canonical `MachineTokenType` and `isTokenTypeAccepted` from `@clerk/backend` +// so consumers don't have to dual-import. +export { isTokenTypeAccepted, TokenType } from '@clerk/backend/internal'; +export type { MachineTokenType } from '@clerk/backend/internal'; diff --git a/packages/cli-auth/src/server/resolve-auth.ts b/packages/cli-auth/src/server/resolve-auth.ts index afa3260ff1c..34775d00c71 100644 --- a/packages/cli-auth/src/server/resolve-auth.ts +++ b/packages/cli-auth/src/server/resolve-auth.ts @@ -1,4 +1,4 @@ -import type { TokenType } from '@clerk/backend/internal'; +import type { MachineTokenType } from '@clerk/backend/internal'; import { ClerkCliAuthError } from '../errors'; import type { Identity } from '../types'; @@ -38,7 +38,7 @@ function extractSubject(tokenInfo: TokenInfo): string { * 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 { +export function resolveAuthInfo(ctx: ResolveAuthInfoContext): Identity { const { tokenInfo } = ctx; const sub = extractSubject(tokenInfo); diff --git a/packages/cli-auth/src/server/types.ts b/packages/cli-auth/src/server/types.ts index 7e433c2fe54..07f1888f325 100644 --- a/packages/cli-auth/src/server/types.ts +++ b/packages/cli-auth/src/server/types.ts @@ -1,13 +1,14 @@ import type { ClerkClient, ClerkOptions } from '@clerk/backend'; -import type { TokenType } from '@clerk/backend/internal'; +import type { MachineTokenType } from '@clerk/backend/internal'; import type { Identity } from '../types'; /** * Which token types this endpoint accepts. Mirrors `@clerk/backend`'s `acceptsToken`: - * a single `TokenType`, a readonly tuple of `TokenType`s, or the literal `'any'`. + * a single {@link MachineTokenType}, a readonly tuple of them, or the literal `'any'`. + * Session tokens are intentionally excluded — the CLI flow never holds one. */ -export type AcceptsToken = TokenType | readonly TokenType[] | 'any'; +export type AcceptsToken = MachineTokenType | readonly MachineTokenType[] | 'any'; /** * A Clerk Backend client, either resolved or wrapped in a factory. Passing the factory @@ -20,10 +21,10 @@ export type ClientArg = ClerkClient | (() => ClerkClient | Promise) * Verified token payload. Returned by `verifyToken` / `verifyTokenFromRequest` * and passed into `resolveAuthInfo` as `tokenInfo`. */ -export interface TokenInfo { +export interface TokenInfo { /** The verified token's subject — `user_*`, `org_*`, `mch_*`, or `scim_*`. */ subject: string; - /** The verified token type (`session_token` | `api_key` | `m2m_token` | `oauth_token`). */ + /** The verified token type (`api_key` | `m2m_token` | `oauth_token`). */ type: T; /** Scopes attached to the token, when applicable. */ scopes?: string[]; @@ -32,13 +33,13 @@ export interface TokenInfo { } /** - * Context passed to a `verifyToken` callback. `token` is the raw, unverified bearer. - * `clerk` is the resolved Clerk Backend client (the factory's default, or a route override). + * 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 { +export interface VerifyTokenContext { /** Raw bearer token from the `Authorization` header. */ token: string; - /** Token type detected from the token's prefix / JWT shape. */ + /** Token type auto-detected from the token's prefix / JWT shape. */ type: T; /** Original incoming `Request`. */ request: Request; @@ -51,7 +52,7 @@ export interface VerifyTokenContext { * downstream code reads `tokenInfo.subject` / `.claims`, the original `request`, and the * resolved Clerk Backend client. */ -export interface ResolveAuthInfoContext { +export interface ResolveAuthInfoContext { /** The verified token, including subject, type, scopes, and claims. */ tokenInfo: TokenInfo; /** Original incoming `Request`. */ @@ -61,12 +62,12 @@ export interface ResolveAuthInfoContext { } /** A `verifyToken` callback returns a verified `TokenInfo` or throws on rejection. */ -export type VerifyTokenFn = ( +export type VerifyTokenFn = ( ctx: VerifyTokenContext, ) => Promise> | TokenInfo; /** A `resolveAuthInfo` callback shapes the verified token into a `Identity` payload. */ -export type ResolveAuthInfoFn = ( +export type ResolveAuthInfoFn = ( ctx: ResolveAuthInfoContext, ) => Promise | Identity; @@ -74,6 +75,9 @@ export type ResolveAuthInfoFn = ( * 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`). */ @@ -82,22 +86,25 @@ export interface CliAuthFactoryOptions { clientConfig?: ClerkOptions; } -/** Options for the bound `handle()` returned by `cliAuth()`. */ -export interface HandleOptions { - /** Which token types this endpoint accepts. Rejects every other type with 401. */ - accepts: AcceptsToken; - /** Per-route client override. Falls back to the factory's client when omitted. */ - client?: ClientArg; - /** Per-route `clientConfig` override. Falls back to the factory's config when omitted. */ - clientConfig?: ClerkOptions; - /** Override the verification step. Defaults to a `@clerk/backend`-backed verifier. */ +/** + * 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 })`. Destructure the bits you need. + * 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 @@ -105,27 +112,36 @@ export interface HandleOptions { * import { cliAuth } from '@clerk/cli-auth/server'; * import { clerkClient } from '@clerk/nextjs/server'; * - * export const { handle, verifyToken, verifyTokenFromRequest, resolveAuthInfo } = - * cliAuth({ client: clerkClient }); + * export const auth = cliAuth({ client: clerkClient }); * * // app/api/cli/verify/route.ts - * export const GET = handle({ accepts: ['api_key', 'oauth_token'] }); + * 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 { - /** Wrap a route handler for any framework using Web `Request`/`Response`. */ - handle: (opts: HandleOptions) => (request: Request) => Promise; - /** Primitive verifier: raw bearer in, verified `TokenInfo` out. `clerk` is auto-injected from the factory's client. */ - verifyToken: ( - ctx: Omit, 'clerk'> & { clerk?: ClerkClient }, + /** + * 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>; - /** High-level verifier: `Request` in, verified `TokenInfo` out. Uses the factory's client. */ - verifyTokenFromRequest: ( + /** + * Request-level verifier — reads `Authorization: Bearer `, then defers to + * `verifyToken(token, options)`. + */ + verifyTokenFromRequest: ( request: Request, - options: { accepts: AcceptsToken }, + 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: ( + 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 index c1a68739124..751a6f77b5d 100644 --- a/packages/cli-auth/src/server/verify-token.ts +++ b/packages/cli-auth/src/server/verify-token.ts @@ -1,10 +1,8 @@ -import type { ClerkClient } from '@clerk/backend'; -import { TokenType, type TokenType as TokenTypeT } from '@clerk/backend/internal'; +import type { ClerkOptions } from '@clerk/backend'; +import { isTokenTypeAccepted, type MachineTokenType, verifyMachineAuthToken } from '@clerk/backend/internal'; import { ClerkCliAuthError } from '../errors'; - -import { detectTokenType, isTokenTypeAccepted } from './detect-type'; -import type { AcceptsToken, TokenInfo, VerifyTokenContext, VerifyTokenFn } from './types'; +import type { AcceptsToken, TokenInfo } from './types'; const BEARER_PREFIX = /^Bearer\s+/i; @@ -21,92 +19,70 @@ export function readBearer(request: Request): string { } /** - * Default token verifier — uses the `clerk` client from ctx to verify each supported token type. - * - * - `api_key`: `clerk.apiKeys.verify({ secret: token })` - * - `session_token` / `m2m_token` / `oauth_token`: `clerk.authenticateRequest(request, { acceptsToken: [type] })` - * - * Consumers can replace this entirely by passing a `verifyToken` to `cliAuth()` or `handle()`. + * Build the `VerifyTokenOptions` payload `verifyMachineAuthToken` expects. The bound + * `clientConfig` wins; otherwise we fall back to the env vars `@clerk/backend` itself reads. */ -export async function defaultVerifyToken(ctx: VerifyTokenContext): Promise> { - const { clerk } = ctx; - - if (ctx.type === TokenType.ApiKey) { - try { - const apiKey = await clerk.apiKeys.verify({ secret: ctx.token }); - if (apiKey.revoked || apiKey.expired) { - throw new ClerkCliAuthError('verify_api_key', 'API key revoked or expired.'); - } - return { - subject: apiKey.subject, - type: ctx.type, - scopes: apiKey.scopes, - claims: apiKey.claims ?? undefined, - }; - } catch (error) { - if (error instanceof ClerkCliAuthError) { - throw error; - } - throw new ClerkCliAuthError('verify_api_key', `API key verification failed: ${(error as Error).message}`); - } - } - - // OAuth / M2M / session — delegate to authenticateRequest. - try { - const state = await clerk.authenticateRequest(ctx.request, { acceptsToken: [ctx.type] }); - if (state.isAuthenticated === false) { - throw new ClerkCliAuthError('not_authenticated', state.reason ?? 'Token rejected by Clerk.'); - } - const auth = state.toAuth(); - if (!auth) { - throw new ClerkCliAuthError('not_authenticated', 'authenticateRequest returned no auth payload.'); - } - // SessionAuthObject exposes `userId`; MachineAuthObject (oauth_token / m2m_token) exposes `subject`. - const subject = - 'subject' in auth && typeof auth.subject === 'string' - ? auth.subject - : 'userId' in auth && typeof auth.userId === 'string' - ? auth.userId - : undefined; - if (!subject) { - throw new ClerkCliAuthError('not_authenticated', 'Verified token had no subject.'); - } - const scopes = 'scopes' in auth && Array.isArray(auth.scopes) ? auth.scopes : undefined; - const claims = 'claims' in auth && auth.claims ? (auth.claims as Record) : undefined; - return { - subject, - type: ctx.type, - scopes, - claims, - }; - } catch (error) { - if (error instanceof ClerkCliAuthError) { - throw error; - } - throw new ClerkCliAuthError('not_authenticated', `Token verification failed: ${(error as Error).message}`); +export function buildVerifyOptions(clientConfig: ClerkOptions | undefined): { + secretKey: string; + apiUrl?: string; + jwtKey?: string; +} { + const secretKey = clientConfig?.secretKey ?? process.env.CLERK_SECRET_KEY; + if (!secretKey) { + throw new ClerkCliAuthError( + 'config', + 'cliAuth() needs a Clerk secret key. Pass `clientConfig.secretKey`, or set CLERK_SECRET_KEY in the env.', + ); } + return { + secretKey, + apiUrl: clientConfig?.apiUrl ?? process.env.CLERK_API_URL ?? undefined, + jwtKey: clientConfig?.jwtKey ?? process.env.CLERK_JWT_KEY ?? undefined, + }; } /** - * Internal: end-to-end pipeline used by `handle()`. Read bearer, detect type, gate on - * `accepts`, run the (default or overridden) verifier with the resolved `clerk` injected. + * Default token verifier — delegates to `@clerk/backend`'s `verifyMachineAuthToken`, which + * detects the token type internally (api_key / m2m_token / oauth_token) and verifies via + * the appropriate Backend API endpoint or JWT path. Maps the result to {@link TokenInfo}. + * + * `accepts` (when provided) gates against the verified `tokenType`; tokens of an unaccepted + * type fail with `not_authenticated`. Consumers can replace this entirely by passing a + * `verifyToken` override to `handle()`. */ -export async function runHandlePipeline( - request: Request, - options: { - accepts: AcceptsToken; - verifyToken: VerifyTokenFn; - getClerk: () => Promise; - }, -): Promise<{ tokenInfo: TokenInfo; rawToken: string }> { - const token = readBearer(request); - const type = detectTokenType(token); +export async function verifyTokenWithClerk( + token: string, + options: { accepts?: AcceptsToken; clientConfig?: ClerkOptions }, +): Promise { + const verifyOptions = buildVerifyOptions(options.clientConfig); + const result = await verifyMachineAuthToken(token, verifyOptions); - if (!isTokenTypeAccepted(type, options.accepts)) { - throw new ClerkCliAuthError('not_authenticated', `Token type "${type}" is not accepted by this endpoint.`); + const accepts: AcceptsToken = options.accepts ?? 'any'; + if (!isTokenTypeAccepted(result.tokenType, accepts)) { + throw new ClerkCliAuthError( + 'not_authenticated', + `Token type "${result.tokenType}" is not accepted by this endpoint.`, + ); } - const clerk = await options.getClerk(); - const tokenInfo = await options.verifyToken({ token, type: type as T, request, clerk }); - return { tokenInfo, rawToken: token }; + if (result.errors) { + throw new ClerkCliAuthError('not_authenticated', result.errors[0].message); + } + + return mapVerifiedToken(result.data, result.tokenType); +} + +interface VerifiedTokenLike { + subject: string; + scopes?: string[] | null; + claims?: Record | null; +} + +function mapVerifiedToken(data: VerifiedTokenLike, type: MachineTokenType): TokenInfo { + return { + subject: data.subject, + type, + scopes: data.scopes ?? undefined, + claims: data.claims ?? undefined, + }; } From 4e99176d73a26416775f19a9a4aa0da33a52e3a4 Mon Sep 17 00:00:00 2001 From: Nicolas Angelo Date: Thu, 28 May 2026 18:59:00 -0400 Subject: [PATCH 16/20] fix(cli-auth): resolve eslint warnings in pre-existing code --- .../src/__tests__/clerk-cli-auth.test.ts | 188 ++++++++++-------- packages/cli-auth/src/errors.ts | 4 +- packages/cli-auth/src/lib/credential-store.ts | 3 + packages/cli-auth/src/lib/pkce.ts | 1 + 4 files changed, 110 insertions(+), 86 deletions(-) diff --git a/packages/cli-auth/src/__tests__/clerk-cli-auth.test.ts b/packages/cli-auth/src/__tests__/clerk-cli-auth.test.ts index 46121cf0657..f46146f5376 100644 --- a/packages/cli-auth/src/__tests__/clerk-cli-auth.test.ts +++ b/packages/cli-auth/src/__tests__/clerk-cli-auth.test.ts @@ -13,17 +13,29 @@ function seededStore(entries: Record): CredentialStore { } 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) { @@ -56,34 +68,36 @@ describe('ClerkCliAuth', () => { let userinfoAuthorization: string | undefined; let authorizeUrl: URL | undefined; - const issuerServer = createServer(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; - } + 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; - } + 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' }); - }); + json(res, 404, { error: 'not_found' }); + }), + ); servers.push(issuerServer); await new Promise(resolve => issuerServer.listen(0, '127.0.0.1', resolve)); @@ -140,35 +154,37 @@ describe('ClerkCliAuth', () => { const revoked = new Set(); const refreshToAccess = new Map([['refresh-token', 'access-token']]); - const issuerServer = createServer(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' }); + 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; } - 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); + 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; } - res.writeHead(200); - res.end(); - return; - } - json(res, 404, { error: 'not_found' }); - }); + 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}`; @@ -201,35 +217,37 @@ describe('ClerkCliAuth', () => { const revoked = new Set(); const refreshToAccess = new Map([['refresh-token', 'access-token']]); - const issuerServer = createServer(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' }); + 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; } - 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); + 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; } - res.writeHead(200); - res.end(); - return; - } - json(res, 404, { error: 'not_found' }); - }); + 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}`; @@ -256,16 +274,18 @@ describe('ClerkCliAuth', () => { it('revokes the refresh token on logout and clears local state', async () => { let revokeRequest: URLSearchParams | undefined; - const issuerServer = createServer(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' }); - }); + 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}`; diff --git a/packages/cli-auth/src/errors.ts b/packages/cli-auth/src/errors.ts index 8f0594ea13e..096d0d50f91 100644 --- a/packages/cli-auth/src/errors.ts +++ b/packages/cli-auth/src/errors.ts @@ -23,9 +23,9 @@ export interface ClerkCliAuthErrorOptions { } export class ClerkCliAuthError extends Error { - code: ErrorCode | string; + code: ErrorCode | (string & {}); exitCode: ExitCode; - constructor(code: ErrorCode | string, message: string, options?: ClerkCliAuthErrorOptions) { + constructor(code: ErrorCode | (string & {}), message: string, options?: ClerkCliAuthErrorOptions) { super(message, options?.cause ? { cause: options.cause } : undefined); this.name = 'ClerkCliAuthError'; this.code = code; diff --git a/packages/cli-auth/src/lib/credential-store.ts b/packages/cli-auth/src/lib/credential-store.ts index 7fe0dca451f..5ffd8936e15 100644 --- a/packages/cli-auth/src/lib/credential-store.ts +++ b/packages/cli-auth/src/lib/credential-store.ts @@ -35,14 +35,17 @@ 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); } } diff --git a/packages/cli-auth/src/lib/pkce.ts b/packages/cli-auth/src/lib/pkce.ts index d84becb3d8a..0e2063f68da 100644 --- a/packages/cli-auth/src/lib/pkce.ts +++ b/packages/cli-auth/src/lib/pkce.ts @@ -9,6 +9,7 @@ export function generateCodeVerifier(): string { } export async function generateCodeChallenge(verifier: string): Promise { + await Promise.resolve(); return base64Url(createHash('sha256').update(verifier).digest()); } From 14c07d218e391327f1650b3dd20331120a8b9600 Mon Sep 17 00:00:00 2001 From: Nicolas Angelo Date: Thu, 28 May 2026 18:59:08 -0400 Subject: [PATCH 17/20] test(cli-auth): add server integration suite and oauth_token unit test --- packages/cli-auth/package.json | 1 + .../src/__tests__/integration/server.test.ts | 156 ++++++++++++++++++ .../src/__tests__/integration/setup.ts | 67 ++++++++ .../src/__tests__/server-oauth.test.ts | 88 ++++++++++ packages/cli-auth/vitest.config.mts | 3 + .../cli-auth/vitest.integration.config.mts | 23 +++ 6 files changed, 338 insertions(+) create mode 100644 packages/cli-auth/src/__tests__/integration/server.test.ts create mode 100644 packages/cli-auth/src/__tests__/integration/setup.ts create mode 100644 packages/cli-auth/src/__tests__/server-oauth.test.ts create mode 100644 packages/cli-auth/vitest.integration.config.mts diff --git a/packages/cli-auth/package.json b/packages/cli-auth/package.json index 88f1fff8885..e523f0c9d6b 100644 --- a/packages/cli-auth/package.json +++ b/packages/cli-auth/package.json @@ -63,6 +63,7 @@ "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": { 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..0444d823aa6 --- /dev/null +++ b/packages/cli-auth/src/__tests__/integration/server.test.ts @@ -0,0 +1,156 @@ +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', () => { + it('verifies an API key, returns TokenInfo with type=api_key + correct subject', async () => { + const secret = fx.apiKey.secret; + expect(secret).toBeTypeOf('string'); + const info = await fx.auth.verifyToken(secret!); + expect(info.type).toBe('api_key'); + expect(info.subject).toBe(fx.user.id); + expect(info.scopes).toEqual(expect.arrayContaining(['cli:read'])); + }); + + it('verifies an opaque M2M token, type=m2m_token, subject mch_*', async () => { + const token = fx.m2mTokenOpaque.token; + expect(token).toBeTypeOf('string'); + expect(token!.startsWith('mt_')).toBe(true); + const info = await fx.auth.verifyToken(token!); + expect(info.type).toBe('m2m_token'); + expect(info.subject).toMatch(/^mch_/); + }); + + it('verifies a JWT-shaped M2M token, type=m2m_token', async () => { + const token = fx.m2mTokenJwt.token; + expect(token).toBeTypeOf('string'); + expect(token!.split('.').length).toBe(3); + const info = await fx.auth.verifyToken(token!); + expect(info.type).toBe('m2m_token'); + expect(info.subject).toMatch(/^mch_/); + }); + + it('rejects when accepts gates out the verified type', async () => { + // M2M sent to an api_key-only verifier. + await expect(fx.auth.verifyToken(fx.m2mTokenOpaque.token!, { accepts: 'api_key' })).rejects.toThrow( + /not accepted/i, + ); + }); + + it('throws on an unrecognized token', async () => { + await expect(fx.auth.verifyToken('not-a-real-token')).rejects.toThrow(); + }); + }); + + describe('auth.verifyTokenFromRequest', () => { + it('reads the Bearer header and verifies', async () => { + const req = bearerRequest(fx.apiKey.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/); + }); + + it('throws on an unaccepted type', async () => { + const req = bearerRequest(fx.m2mTokenOpaque.token!); + await expect(fx.auth.verifyTokenFromRequest(req, { accepts: 'api_key' })).rejects.toThrow(/not accepted/i); + }); + }); + + describe('auth.resolveAuthInfo (default)', () => { + it('projects subject + claims into Identity', async () => { + const info = await fx.auth.verifyToken(fx.apiKey.secret!); + const identity = await fx.auth.resolveAuthInfo({ tokenInfo: info, request: bearerRequest(fx.apiKey.secret!) }); + expect(identity.sub).toBe(fx.user.id); + }); + }); + + describe('handle() end-to-end', () => { + it('200 with Identity body when a valid API key is sent', async () => { + const route = handle({ auth: fx.auth, accepts: 'api_key' }); + const res = await route(bearerRequest(fx.apiKey.secret!)); + expect(res.status).toBe(200); + const body = (await res.json()) as Identity; + expect(body.sub).toBe(fx.user.id); + }); + + it('200 with mch_ subject when a valid M2M token is sent', async () => { + const route = handle({ auth: fx.auth, accepts: 'm2m_token' }); + const res = await route(bearerRequest(fx.m2mTokenOpaque.token!)); + expect(res.status).toBe(200); + const body = (await res.json()) as Identity; + expect(body.sub).toMatch(/^mch_/); + }); + + it('401 when an M2M token hits an api_key-only route', async () => { + const route = handle({ auth: fx.auth, accepts: 'api_key' }); + const res = await route(bearerRequest(fx.m2mTokenOpaque.token!)); + expect(res.status).toBe(401); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe('not_authenticated'); + }); + + 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('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.apiKey.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({ secret: token }); + return { subject: verified.subject, type, scopes: verified.scopes }; + }, + }); + const res = await route(bearerRequest(fx.apiKey.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..37df0a5e945 --- /dev/null +++ b/packages/cli-auth/src/__tests__/integration/setup.ts @@ -0,0 +1,67 @@ +import { type APIKey, type ClerkClient, createClerkClient, type M2MToken, type User } from '@clerk/backend'; + +import { cliAuth, type CliAuthInstance } from '../../server'; + +export const INTEGRATION_SECRET_KEY = process.env.CLERK_SECRET_KEY; + +/** Skip the suite when the secret isn't configured — CI without it is a no-op. */ +export const skipWhenNoSecret = !INTEGRATION_SECRET_KEY; + +export interface IntegrationFixtures { + clerk: ClerkClient; + auth: CliAuthInstance; + user: User; + apiKey: APIKey; + m2mTokenOpaque: M2MToken; + m2mTokenJwt: M2MToken; +} + +/** + * Create the throwaway Clerk resources we verify against: a user (subject for the API + * key), one API key, and two M2M tokens (opaque and JWT-shaped). Returned fixtures are + * shared across the suite via `beforeAll` and cleaned up in `afterAll`. + */ +export async function provisionFixtures(): Promise { + if (!INTEGRATION_SECRET_KEY) { + throw new Error('CLERK_SECRET_KEY is required for integration tests'); + } + + const clerk = createClerkClient({ secretKey: INTEGRATION_SECRET_KEY }); + const auth = cliAuth({ client: clerk }); + + // Unique-per-run email so reruns in parallel CI shards don't collide. + const email = `cli-auth-int+${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`; + const user = await clerk.users.createUser({ emailAddress: [email] }); + + const apiKey = await clerk.apiKeys.create({ + name: `cli-auth integration ${Date.now()}`, + subject: user.id, + scopes: ['cli:read'], + }); + + const m2mTokenOpaque = await clerk.m2m.createToken({ tokenFormat: 'opaque' }); + const m2mTokenJwt = await clerk.m2m.createToken({ tokenFormat: 'jwt' }); + + return { clerk, auth, user, apiKey, m2mTokenOpaque, m2mTokenJwt }; +} + +/** Tear down everything `provisionFixtures` created. Each step is best-effort. */ +export async function teardownFixtures(fixtures: IntegrationFixtures | undefined): Promise { + if (!fixtures) { + return; + } + const { clerk, user, apiKey, m2mTokenOpaque, m2mTokenJwt } = fixtures; + + await Promise.allSettled([ + clerk.apiKeys.delete(apiKey.id), + clerk.m2m.revokeToken({ m2mTokenId: m2mTokenOpaque.id }), + clerk.m2m.revokeToken({ m2mTokenId: m2mTokenJwt.id }), + ]); + // Delete the user last so any token references resolve cleanly during revoke. + await Promise.allSettled([clerk.users.deleteUser(user.id)]); +} + +/** 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__/server-oauth.test.ts b/packages/cli-auth/src/__tests__/server-oauth.test.ts new file mode 100644 index 00000000000..bdbc92d50bc --- /dev/null +++ b/packages/cli-auth/src/__tests__/server-oauth.test.ts @@ -0,0 +1,88 @@ +import type * as ClerkBackendInternal from '@clerk/backend/internal'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@clerk/backend/internal', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + verifyMachineAuthToken: vi.fn(), + }; +}); + +// Import order matters: pull the mocked function out *after* the mock is registered. +const { verifyMachineAuthToken } = await import('@clerk/backend/internal'); +const { cliAuth } = await import('../server/cli-auth'); +const { handle } = await import('../server/handle'); + +const mockedVerify = vi.mocked(verifyMachineAuthToken); + +function bearer(token: string) { + return new Request('http://test.local/cli', { headers: { Authorization: `Bearer ${token}` } }); +} + +const FAKE_OAUTH_TOKEN = 'oat_fake_test_token_value_here'; + +describe('cli-auth server: oauth_token path (mocked)', () => { + it('verifyToken returns TokenInfo with type=oauth_token', async () => { + mockedVerify.mockResolvedValueOnce({ + data: { + subject: 'user_2abcDEFghiJKLmnoPQRstuVWXyz', + scopes: ['profile', 'email'], + claims: null, + } as never, + tokenType: 'oauth_token', + errors: undefined, + }); + + const auth = cliAuth({ clientConfig: { secretKey: 'sk_test_xxx' } }); + const info = await auth.verifyToken(FAKE_OAUTH_TOKEN); + expect(info.type).toBe('oauth_token'); + expect(info.subject).toBe('user_2abcDEFghiJKLmnoPQRstuVWXyz'); + expect(info.scopes).toEqual(['profile', 'email']); + }); + + it('handle() returns 200 + Identity body for a verified OAuth token', async () => { + mockedVerify.mockResolvedValueOnce({ + data: { + subject: 'user_2abcDEFghiJKLmnoPQRstuVWXyz', + scopes: ['profile'], + claims: null, + } as never, + tokenType: 'oauth_token', + errors: undefined, + }); + + const auth = cliAuth({ clientConfig: { secretKey: 'sk_test_xxx' } }); + 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('user_2abcDEFghiJKLmnoPQRstuVWXyz'); + }); + + it('rejects when accepts gates out oauth_token', async () => { + mockedVerify.mockResolvedValueOnce({ + data: { + subject: 'user_xxx', + scopes: undefined, + claims: null, + } as never, + tokenType: 'oauth_token', + errors: undefined, + }); + + const auth = cliAuth({ clientConfig: { secretKey: 'sk_test_xxx' } }); + await expect(auth.verifyToken(FAKE_OAUTH_TOKEN, { accepts: 'api_key' })).rejects.toThrow(/not accepted/i); + }); + + it('surfaces verifier errors from @clerk/backend as not_authenticated', async () => { + mockedVerify.mockResolvedValueOnce({ + data: undefined, + tokenType: 'oauth_token', + errors: [{ message: 'OAuth token not found' }] as never, + }); + + const auth = cliAuth({ clientConfig: { secretKey: 'sk_test_xxx' } }); + await expect(auth.verifyToken(FAKE_OAUTH_TOKEN)).rejects.toThrow(/OAuth token not found/); + }); +}); diff --git a/packages/cli-auth/vitest.config.mts b/packages/cli-auth/vitest.config.mts index ba0697457b9..d6435eaaf53 100644 --- a/packages/cli-auth/vitest.config.mts +++ b/packages/cli-auth/vitest.config.mts @@ -9,5 +9,8 @@ export default defineConfig({ 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, + }, + }, +}); From 14bec668ea7380e9c1320d583a5b4df2d00d300e Mon Sep 17 00:00:00 2001 From: Nicolas Angelo Date: Thu, 28 May 2026 19:10:59 -0400 Subject: [PATCH 18/20] fix(cli-auth): make integration tests self-contained via clerk.machines.create + correct apiKeys.verify signature --- packages/cli-auth/README.md | 2 +- .../src/__tests__/integration/server.test.ts | 2 +- .../src/__tests__/integration/setup.ts | 51 ++++++++++++++----- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/packages/cli-auth/README.md b/packages/cli-auth/README.md index 4f9170846d7..84a8b9b8a41 100644 --- a/packages/cli-auth/README.md +++ b/packages/cli-auth/README.md @@ -268,7 +268,7 @@ export const GET = handle({ if (!isAllowlisted(token)) { throw new Error('Token not allowlisted'); } - const apiKey = await clerk.apiKeys.verify({ secret: token }); + const apiKey = await clerk.apiKeys.verify(token); return { subject: apiKey.subject, type, scopes: apiKey.scopes }; }, }); diff --git a/packages/cli-auth/src/__tests__/integration/server.test.ts b/packages/cli-auth/src/__tests__/integration/server.test.ts index 0444d823aa6..705371e180e 100644 --- a/packages/cli-auth/src/__tests__/integration/server.test.ts +++ b/packages/cli-auth/src/__tests__/integration/server.test.ts @@ -143,7 +143,7 @@ describe.skipIf(skipWhenNoSecret)('cli-auth server integration', () => { // 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({ secret: token }); + const verified = await clerk.apiKeys.verify(token); return { subject: verified.subject, type, scopes: verified.scopes }; }, }); diff --git a/packages/cli-auth/src/__tests__/integration/setup.ts b/packages/cli-auth/src/__tests__/integration/setup.ts index 37df0a5e945..6adf8b92be6 100644 --- a/packages/cli-auth/src/__tests__/integration/setup.ts +++ b/packages/cli-auth/src/__tests__/integration/setup.ts @@ -1,4 +1,11 @@ -import { type APIKey, type ClerkClient, createClerkClient, type M2MToken, type User } from '@clerk/backend'; +import { + type APIKey, + type ClerkClient, + createClerkClient, + type M2MToken, + type Machine, + type User, +} from '@clerk/backend'; import { cliAuth, type CliAuthInstance } from '../../server'; @@ -12,6 +19,8 @@ export interface IntegrationFixtures { auth: CliAuthInstance; user: User; apiKey: APIKey; + /** Throwaway machine created for this run. Deleted in `teardownFixtures`. */ + machine: Machine; m2mTokenOpaque: M2MToken; m2mTokenJwt: M2MToken; } @@ -29,9 +38,22 @@ export async function provisionFixtures(): Promise { const clerk = createClerkClient({ secretKey: INTEGRATION_SECRET_KEY }); const auth = cliAuth({ client: clerk }); - // Unique-per-run email so reruns in parallel CI shards don't collide. - const email = `cli-auth-int+${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`; - const user = await clerk.users.createUser({ emailAddress: [email] }); + // Unique-per-run identifier so reruns in parallel CI shards don't collide. + const slug = `${Date.now()}${Math.random().toString(36).slice(2, 8)}`; + let user; + try { + user = await clerk.users.createUser({ + username: `cliauthint${slug}`, + password: `Test_${Math.random().toString(36).slice(2)}_${Date.now()}`, + skipPasswordChecks: true, + }); + } catch (err) { + // Surface the BAPI validation errors so failures are actionable. + 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)}` : ''}`, + ); + } const apiKey = await clerk.apiKeys.create({ name: `cli-auth integration ${Date.now()}`, @@ -39,10 +61,15 @@ export async function provisionFixtures(): Promise { scopes: ['cli:read'], }); - const m2mTokenOpaque = await clerk.m2m.createToken({ tokenFormat: 'opaque' }); - const m2mTokenJwt = await clerk.m2m.createToken({ tokenFormat: 'jwt' }); + // Provision a throwaway machine + its secret so we can mint M2M tokens without + // depending on any env var beyond CLERK_SECRET_KEY. Deleted in `teardownFixtures`. + const machine = await clerk.machines.create({ name: `cli-auth integration ${slug}` }); + const { secret: machineSecretKey } = await clerk.machines.getSecretKey(machine.id); + + const m2mTokenOpaque = await clerk.m2m.createToken({ machineSecretKey, tokenFormat: 'opaque' }); + const m2mTokenJwt = await clerk.m2m.createToken({ machineSecretKey, tokenFormat: 'jwt' }); - return { clerk, auth, user, apiKey, m2mTokenOpaque, m2mTokenJwt }; + return { clerk, auth, user, apiKey, machine, m2mTokenOpaque, m2mTokenJwt }; } /** Tear down everything `provisionFixtures` created. Each step is best-effort. */ @@ -50,14 +77,10 @@ export async function teardownFixtures(fixtures: IntegrationFixtures | undefined if (!fixtures) { return; } - const { clerk, user, apiKey, m2mTokenOpaque, m2mTokenJwt } = fixtures; + const { clerk, user, apiKey, machine } = fixtures; - await Promise.allSettled([ - clerk.apiKeys.delete(apiKey.id), - clerk.m2m.revokeToken({ m2mTokenId: m2mTokenOpaque.id }), - clerk.m2m.revokeToken({ m2mTokenId: m2mTokenJwt.id }), - ]); - // Delete the user last so any token references resolve cleanly during revoke. + // Deleting the machine cascades to its M2M tokens — no need to revoke them individually. + await Promise.allSettled([clerk.apiKeys.delete(apiKey.id), clerk.machines.delete(machine.id)]); await Promise.allSettled([clerk.users.deleteUser(user.id)]); } From 46793f34dacc3377f3af02631cf0741b08535097 Mon Sep 17 00:00:00 2001 From: Nicolas Angelo Date: Thu, 28 May 2026 22:23:23 -0400 Subject: [PATCH 19/20] feat(cli-auth)!: narrow accepted tokens to api_key and oauth_token and stabilize verifier on clerk.authenticateRequest --- .../src/__tests__/integration/server.test.ts | 103 ++++++------ .../src/__tests__/integration/setup.ts | 147 +++++++++++++----- .../src/__tests__/server-oauth.test.ts | 107 +++++++------ packages/cli-auth/src/lib/classify-token.ts | 34 ++-- packages/cli-auth/src/lib/verify-token.ts | 6 +- packages/cli-auth/src/server/cli-auth.ts | 28 ++-- packages/cli-auth/src/server/handle.ts | 10 +- packages/cli-auth/src/server/index.ts | 6 +- packages/cli-auth/src/server/resolve-auth.ts | 5 +- packages/cli-auth/src/server/types.ts | 30 ++-- packages/cli-auth/src/server/verify-token.ts | 91 +++++------ packages/cli-auth/src/types.ts | 8 +- 12 files changed, 312 insertions(+), 263 deletions(-) diff --git a/packages/cli-auth/src/__tests__/integration/server.test.ts b/packages/cli-auth/src/__tests__/integration/server.test.ts index 705371e180e..f6ab8aabca3 100644 --- a/packages/cli-auth/src/__tests__/integration/server.test.ts +++ b/packages/cli-auth/src/__tests__/integration/server.test.ts @@ -21,49 +21,33 @@ describe.skipIf(skipWhenNoSecret)('cli-auth server integration', () => { await teardownFixtures(fx); }); - describe('auth.verifyToken', () => { - it('verifies an API key, returns TokenInfo with type=api_key + correct subject', async () => { - const secret = fx.apiKey.secret; - expect(secret).toBeTypeOf('string'); - const info = await fx.auth.verifyToken(secret!); + 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 opaque M2M token, type=m2m_token, subject mch_*', async () => { - const token = fx.m2mTokenOpaque.token; - expect(token).toBeTypeOf('string'); - expect(token!.startsWith('mt_')).toBe(true); - const info = await fx.auth.verifyToken(token!); - expect(info.type).toBe('m2m_token'); - expect(info.subject).toMatch(/^mch_/); + 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 JWT-shaped M2M token, type=m2m_token', async () => { - const token = fx.m2mTokenJwt.token; - expect(token).toBeTypeOf('string'); - expect(token!.split('.').length).toBe(3); - const info = await fx.auth.verifyToken(token!); - expect(info.type).toBe('m2m_token'); + 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_/); }); - - it('rejects when accepts gates out the verified type', async () => { - // M2M sent to an api_key-only verifier. - await expect(fx.auth.verifyToken(fx.m2mTokenOpaque.token!, { accepts: 'api_key' })).rejects.toThrow( - /not accepted/i, - ); - }); - - it('throws on an unrecognized token', async () => { - await expect(fx.auth.verifyToken('not-a-real-token')).rejects.toThrow(); - }); }); describe('auth.verifyTokenFromRequest', () => { it('reads the Bearer header and verifies', async () => { - const req = bearerRequest(fx.apiKey.secret!); + 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'); @@ -73,44 +57,58 @@ describe.skipIf(skipWhenNoSecret)('cli-auth server integration', () => { 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('throws on an unaccepted type', async () => { - const req = bearerRequest(fx.m2mTokenOpaque.token!); - await expect(fx.auth.verifyTokenFromRequest(req, { accepts: 'api_key' })).rejects.toThrow(/not accepted/i); + 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.apiKey.secret!); - const identity = await fx.auth.resolveAuthInfo({ tokenInfo: info, request: bearerRequest(fx.apiKey.secret!) }); + 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 when a valid API key is sent', async () => { + 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.apiKey.secret!)); + 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 mch_ subject when a valid M2M token is sent', async () => { - const route = handle({ auth: fx.auth, accepts: 'm2m_token' }); - const res = await route(bearerRequest(fx.m2mTokenOpaque.token!)); + 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).toMatch(/^mch_/); + expect(body.sub).toBe(fx.org.id); }); - it('401 when an M2M token hits an api_key-only route', async () => { + 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.m2mTokenOpaque.token!)); - expect(res.status).toBe(401); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe('not_authenticated'); + 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 () => { @@ -119,6 +117,14 @@ describe.skipIf(skipWhenNoSecret)('cli-auth server integration', () => { 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, @@ -128,7 +134,7 @@ describe.skipIf(skipWhenNoSecret)('cli-auth server integration', () => { custom_field: 'enriched', }), }); - const res = await route(bearerRequest(fx.apiKey.secret!)); + 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); @@ -147,7 +153,8 @@ describe.skipIf(skipWhenNoSecret)('cli-auth server integration', () => { return { subject: verified.subject, type, scopes: verified.scopes }; }, }); - const res = await route(bearerRequest(fx.apiKey.secret!)); + + 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 index 6adf8b92be6..2472d10dc0d 100644 --- a/packages/cli-auth/src/__tests__/integration/setup.ts +++ b/packages/cli-auth/src/__tests__/integration/setup.ts @@ -2,86 +2,153 @@ import { type APIKey, type ClerkClient, createClerkClient, - type M2MToken, 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 the secret isn't configured — CI without it is a no-op. */ -export const skipWhenNoSecret = !INTEGRATION_SECRET_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; - apiKey: APIKey; - /** Throwaway machine created for this run. Deleted in `teardownFixtures`. */ + org: Organization; machine: Machine; - m2mTokenOpaque: M2MToken; - m2mTokenJwt: M2MToken; + /** API keys, one per supported subject kind. */ + userApiKey: APIKey; + orgApiKey: APIKey; + machineApiKey: APIKey; } -/** - * Create the throwaway Clerk resources we verify against: a user (subject for the API - * key), one API key, and two M2M tokens (opaque and JWT-shaped). Returned fixtures are - * shared across the suite via `beforeAll` and cleaned up in `afterAll`. - */ -export async function provisionFixtures(): Promise { - if (!INTEGRATION_SECRET_KEY) { - throw new Error('CLERK_SECRET_KEY is required for integration tests'); - } +// --------------------------------------------------------------------------- +// 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. +// --------------------------------------------------------------------------- - const clerk = createClerkClient({ secretKey: INTEGRATION_SECRET_KEY }); - const auth = cliAuth({ client: clerk }); +function uniqueSlug(): string { + return `${Date.now()}${Math.random().toString(36).slice(2, 8)}`; +} - // Unique-per-run identifier so reruns in parallel CI shards don't collide. - const slug = `${Date.now()}${Math.random().toString(36).slice(2, 8)}`; - let user; +async function createTestUser(): Promise { + const slug = uniqueSlug(); try { - user = await clerk.users.createUser({ + return await clerk.users.createUser({ username: `cliauthint${slug}`, - password: `Test_${Math.random().toString(36).slice(2)}_${Date.now()}`, + password: `Test_${slug}_${Date.now()}`, skipPasswordChecks: true, }); } catch (err) { - // Surface the BAPI validation errors so failures are actionable. + // 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, + }); +} - const apiKey = await clerk.apiKeys.create({ - name: `cli-auth integration ${Date.now()}`, - subject: user.id, - scopes: ['cli:read'], +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'); + } - // Provision a throwaway machine + its secret so we can mint M2M tokens without - // depending on any env var beyond CLERK_SECRET_KEY. Deleted in `teardownFixtures`. - const machine = await clerk.machines.create({ name: `cli-auth integration ${slug}` }); - const { secret: machineSecretKey } = await clerk.machines.getSecretKey(machine.id); + // Wave 1: user and machine are independent; provision in parallel. + const [user, machine] = await Promise.all([createTestUser(), createTestMachine()]); - const m2mTokenOpaque = await clerk.m2m.createToken({ machineSecretKey, tokenFormat: 'opaque' }); - const m2mTokenJwt = await clerk.m2m.createToken({ machineSecretKey, tokenFormat: 'jwt' }); + // Wave 2: org needs an owner (createdBy = user.id). + const org = await createTestOrg(user.id); - return { clerk, auth, user, apiKey, machine, m2mTokenOpaque, m2mTokenJwt }; + // 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. Each step is best-effort. */ +/** Tear down everything `provisionFixtures` created. Surfaces any cleanup failures. */ export async function teardownFixtures(fixtures: IntegrationFixtures | undefined): Promise { if (!fixtures) { return; } - const { clerk, user, apiKey, machine } = fixtures; + 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), + ]); - // Deleting the machine cascades to its M2M tokens — no need to revoke them individually. - await Promise.allSettled([clerk.apiKeys.delete(apiKey.id), clerk.machines.delete(machine.id)]); - await Promise.allSettled([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. */ diff --git a/packages/cli-auth/src/__tests__/server-oauth.test.ts b/packages/cli-auth/src/__tests__/server-oauth.test.ts index bdbc92d50bc..d40ff05ea99 100644 --- a/packages/cli-auth/src/__tests__/server-oauth.test.ts +++ b/packages/cli-auth/src/__tests__/server-oauth.test.ts @@ -1,88 +1,87 @@ -import type * as ClerkBackendInternal from '@clerk/backend/internal'; +import type { ClerkClient } from '@clerk/backend'; import { describe, expect, it, vi } from 'vitest'; -vi.mock('@clerk/backend/internal', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - verifyMachineAuthToken: vi.fn(), - }; -}); - -// Import order matters: pull the mocked function out *after* the mock is registered. -const { verifyMachineAuthToken } = await import('@clerk/backend/internal'); -const { cliAuth } = await import('../server/cli-auth'); -const { handle } = await import('../server/handle'); - -const mockedVerify = vi.mocked(verifyMachineAuthToken); +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 () => { - mockedVerify.mockResolvedValueOnce({ - data: { - subject: 'user_2abcDEFghiJKLmnoPQRstuVWXyz', - scopes: ['profile', 'email'], - claims: null, - } as never, - tokenType: 'oauth_token', - errors: undefined, + const client = fakeClerkClient({ + isAuthenticated: true, + auth: { subject: FAKE_SUBJECT, scopes: ['profile', 'email'], tokenType: 'oauth_token' }, }); + const auth = cliAuth({ client }); - const auth = cliAuth({ clientConfig: { secretKey: 'sk_test_xxx' } }); const info = await auth.verifyToken(FAKE_OAUTH_TOKEN); expect(info.type).toBe('oauth_token'); - expect(info.subject).toBe('user_2abcDEFghiJKLmnoPQRstuVWXyz'); + expect(info.subject).toBe(FAKE_SUBJECT); expect(info.scopes).toEqual(['profile', 'email']); }); it('handle() returns 200 + Identity body for a verified OAuth token', async () => { - mockedVerify.mockResolvedValueOnce({ - data: { - subject: 'user_2abcDEFghiJKLmnoPQRstuVWXyz', - scopes: ['profile'], - claims: null, - } as never, - tokenType: 'oauth_token', - errors: undefined, + const client = fakeClerkClient({ + isAuthenticated: true, + auth: { subject: FAKE_SUBJECT, scopes: ['profile'], tokenType: 'oauth_token' }, }); + const auth = cliAuth({ client }); - const auth = cliAuth({ clientConfig: { secretKey: 'sk_test_xxx' } }); 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('user_2abcDEFghiJKLmnoPQRstuVWXyz'); + expect(body.sub).toBe(FAKE_SUBJECT); }); - it('rejects when accepts gates out oauth_token', async () => { - mockedVerify.mockResolvedValueOnce({ - data: { - subject: 'user_xxx', - scopes: undefined, - claims: null, - } as never, - tokenType: 'oauth_token', - errors: undefined, - }); + it('surfaces clerk.authenticateRequest rejection as not_authenticated', async () => { + const client = fakeClerkClient({ isAuthenticated: false, reason: 'OAuth token expired' }); + const auth = cliAuth({ client }); - const auth = cliAuth({ clientConfig: { secretKey: 'sk_test_xxx' } }); - await expect(auth.verifyToken(FAKE_OAUTH_TOKEN, { accepts: 'api_key' })).rejects.toThrow(/not accepted/i); + await expect(auth.verifyToken(FAKE_OAUTH_TOKEN)).rejects.toThrow(/OAuth token expired/); }); - it('surfaces verifier errors from @clerk/backend as not_authenticated', async () => { - mockedVerify.mockResolvedValueOnce({ - data: undefined, - tokenType: 'oauth_token', - errors: [{ message: 'OAuth token not found' }] as never, + 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 auth = cliAuth({ clientConfig: { secretKey: 'sk_test_xxx' } }); - await expect(auth.verifyToken(FAKE_OAUTH_TOKEN)).rejects.toThrow(/OAuth token not found/); + 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/lib/classify-token.ts b/packages/cli-auth/src/lib/classify-token.ts index 463f92d78eb..2c0380eb237 100644 --- a/packages/cli-auth/src/lib/classify-token.ts +++ b/packages/cli-auth/src/lib/classify-token.ts @@ -4,16 +4,15 @@ import { decodeJwt } from '@clerk/backend/jwt'; import { ClerkCliAuthError } from '../errors'; /** - * Token kind values. Re-export of `@clerk/backend`'s `MachineTokenType` — - * `'api_key' | 'm2m_token' | 'oauth_token'`. Session tokens are intentionally - * excluded; the CLI flow never holds a browser session credential. + * 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 = MachineTokenType; +export type TokenKind = Exclude; const API_KEY_PREFIX = 'ak_'; -const M2M_TOKEN_PREFIX = 'mt_'; const OAUTH_TOKEN_PREFIX = 'oat_'; -const M2M_SUBJECT_PREFIX = 'mch_'; 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; @@ -23,40 +22,35 @@ function isJwtFormat(token: string): boolean { } /** - * Classify a token by prefix or JWT claims. Mirrors `@clerk/backend`'s internal - * `getMachineTokenType`: + * Classify a credential as an API key or an OAuth access token by prefix / JWT shape: * - * - `ak_*` → `'api_key'` - * - `mt_*` or JWT with `sub: mch_*` → `'m2m_token'` + * - `ak_*` → `'api_key'` (user/org/machine API keys all share this prefix) * - `oat_*` or JWT with `typ: at+jwt` (RFC 9068) → `'oauth_token'` * - * Throws when the token doesn't match any known machine-token shape. + * 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(M2M_TOKEN_PREFIX)) { - return TokenType.M2MToken; - } if (token.startsWith(OAUTH_TOKEN_PREFIX)) { return TokenType.OAuthToken; } if (isJwtFormat(token)) { const result = decodeJwt(token) as { - data?: { header: { typ?: unknown }; payload: { sub?: unknown } }; + data?: { header: { typ?: unknown } }; errors?: unknown; }; if (!result.errors && result.data) { - const sub = result.data.payload.sub; - if (typeof sub === 'string' && sub.startsWith(M2M_SUBJECT_PREFIX)) { - return TokenType.M2MToken; - } 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', 'Unable to determine token type for credential.'); + throw new ClerkCliAuthError( + 'not_authenticated', + 'Unsupported credential. Expected an API key or OAuth access token.', + ); } diff --git a/packages/cli-auth/src/lib/verify-token.ts b/packages/cli-auth/src/lib/verify-token.ts index a1db47cf184..7fd80bbd5ea 100644 --- a/packages/cli-auth/src/lib/verify-token.ts +++ b/packages/cli-auth/src/lib/verify-token.ts @@ -9,9 +9,9 @@ export interface VerifyTokenParams { } /** - * POST a credential (API key, machine token, or OAuth access token) to a consumer-hosted - * `identityEndpoint` and return the verified `Identity`. The endpoint is responsible for - * verifying the credential server-side. + * 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, { diff --git a/packages/cli-auth/src/server/cli-auth.ts b/packages/cli-auth/src/server/cli-auth.ts index 8c7d16ecf31..73023a14e79 100644 --- a/packages/cli-auth/src/server/cli-auth.ts +++ b/packages/cli-auth/src/server/cli-auth.ts @@ -1,7 +1,7 @@ import { type ClerkClient, type ClerkOptions, createClerkClient } from '@clerk/backend'; -import type { MachineTokenType } from '@clerk/backend/internal'; import { ClerkCliAuthError } from '../errors'; +import type { TokenKind } from '../lib/classify-token'; import { resolveAuthInfo as defaultResolveAuthInfo } from './resolve-auth'; import type { AcceptsToken, @@ -11,7 +11,12 @@ import type { ResolveAuthInfoContext, TokenInfo, } from './types'; -import { readBearer, verifyTokenWithClerk } from './verify-token'; +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( @@ -87,26 +92,23 @@ function makeClerkGetter( export function cliAuth(options: CliAuthFactoryOptions = {}): CliAuthInstance { const getClerk = makeClerkGetter(options.client, options.clientConfig); - async function verifyToken( - token: string, + async function verifyTokenFromRequest( + request: Request, verifyOptions?: { accepts?: AcceptsToken }, ): Promise> { - const info = await verifyTokenWithClerk(token, { - accepts: verifyOptions?.accepts, - clientConfig: options.clientConfig, - }); + const clerk = await getClerk(); + const info = await verifyTokenWithClerk(request, { accepts: verifyOptions?.accepts, clerk }); return info as TokenInfo; } - async function verifyTokenFromRequest( - request: Request, + async function verifyToken( + token: string, verifyOptions?: { accepts?: AcceptsToken }, ): Promise> { - const token = readBearer(request); - return verifyToken(token, verifyOptions); + return verifyTokenFromRequest(requestForToken(token), verifyOptions); } - function resolveAuthInfo( + function resolveAuthInfo( ctx: Omit, 'clerk'> & { clerk?: ClerkClient }, ): ReturnType { return defaultResolveAuthInfo(ctx as ResolveAuthInfoContext); diff --git a/packages/cli-auth/src/server/handle.ts b/packages/cli-auth/src/server/handle.ts index 09776e85e60..7ecac9331ba 100644 --- a/packages/cli-auth/src/server/handle.ts +++ b/packages/cli-auth/src/server/handle.ts @@ -1,7 +1,5 @@ -import type { MachineTokenType } from '@clerk/backend/internal'; - import { ClerkCliAuthError, EXIT_CODE } from '../errors'; -import { classifyToken } from '../lib/classify-token'; +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'; @@ -28,7 +26,7 @@ function jsonError(error: ClerkCliAuthError): Response { ); } -async function verifyForHandle( +async function verifyForHandle( request: Request, options: HandleOptions, ): Promise> { @@ -61,14 +59,14 @@ async function verifyForHandle( * * export const GET = handle({ * auth, - * accepts: ['api_key', 'm2m_token', 'oauth_token'], + * accepts: ['api_key', 'oauth_token'], * // Optional overrides: * // verifyToken: ({ token, type, request, clerk }) => ... * // resolveAuthInfo: ({ tokenInfo, request, clerk }) => ... * }); * ``` */ -export function handle( +export function handle( options: HandleOptions, ): (request: Request) => Promise { return async function routeHandler(request: Request): Promise { diff --git a/packages/cli-auth/src/server/index.ts b/packages/cli-auth/src/server/index.ts index e2e0c3db6f7..946d63200a7 100644 --- a/packages/cli-auth/src/server/index.ts +++ b/packages/cli-auth/src/server/index.ts @@ -13,7 +13,5 @@ export type { VerifyTokenFn, } from './types'; -// Re-export the canonical `MachineTokenType` and `isTokenTypeAccepted` from `@clerk/backend` -// so consumers don't have to dual-import. -export { isTokenTypeAccepted, TokenType } from '@clerk/backend/internal'; -export type { MachineTokenType } from '@clerk/backend/internal'; +// 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 index 34775d00c71..9a91a145d14 100644 --- a/packages/cli-auth/src/server/resolve-auth.ts +++ b/packages/cli-auth/src/server/resolve-auth.ts @@ -1,6 +1,5 @@ -import type { MachineTokenType } from '@clerk/backend/internal'; - import { ClerkCliAuthError } from '../errors'; +import type { TokenKind } from '../lib/classify-token'; import type { Identity } from '../types'; import type { ResolveAuthInfoContext, TokenInfo } from './types'; @@ -38,7 +37,7 @@ function extractSubject(tokenInfo: TokenInfo): string { * 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 { +export function resolveAuthInfo(ctx: ResolveAuthInfoContext): Identity { const { tokenInfo } = ctx; const sub = extractSubject(tokenInfo); diff --git a/packages/cli-auth/src/server/types.ts b/packages/cli-auth/src/server/types.ts index 07f1888f325..d0cff5458c3 100644 --- a/packages/cli-auth/src/server/types.ts +++ b/packages/cli-auth/src/server/types.ts @@ -1,14 +1,14 @@ import type { ClerkClient, ClerkOptions } from '@clerk/backend'; -import type { MachineTokenType } from '@clerk/backend/internal'; +import type { TokenKind } from '../lib/classify-token'; import type { Identity } from '../types'; /** - * Which token types this endpoint accepts. Mirrors `@clerk/backend`'s `acceptsToken`: - * a single {@link MachineTokenType}, a readonly tuple of them, or the literal `'any'`. - * Session tokens are intentionally excluded — the CLI flow never holds one. + * 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 = MachineTokenType | readonly MachineTokenType[] | 'any'; +export type AcceptsToken = TokenKind | readonly TokenKind[] | 'any'; /** * A Clerk Backend client, either resolved or wrapped in a factory. Passing the factory @@ -21,10 +21,10 @@ export type ClientArg = ClerkClient | (() => ClerkClient | Promise) * Verified token payload. Returned by `verifyToken` / `verifyTokenFromRequest` * and passed into `resolveAuthInfo` as `tokenInfo`. */ -export interface TokenInfo { +export interface TokenInfo { /** The verified token's subject — `user_*`, `org_*`, `mch_*`, or `scim_*`. */ subject: string; - /** The verified token type (`api_key` | `m2m_token` | `oauth_token`). */ + /** The verified token type (`api_key` | `oauth_token`). */ type: T; /** Scopes attached to the token, when applicable. */ scopes?: string[]; @@ -36,7 +36,7 @@ export interface TokenInfo { * 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 { +export interface VerifyTokenContext { /** Raw bearer token from the `Authorization` header. */ token: string; /** Token type auto-detected from the token's prefix / JWT shape. */ @@ -52,7 +52,7 @@ export interface VerifyTokenContext { +export interface ResolveAuthInfoContext { /** The verified token, including subject, type, scopes, and claims. */ tokenInfo: TokenInfo; /** Original incoming `Request`. */ @@ -62,12 +62,12 @@ export interface ResolveAuthInfoContext = ( +export type VerifyTokenFn = ( ctx: VerifyTokenContext, ) => Promise> | TokenInfo; /** A `resolveAuthInfo` callback shapes the verified token into a `Identity` payload. */ -export type ResolveAuthInfoFn = ( +export type ResolveAuthInfoFn = ( ctx: ResolveAuthInfoContext, ) => Promise | Identity; @@ -91,7 +91,7 @@ export interface CliAuthFactoryOptions { * bind the route to via `auth`. Per-route Clerk client overrides aren't supported — make * another `cliAuth()` instance instead. */ -export interface HandleOptions { +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'`. */ @@ -126,7 +126,7 @@ 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: ( + verifyToken: ( token: string, options?: { accepts?: AcceptsToken }, ) => Promise>; @@ -134,12 +134,12 @@ export interface CliAuthInstance { * Request-level verifier — reads `Authorization: Bearer `, then defers to * `verifyToken(token, options)`. */ - verifyTokenFromRequest: ( + 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: ( + resolveAuthInfo: ( ctx: Omit, 'clerk'> & { clerk?: ClerkClient }, ) => Identity | Promise; /** Resolve the bound Clerk Backend SDK client. Cached after the first call. */ diff --git a/packages/cli-auth/src/server/verify-token.ts b/packages/cli-auth/src/server/verify-token.ts index 751a6f77b5d..f7dd1d6c719 100644 --- a/packages/cli-auth/src/server/verify-token.ts +++ b/packages/cli-auth/src/server/verify-token.ts @@ -1,11 +1,14 @@ -import type { ClerkOptions } from '@clerk/backend'; -import { isTokenTypeAccepted, type MachineTokenType, verifyMachineAuthToken } from '@clerk/backend/internal'; +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) { @@ -19,70 +22,52 @@ export function readBearer(request: Request): string { } /** - * Build the `VerifyTokenOptions` payload `verifyMachineAuthToken` expects. The bound - * `clientConfig` wins; otherwise we fall back to the env vars `@clerk/backend` itself reads. + * 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". */ -export function buildVerifyOptions(clientConfig: ClerkOptions | undefined): { - secretKey: string; - apiUrl?: string; - jwtKey?: string; -} { - const secretKey = clientConfig?.secretKey ?? process.env.CLERK_SECRET_KEY; - if (!secretKey) { - throw new ClerkCliAuthError( - 'config', - 'cliAuth() needs a Clerk secret key. Pass `clientConfig.secretKey`, or set CLERK_SECRET_KEY in the env.', - ); +function normalizeAccepts(accepts: AcceptsToken | undefined): readonly TokenKind[] { + if (!accepts || accepts === 'any') { + return DEFAULT_ACCEPTS; } - return { - secretKey, - apiUrl: clientConfig?.apiUrl ?? process.env.CLERK_API_URL ?? undefined, - jwtKey: clientConfig?.jwtKey ?? process.env.CLERK_JWT_KEY ?? undefined, - }; + return Array.isArray(accepts) ? accepts : [accepts as TokenKind]; } /** - * Default token verifier — delegates to `@clerk/backend`'s `verifyMachineAuthToken`, which - * detects the token type internally (api_key / m2m_token / oauth_token) and verifies via - * the appropriate Backend API endpoint or JWT path. Maps the result to {@link TokenInfo}. + * 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. * - * `accepts` (when provided) gates against the verified `tokenType`; tokens of an unaccepted - * type fail with `not_authenticated`. Consumers can replace this entirely by passing a - * `verifyToken` override to `handle()`. + * 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( - token: string, - options: { accepts?: AcceptsToken; clientConfig?: ClerkOptions }, + request: Request, + options: { accepts?: AcceptsToken; clerk: ClerkClient }, ): Promise { - const verifyOptions = buildVerifyOptions(options.clientConfig); - const result = await verifyMachineAuthToken(token, verifyOptions); - - const accepts: AcceptsToken = options.accepts ?? 'any'; - if (!isTokenTypeAccepted(result.tokenType, accepts)) { - throw new ClerkCliAuthError( - 'not_authenticated', - `Token type "${result.tokenType}" is not accepted by this endpoint.`, - ); - } + // 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); - if (result.errors) { - throw new ClerkCliAuthError('not_authenticated', result.errors[0].message); + 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.'); } - return mapVerifiedToken(result.data, result.tokenType); -} - -interface VerifiedTokenLike { - subject: string; - scopes?: string[] | null; - claims?: Record | null; -} + 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; -function mapVerifiedToken(data: VerifiedTokenLike, type: MachineTokenType): TokenInfo { return { - subject: data.subject, - type, - scopes: data.scopes ?? undefined, - claims: data.claims ?? undefined, + 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 index 4a29625774d..df9b88971e8 100644 --- a/packages/cli-auth/src/types.ts +++ b/packages/cli-auth/src/types.ts @@ -43,10 +43,10 @@ export interface ClerkCliAuthConfig { /** 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, - * machine token, 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 + * 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; From c39913e438315e204c6ca371181423699383d4f6 Mon Sep 17 00:00:00 2001 From: Nicolas Angelo Date: Thu, 28 May 2026 22:23:41 -0400 Subject: [PATCH 20/20] refactor(cli-auth)!: replace resolveToken kind with source field and drop classifyToken from CLI exports --- .changeset/loud-callbacks-listen.md | 18 +++++---- packages/cli-auth/README.md | 50 ++++++++++++------------- packages/cli-auth/src/clerk-cli-auth.ts | 20 ++++++---- packages/cli-auth/src/index.ts | 1 - packages/cli-auth/src/types.ts | 10 +++++ 5 files changed, 56 insertions(+), 43 deletions(-) diff --git a/.changeset/loud-callbacks-listen.md b/.changeset/loud-callbacks-listen.md index da31d4214fc..afb3bafd9d0 100644 --- a/.changeset/loud-callbacks-listen.md +++ b/.changeset/loud-callbacks-listen.md @@ -4,20 +4,24 @@ 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-token resolution via `resolveToken()`, optional Clerk API key verification, and tunable timeouts (`loginTimeoutMs`, `requestTimeoutMs`). +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 factory consumers drop into any framework using Web `Request`/`Response` (Next.js App Router, Hono, Cloudflare Workers, Bun, Deno): +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 { handle, verifyToken, verifyTokenFromRequest, resolveAuthInfo } = - cliAuth({ client: await clerkClient() }); +export const auth = cliAuth({ client: clerkClient }); -// app/api/cli/verify/route.ts -export const GET = handle({ accepts: ['api_key', 'oauth_token'] }); +// 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 `handle()`, `verifyToken()`, `verifyTokenFromRequest()`, and `resolveAuthInfo()` ready to use. `handle({ accepts, verifyToken?, resolveAuthInfo? })` produces a route handler that detects token type by prefix, gates against `accepts`, verifies via `@clerk/backend`, and returns a `UserInfo` JSON payload. Override the verification or resolution steps per-route by passing the corresponding callbacks. +`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/packages/cli-auth/README.md b/packages/cli-auth/README.md index 84a8b9b8a41..1db3c9f6a5a 100644 --- a/packages/cli-auth/README.md +++ b/packages/cli-auth/README.md @@ -149,9 +149,9 @@ await auth.logout(); await auth.logout({ revoke: false }); ``` -### Accept API keys and machine tokens alongside OAuth +### 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_*`) or machine-to-machine token (`mt_*`) instead of going through the browser. Configure `identityEndpoint` (and optionally `tokenEnvVar`) to enable this: +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({ @@ -161,46 +161,44 @@ const auth = new ClerkCliAuth({ tokenEnvVar: 'MYAPP_API_KEY', }); -// Look up the identity for a specific token (API key, machine token, or OAuth access token): +// 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, kind } = await auth.resolveToken({ tokenFromArg: argv.token }); +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). -2. The environment variable named in `tokenEnvVar`. -3. The cached OAuth access token from `login()`. +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'`. -It returns `{ token, kind }`, where `kind` is one of `'session_token'`, `'api_key'`, `'m2m_token'`, or `'oauth_token'` (matching the Clerk Backend SDK's `TokenType`). Use it to branch logic per credential type: +`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, kind } = await auth.resolveToken({ tokenFromArg: argv.token }); +const { token, source } = await auth.resolveToken({ tokenFromArg: argv.token }); -// One call works for every kind — the server-side handler resolves the identity via the -// matching verification path: +// One call works for every source — the server-side handler verifies and returns the +// identity: const identity = await auth.verifyToken(token); -if (kind === 'm2m_token') { - // ...e.g. attach the machine actor's org context to a log line +if (source === 'oauth') { + // ...e.g. surface "logged in as " UI; this credential is revokable via logout() } ``` -Server-side verification of API keys and machine tokens happens at the `identityEndpoint` you host — see [Server-side](#server-side) for the implementation. +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. It accepts every [Clerk Backend SDK](https://clerk.com/docs/references/backend/overview) token type: +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 | -| ----------------- | -------------------------------- | -| `'session_token'` | Clerk session JWTs | -| `'api_key'` | `ak_*` Clerk API keys | -| `'m2m_token'` | `mt_*` machine-to-machine tokens | -| `'oauth_token'` | `oat_*` OAuth access tokens | -| `'any'` | Any of the above | +| `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 @@ -227,11 +225,11 @@ import { auth } from '@/lib/clerk-cli'; export const GET = handle({ auth, - accepts: ['api_key', 'm2m_token', 'oauth_token'], + 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, machine tokens, and OAuth access tokens alike. +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 @@ -244,7 +242,7 @@ import { auth } from '@/lib/clerk-cli'; export async function GET(request: Request) { const tokenInfo = await auth.verifyTokenFromRequest(request, { - accepts: ['api_key', 'm2m_token', 'oauth_token'], + accepts: ['api_key', 'oauth_token'], }); const projects = await getProjectsForSubject(tokenInfo.subject); @@ -299,8 +297,6 @@ export const GET = handle({ }); ``` -For multi-tenant applications, create one `cliAuth()` instance per tenant (each bound to a different Clerk client) and pass the relevant instance to `handle()` on each route. - ## Configuration reference ### Storage diff --git a/packages/cli-auth/src/clerk-cli-auth.ts b/packages/cli-auth/src/clerk-cli-auth.ts index 203b3293c10..7480e6bb87d 100644 --- a/packages/cli-auth/src/clerk-cli-auth.ts +++ b/packages/cli-auth/src/clerk-cli-auth.ts @@ -2,7 +2,6 @@ import { spawn } from 'node:child_process'; import { ClerkCliAuthError } from './errors'; import { startAuthServer } from './lib/auth-server'; -import { classifyToken, type TokenKind } from './lib/classify-token'; import { createCredentialStore } from './lib/credential-store'; import { generateCodeChallenge, generateCodeVerifier, generateState } from './lib/pkce'; import { exchangeCodeForTokens, fetchIdentity, refreshAccessToken, revokeToken } from './lib/token-exchange'; @@ -14,6 +13,7 @@ import type { LoginResult, OAuthScope, TokenSet, + TokenSource, UserIdentity, } from './types'; @@ -203,22 +203,26 @@ export class ClerkCliAuth { }); } - async resolveToken(opts: { tokenFromArg?: string } = {}): Promise<{ token: string; kind: TokenKind }> { + /** + * 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, - kind: classifyToken(opts.tokenFromArg), - }; + return { token: opts.tokenFromArg, source: 'arg' }; } if (this.config.tokenEnvVar) { const fromEnv = process.env[this.config.tokenEnvVar]; if (fromEnv) { - return { token: fromEnv, kind: classifyToken(fromEnv) }; + return { token: fromEnv, source: 'env' }; } } const accessToken = await this.getAccessToken(); if (accessToken) { - return { token: accessToken, kind: classifyToken(accessToken) }; + return { token: accessToken, source: 'oauth' }; } const envHint = this.config.tokenEnvVar ? ` or set $${this.config.tokenEnvVar}` : ''; diff --git a/packages/cli-auth/src/index.ts b/packages/cli-auth/src/index.ts index 628f57cd0b6..a54599f1d8b 100644 --- a/packages/cli-auth/src/index.ts +++ b/packages/cli-auth/src/index.ts @@ -15,4 +15,3 @@ export * from './types'; export * from './errors'; export { fetchIdentity, revokeToken } from './lib/token-exchange'; export { verifyToken } from './lib/verify-token'; -export { classifyToken, type TokenKind } from './lib/classify-token'; diff --git a/packages/cli-auth/src/types.ts b/packages/cli-auth/src/types.ts index df9b88971e8..0de0a08403e 100644 --- a/packages/cli-auth/src/types.ts +++ b/packages/cli-auth/src/types.ts @@ -66,6 +66,16 @@ export interface TokenSet { 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: