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
+
+
+
+
+[](https://clerk.com/discord)
+[](https://clerk.com/docs?utm_source=github&utm_medium=clerk_cli_auth)
+[](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 @@
[](https://clerk.com/discord)
[](https://clerk.com/docs?utm_source=github&utm_medium=clerk_cli_auth)
-[](https://twitter.com/intent/follow?screen_name=Clerk)
+[](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