diff --git a/.changeset/loud-callbacks-listen.md b/.changeset/loud-callbacks-listen.md
new file mode 100644
index 00000000000..afb3bafd9d0
--- /dev/null
+++ b/.changeset/loud-callbacks-listen.md
@@ -0,0 +1,27 @@
+---
+'@clerk/cli-auth': minor
+---
+
+Add `@clerk/cli-auth`: reusable OAuth 2.0 + PKCE localhost-callback flow for adding Clerk authentication to Node.js CLIs.
+
+The default export (`@clerk/cli-auth`) ships the CLI-side runtime: browser-based sign-in via a one-shot localhost callback server, token storage (keychain with file fallback, file, or memory), token refresh, revocation, `/oauth/userinfo` lookup, multi-credential resolution via `resolveToken()` (returns `{ token, source }` where `source` is `'arg' | 'env' | 'oauth'`), optional `identityEndpoint`-backed verification, and tunable timeouts (`loginTimeoutMs`, `requestTimeoutMs`).
+
+The new `@clerk/cli-auth/server` subpath ships a backend route handler that consumers drop into any framework using Web `Request`/`Response` (Next.js App Router, Hono, Cloudflare Workers, Bun, Deno):
+
+```ts
+// lib/clerk-cli.ts
+import { cliAuth } from '@clerk/cli-auth/server';
+import { clerkClient } from '@clerk/nextjs/server';
+
+export const auth = cliAuth({ client: clerkClient });
+
+// app/api/cli/identity/route.ts
+import { handle } from '@clerk/cli-auth/server';
+import { auth } from '@/lib/clerk-cli';
+
+export const GET = handle({ auth, accepts: ['api_key', 'oauth_token'] });
+```
+
+`cliAuth({ client | clientConfig })` binds a `@clerk/backend` client once and returns an instance exposing `verifyToken(token)`, `verifyTokenFromRequest(request)`, `resolveAuthInfo(ctx)`, and `getClerk()`. The standalone `handle({ auth, accepts, verifyToken?, resolveAuthInfo? })` produces a route handler that auto-detects token type, gates against `accepts`, verifies via `@clerk/backend`'s `verifyMachineAuthToken`, and returns an `Identity` JSON payload. Override the verification or resolution steps per-route by passing the corresponding callbacks.
+
+`accepts` recognizes `'api_key'` (covers user, org, and machine subjects), `'oauth_token'` (opaque `oat_*` or RFC 9068 `at+jwt` JWTs), or `'any'`. The narrowed `TokenKind` excludes Clerk session tokens and M2M tokens — neither is a CLI credential.
diff --git a/.github/labeler.yml b/.github/labeler.yml
index 642ff9645ad..dd462647c30 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -12,6 +12,10 @@ chrome-extension:
- changed-files:
- any-glob-to-any-file: packages/chrome-extension/**
+cli-auth:
+ - changed-files:
+ - any-glob-to-any-file: packages/cli-auth/**
+
clerk-js:
- changed-files:
- any-glob-to-any-file: packages/clerk-js/**
diff --git a/packages/cli-auth/.gitignore b/packages/cli-auth/.gitignore
new file mode 100644
index 00000000000..6a53db05dea
--- /dev/null
+++ b/packages/cli-auth/.gitignore
@@ -0,0 +1,8 @@
+*.log
+.DS_Store
+.idea
+node_modules
+dist
+coverage
+.env
+.env.local
diff --git a/packages/cli-auth/.npmignore b/packages/cli-auth/.npmignore
new file mode 100644
index 00000000000..8b2767ff3b3
--- /dev/null
+++ b/packages/cli-auth/.npmignore
@@ -0,0 +1,2 @@
+*
+!/dist/**/*
\ No newline at end of file
diff --git a/packages/cli-auth/LICENSE b/packages/cli-auth/LICENSE
new file mode 100644
index 00000000000..5713d0938b3
--- /dev/null
+++ b/packages/cli-auth/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 Clerk, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/cli-auth/README.md b/packages/cli-auth/README.md
new file mode 100644
index 00000000000..1db3c9f6a5a
--- /dev/null
+++ b/packages/cli-auth/README.md
@@ -0,0 +1,361 @@
+
+
+
+
+
+
+
+
+
@clerk/cli-auth
+
+
+
+
+[](https://clerk.com/discord)
+[](https://clerk.com/docs?utm_source=github&utm_medium=clerk_cli_auth)
+[](https://x.com/intent/follow?screen_name=Clerk)
+
+[Changelog](https://github.com/clerk/javascript/blob/main/packages/cli-auth/CHANGELOG.md)
+·
+[Report a Bug](https://github.com/clerk/javascript/issues/new?assignees=&labels=needs-triage&projects=&template=BUG_REPORT.yml)
+·
+[Request a Feature](https://feedback.clerk.com/roadmap)
+·
+[Get help](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_cli_auth)
+
+
+
+## Getting Started
+
+`@clerk/cli-auth` is a set of building blocks for adding [Clerk](https://clerk.com) authentication to Node.js command-line tools.
+
+The package ships two entry points:
+
+- **`@clerk/cli-auth`** — sign-in and credential management in your CLI.
+- **`@clerk/cli-auth/server`** — verify CLI requests on your backend.
+
+### Prerequisites
+
+- Node.js `>=20.9.0` or later
+- An existing Clerk application. [Create your account for free](https://dashboard.clerk.com/sign-up?utm_source=github&utm_medium=clerk_cli_auth).
+
+### Installation
+
+```sh
+npm install @clerk/cli-auth
+```
+
+## Setup
+
+Create an OAuth Application in your Clerk instance and grab its `client_id` and issuer URL.
+
+### Create an OAuth Application
+
+Pick whichever path fits your workflow.
+
+**Clerk Dashboard** — in your dev instance, go to **Configure → OAuth Applications → Create** and set:
+
+- **Name** — your CLI's name
+- **Redirect URI** — `http://127.0.0.1/callback` (the CLI listens on a dynamic loopback port and sends the actual `http://127.0.0.1:{port}/callback` during authorization)
+- **Public client (PKCE)** — enabled
+- **Scopes** — `profile email openid offline_access`
+
+**Clerk CLI** — if you have [`clerk`](https://clerk.com/cli) installed, this is the fastest path. Keychain-based auth, no secret key in the env:
+
+```sh
+clerk api /oauth_applications --instance -X POST --yes -d '{
+ "name": "my-cli",
+ "redirect_uris": ["http://127.0.0.1/callback"],
+ "public": true,
+ "pkce_required": true,
+ "scopes": "profile email openid offline_access"
+}'
+```
+
+**curl against BAPI** — if you'd rather script it directly. Replace `$SK` with your instance's secret key:
+
+```sh
+curl -X POST https://api.clerk.com/v1/oauth_applications \
+ -H "Authorization: Bearer $SK" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "name": "my-cli",
+ "redirect_uris": ["http://127.0.0.1/callback"],
+ "public": true,
+ "pkce_required": true,
+ "scopes": "profile email openid offline_access"
+ }'
+```
+
+All three paths return a JSON object with `client_id`. Pair it with your instance's Frontend API URL (the issuer — e.g. `https://clerk.your-subdomain.accounts.dev` or a custom domain like `https://clerk.yourapp.com`).
+
+### Configure your CLI
+
+```sh
+export CLERK_OAUTH_CLIENT_ID="..."
+export CLERK_ISSUER="https://clerk.your-subdomain.accounts.dev"
+```
+
+## Usage
+
+### Sign in
+
+```ts
+import { ClerkCliAuth } from '@clerk/cli-auth';
+
+const auth = new ClerkCliAuth({
+ clientId: process.env.CLERK_OAUTH_CLIENT_ID!,
+ issuer: process.env.CLERK_ISSUER!,
+ scopes: ['profile', 'email', 'openid', 'offline_access'],
+ storage: 'keychain',
+ keychainService: 'my-cli',
+});
+
+const { tokens, user } = await auth.login();
+console.log(`Signed in as ${user.email}`);
+```
+
+`login()` opens the user's browser, starts a one-shot localhost server, exchanges the authorization code for tokens, and stores the result in the OS keychain (with a `chmod 0600` file fallback). Pass `storage: 'memory'` for ephemeral sessions or `storage: 'file'` to skip the keychain entirely.
+
+### Get the current user
+
+```ts
+const me = await auth.whoami();
+if (me) {
+ console.log(`${me.name} <${me.email}>`);
+}
+```
+
+`whoami()` returns the live user info from Clerk. It auto-refreshes the access token when it's close to expiring and surfaces revocations immediately — there is no client-side cache.
+
+### Get an access token for API calls
+
+```ts
+const token = await auth.getAccessToken();
+
+const res = await fetch('https://api.example.com/me', {
+ headers: { Authorization: `Bearer ${token}` },
+});
+```
+
+`getAccessToken()` returns the cached access token, refreshing it within 30 seconds of expiry. Returns `null` if the user isn't signed in.
+
+### Sign out
+
+`logout()` revokes the refresh token at Clerk's issuer, then clears the stored credentials. Pass `{ revoke: false }` to skip the network call when you're offline or the token is already expired — local credentials are cleared either way.
+
+```ts
+await auth.logout();
+await auth.logout({ revoke: false });
+```
+
+### Accept API keys alongside OAuth
+
+CLIs that run in CI/CD, agents, or scripted environments often need to authenticate with a Clerk API key (`ak_*`) instead of going through the browser. API keys can represent a user, an organization, or a machine identity — one credential type covers all three. Configure `identityEndpoint` (and optionally `tokenEnvVar`) to enable this:
+
+```ts
+const auth = new ClerkCliAuth({
+ clientId: process.env.CLERK_OAUTH_CLIENT_ID!,
+ issuer: process.env.CLERK_ISSUER!,
+ identityEndpoint: 'https://myapp.com/api/cli/identity',
+ tokenEnvVar: 'MYAPP_API_KEY',
+});
+
+// Look up the identity for a specific token (API key or OAuth access token):
+const identity = await auth.verifyToken(process.env.MYAPP_API_KEY!);
+
+// Or let the SDK pick whichever credential is available:
+const { token, source } = await auth.resolveToken({ tokenFromArg: argv.token });
+```
+
+`resolveToken()` checks for a credential in this order:
+
+1. The `tokenFromArg` you pass in (typically from a `--token` CLI flag) → `source: 'arg'`.
+2. The environment variable named in `tokenEnvVar` → `source: 'env'`.
+3. The cached OAuth access token from `login()` → `source: 'oauth'`.
+
+`source` tells you where the credential came from — refreshable OAuth vs. a static value the user supplied — so you can branch on the trust model without introspecting the bearer's shape:
+
+```ts
+const { token, source } = await auth.resolveToken({ tokenFromArg: argv.token });
+
+// One call works for every source — the server-side handler verifies and returns the
+// identity:
+const identity = await auth.verifyToken(token);
+
+if (source === 'oauth') {
+ // ...e.g. surface "logged in as " UI; this credential is revokable via logout()
+}
+```
+
+Server-side verification of API keys and OAuth tokens happens at the `identityEndpoint` you host — see [Server-side](#server-side) for the implementation.
+
+## Server-side
+
+The `@clerk/cli-auth/server` entry point provides route handlers that verify incoming tokens against Clerk and return user info. The accepted token types are:
+
+| `accepts` value | Matching token format |
+| --------------- | -------------------------------------------------------------- |
+| `'api_key'` | `ak_*` Clerk API keys (user, org, or machine subject) |
+| `'oauth_token'` | `oat_*` opaque OAuth access tokens or `at+jwt` JWTs (RFC 9068) |
+| `'any'` | Either of the above |
+
+### Bind a Clerk client
+
+Create a single `cliAuth` instance for your application and pass it to `handle()` on each route:
+
+```ts
+// lib/clerk-cli.ts
+import { cliAuth } from '@clerk/cli-auth/server';
+import { clerkClient } from '@clerk/nextjs/server';
+
+export const auth = cliAuth({ client: clerkClient });
+```
+
+`client` accepts either a resolved Clerk Backend SDK client or a factory function. Passing `clerkClient` from `@clerk/nextjs/server` directly works — the SDK calls it lazily on the first request and caches the result. You can also pass `clientConfig` (the same options `createClerkClient` accepts) to construct a client from a specific secret key. If neither is provided, the SDK builds one from `CLERK_SECRET_KEY`.
+
+### Identity endpoint
+
+Wire the `handle()` route handler to your identity endpoint and pass it the `auth` instance:
+
+```ts
+// app/api/cli/identity/route.ts
+import { handle } from '@clerk/cli-auth/server';
+import { auth } from '@/lib/clerk-cli';
+
+export const GET = handle({
+ auth,
+ accepts: ['api_key', 'oauth_token'],
+});
+```
+
+The handler reads `Authorization: Bearer `, detects the token type, verifies it with Clerk, and returns a JSON `Identity` payload. This is what `auth.verifyToken()` calls from the CLI — once it's wired, `verifyToken()` works for API keys and OAuth access tokens alike.
+
+### Protected resource endpoints
+
+Use `auth.verifyTokenFromRequest()` to add authentication to any other route your CLI calls:
+
+```ts
+// app/api/cli/projects/route.ts
+import { NextResponse } from 'next/server';
+import { auth } from '@/lib/clerk-cli';
+
+export async function GET(request: Request) {
+ const tokenInfo = await auth.verifyTokenFromRequest(request, {
+ accepts: ['api_key', 'oauth_token'],
+ });
+
+ const projects = await getProjectsForSubject(tokenInfo.subject);
+ return NextResponse.json({ projects });
+}
+```
+
+`verifyTokenFromRequest` throws on missing, invalid, or unaccepted tokens. The returned `tokenInfo` includes `subject`, `type`, optional `scopes`, and the verified `claims` payload — use any of these to authorize the request.
+
+### Customize verification and response
+
+Override `verifyToken` or `resolveAuthInfo` on `handle()` when the defaults aren't enough. Both callbacks receive `clerk` (the bound Clerk Backend SDK client) so you don't need to re-import or re-initialize it.
+
+Add an allowlist or alternate verifier:
+
+```ts
+export const GET = handle({
+ auth,
+ accepts: 'api_key',
+ verifyToken: async ({ token, type, request, clerk }) => {
+ if (!isAllowlisted(token)) {
+ throw new Error('Token not allowlisted');
+ }
+ const apiKey = await clerk.apiKeys.verify(token);
+ return { subject: apiKey.subject, type, scopes: apiKey.scopes };
+ },
+});
+```
+
+Enrich the response with profile and org data:
+
+```ts
+export const GET = handle({
+ auth,
+ accepts: ['api_key', 'oauth_token'],
+ resolveAuthInfo: async ({ tokenInfo, clerk }) => {
+ if (tokenInfo.type === 'oauth_token') {
+ const user = await clerk.users.getUser(tokenInfo.subject);
+ return {
+ sub: user.id,
+ email: user.primaryEmailAddress?.emailAddress,
+ name: `${user.firstName ?? ''} ${user.lastName ?? ''}`.trim(),
+ picture: user.imageUrl,
+ };
+ }
+ return {
+ sub: tokenInfo.subject,
+ scopes: tokenInfo.scopes,
+ org_id: tokenInfo.claims?.org_id as string | undefined,
+ };
+ },
+});
+```
+
+## Configuration reference
+
+### Storage
+
+`storage` controls how tokens are persisted between CLI invocations:
+
+- `'keychain'` (default) — OS credential manager (macOS Keychain, Windows Credential Manager, libsecret on Linux). Falls back to a `chmod 0600` JSON file at `~/.config/clerk-cli-auth/.json` if the keychain is unavailable.
+- `'file'` — file storage only, no keychain attempt.
+- `'memory'` — in-process only. Tokens are lost when the CLI exits.
+
+Pass a custom `keychainService` to namespace the keychain entries for your CLI, and `environment` to keep credentials for different Clerk instances separate (e.g. `'production'` vs `'staging'`).
+
+### Timeouts
+
+| Option | Default | Scope |
+| ------------------ | ------------------ | ---------------------------------------------------------------------------------------------------------------- |
+| `loginTimeoutMs` | 120000 (2 minutes) | How long `login()` waits for the user to complete browser sign-in. |
+| `requestTimeoutMs` | 30000 (30 seconds) | Per-request timeout for token exchange, refresh, revocation, `/oauth/userinfo`, and `identityEndpoint` requests. |
+
+Both fire `ClerkCliAuthError('timeout', ...)` when exceeded.
+
+## How the OAuth flow works
+
+```
+1. The CLI generates a PKCE code verifier, a SHA-256 code challenge, and a CSRF state value.
+2. The CLI binds an HTTP server on 127.0.0.1 at a random port.
+3. The CLI opens the user's browser to:
+ {issuer}/oauth/authorize?client_id=...&code_challenge=...
+ &redirect_uri=http://127.0.0.1:{port}/callback
+ &code_challenge_method=S256&state=...
+4. The user signs in through Clerk's hosted UI and grants consent.
+5. Clerk redirects the browser to the localhost server with `code` and `state`.
+6. The server validates `state`, responds with a "you can close this tab" page, and shuts down.
+7. The CLI exchanges the code for tokens at {issuer}/oauth/token with the PKCE verifier.
+8. The CLI stores the tokens in the configured credential store.
+```
+
+## Support
+
+For help with `@clerk/cli-auth`, visit [our support page](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_cli_auth) or email [support@clerk.com](mailto:support@clerk.com).
+
+## Community
+
+Join the [Clerk community on Discord](https://clerk.com/discord) to chat with other developers and the Clerk team.
+
+## Contributing
+
+We're open to all community contributions! If you'd like to contribute in any way, please read [our contribution guidelines](https://github.com/clerk/javascript/blob/main/docs/CONTRIBUTING.md) and [code of conduct](https://github.com/clerk/javascript/blob/main/docs/CODE_OF_CONDUCT.md).
+
+## Security
+
+`@clerk/cli-auth` follows good practices of security, but 100% security cannot be assured.
+
+`@clerk/cli-auth` is provided **"as is"** without any **warranty**. Use at your own risk.
+
+_For more information and to report security issues, please refer to our [security documentation](https://github.com/clerk/javascript/blob/main/docs/SECURITY.md)._
+
+## License
+
+This project is licensed under the **MIT license**.
+
+See [LICENSE](https://github.com/clerk/javascript/blob/main/packages/cli-auth/LICENSE) for more information.
diff --git a/packages/cli-auth/package.json b/packages/cli-auth/package.json
new file mode 100644
index 00000000000..e523f0c9d6b
--- /dev/null
+++ b/packages/cli-auth/package.json
@@ -0,0 +1,80 @@
+{
+ "name": "@clerk/cli-auth",
+ "version": "0.0.0",
+ "description": "Clerk SDK for adding OAuth 2.0 + PKCE localhost-callback authentication to Node.js CLIs",
+ "keywords": [
+ "clerk",
+ "sdk",
+ "cli",
+ "oauth",
+ "pkce",
+ "authentication"
+ ],
+ "homepage": "https://clerk.com/",
+ "bugs": {
+ "url": "https://github.com/clerk/javascript/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/clerk/javascript.git",
+ "directory": "packages/cli-auth"
+ },
+ "license": "MIT",
+ "author": {
+ "name": "Clerk, Inc.",
+ "email": "support@clerk.com",
+ "url": "git+https://github.com/clerk/javascript.git"
+ },
+ "exports": {
+ ".": {
+ "import": {
+ "types": "./dist/index.d.mts",
+ "default": "./dist/index.mjs"
+ },
+ "require": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "./server": {
+ "import": {
+ "types": "./dist/server.d.mts",
+ "default": "./dist/server.mjs"
+ },
+ "require": {
+ "types": "./dist/server.d.ts",
+ "default": "./dist/server.js"
+ }
+ },
+ "./package.json": "./package.json"
+ },
+ "main": "./dist/index.js",
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "pnpm clean && tsup",
+ "clean": "rimraf ./dist",
+ "dev": "tsup --watch",
+ "dev:pub": "pnpm dev -- --env.publish",
+ "format": "node ../../scripts/format-package.mjs",
+ "format:check": "node ../../scripts/format-package.mjs --check",
+ "lint": "eslint src",
+ "lint:attw": "attw --pack . --profile node16",
+ "lint:publint": "publint",
+ "test": "vitest run",
+ "test:integration": "vitest run --config vitest.integration.config.mts",
+ "test:watch": "vitest watch"
+ },
+ "dependencies": {
+ "@clerk/backend": "workspace:^",
+ "@napi-rs/keyring": "^1.1.7",
+ "tslib": "catalog:repo"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/cli-auth/src/__tests__/auth-server.test.ts b/packages/cli-auth/src/__tests__/auth-server.test.ts
new file mode 100644
index 00000000000..7787129c0cd
--- /dev/null
+++ b/packages/cli-auth/src/__tests__/auth-server.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest';
+
+import { startAuthServer } from '../lib/auth-server';
+
+describe('auth server', () => {
+ it('returns the authorization code and state from /callback', async () => {
+ const server = await startAuthServer({
+ expectedState: 'state',
+ timeoutMs: 1_000,
+ });
+ const callback = server.waitForCallback();
+
+ const response = await fetch(`${server.redirectUri}?code=code-123&state=state`);
+
+ expect(response.status).toBe(200);
+ await expect(callback).resolves.toEqual({
+ code: 'code-123',
+ state: 'state',
+ });
+ server.close();
+ });
+
+ it('rejects on state mismatch', async () => {
+ const server = await startAuthServer({
+ expectedState: 'expected',
+ timeoutMs: 1_000,
+ });
+ const callback = server.waitForCallback().catch(error => error);
+
+ const response = await fetch(`${server.redirectUri}?code=code-123&state=wrong`);
+
+ expect(response.status).toBe(400);
+ await expect(callback).resolves.toMatchObject({
+ code: 'state_mismatch',
+ });
+ server.close();
+ });
+
+ it('rejects on timeout', async () => {
+ const server = await startAuthServer({
+ expectedState: 'state',
+ timeoutMs: 25,
+ });
+ const callback = server.waitForCallback().catch(error => error);
+
+ await expect(callback).resolves.toMatchObject({ code: 'timeout' });
+ server.close();
+ });
+});
diff --git a/packages/cli-auth/src/__tests__/clerk-cli-auth.test.ts b/packages/cli-auth/src/__tests__/clerk-cli-auth.test.ts
new file mode 100644
index 00000000000..f46146f5376
--- /dev/null
+++ b/packages/cli-auth/src/__tests__/clerk-cli-auth.test.ts
@@ -0,0 +1,414 @@
+import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
+import type { AddressInfo, Socket } from 'node:net';
+
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { ClerkCliAuth } from '../clerk-cli-auth';
+import type { CredentialStore } from '../types';
+
+function seededStore(entries: Record): CredentialStore {
+ const data = new Map();
+ for (const [key, value] of Object.entries(entries)) {
+ data.set(key, JSON.stringify(value));
+ }
+ return {
+ async get(key) {
+ await Promise.resolve();
+ return data.get(key) ?? null;
+ },
+ async set(key, value) {
+ await Promise.resolve();
+ data.set(key, value);
+ },
+ async delete(key) {
+ await Promise.resolve();
+ data.delete(key);
+ },
+ };
+}
+
+/** Adapt an async `createServer` handler so it returns `void`, not a Promise. */
+function asyncServerHandler(
+ fn: (req: IncomingMessage, res: ServerResponse) => Promise,
+): (req: IncomingMessage, res: ServerResponse) => void {
+ return (req, res) => {
+ void fn(req, res);
+ };
+}
+
+async function readBody(req: IncomingMessage): Promise {
+ const chunks: Buffer[] = [];
+ for await (const chunk of req) {
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ }
+ return Buffer.concat(chunks).toString('utf8');
+}
+
+function json(res: ServerResponse, statusCode: number, body: unknown): void {
+ res.writeHead(statusCode, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(body));
+}
+
+describe('ClerkCliAuth', () => {
+ const servers: ReturnType[] = [];
+
+ afterEach(async () => {
+ await Promise.all(
+ servers.splice(0).map(
+ server =>
+ new Promise(resolve => {
+ server.close(() => resolve());
+ }),
+ ),
+ );
+ });
+
+ it('runs the localhost callback flow against stubbed OAuth endpoints', async () => {
+ let tokenRequest: URLSearchParams | undefined;
+ let userinfoAuthorization: string | undefined;
+ let authorizeUrl: URL | undefined;
+
+ const issuerServer = createServer(
+ asyncServerHandler(async (req, res) => {
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
+
+ if (req.method === 'POST' && url.pathname === '/oauth/token') {
+ tokenRequest = new URLSearchParams(await readBody(req));
+ json(res, 200, {
+ access_token: 'access-token',
+ refresh_token: 'refresh-token',
+ id_token: 'id-token',
+ expires_in: 3600,
+ scope: 'profile email openid offline_access',
+ token_type: 'Bearer',
+ });
+ return;
+ }
+
+ if (req.method === 'GET' && url.pathname === '/oauth/userinfo') {
+ userinfoAuthorization = req.headers.authorization;
+ json(res, 200, {
+ sub: 'user_123',
+ email: 'test@example.com',
+ name: 'Test User',
+ });
+ return;
+ }
+
+ json(res, 404, { error: 'not_found' });
+ }),
+ );
+ servers.push(issuerServer);
+
+ await new Promise(resolve => issuerServer.listen(0, '127.0.0.1', resolve));
+ const issuerPort = (issuerServer.address() as AddressInfo).port;
+ const issuer = `http://127.0.0.1:${issuerPort}`;
+
+ const auth = new ClerkCliAuth({
+ clientId: 'client_123',
+ issuer,
+ storage: 'memory',
+ openBrowser: async url => {
+ authorizeUrl = new URL(url);
+ const redirectUri = authorizeUrl.searchParams.get('redirect_uri');
+ const state = authorizeUrl.searchParams.get('state');
+ expect(redirectUri).toBeTruthy();
+ expect(state).toBeTruthy();
+
+ const callbackUrl = new URL(redirectUri ?? '');
+ callbackUrl.searchParams.set('code', 'auth-code');
+ callbackUrl.searchParams.set('state', state ?? '');
+
+ const response = await fetch(callbackUrl);
+ expect(response.status).toBe(200);
+ },
+ });
+
+ const result = await auth.login();
+
+ expect(authorizeUrl?.pathname).toBe('/oauth/authorize');
+ expect(authorizeUrl?.searchParams.get('response_type')).toBe('code');
+ expect(authorizeUrl?.searchParams.get('client_id')).toBe('client_123');
+ expect(authorizeUrl?.searchParams.get('code_challenge_method')).toBe('S256');
+ expect(authorizeUrl?.searchParams.get('scope')).toBe('profile email openid offline_access');
+
+ expect(tokenRequest?.get('grant_type')).toBe('authorization_code');
+ expect(tokenRequest?.get('client_id')).toBe('client_123');
+ expect(tokenRequest?.get('code')).toBe('auth-code');
+ expect(tokenRequest?.get('code_verifier')).toMatch(/^[A-Za-z0-9_-]{43}$/);
+ expect(tokenRequest?.get('redirect_uri')).toBe(authorizeUrl?.searchParams.get('redirect_uri'));
+
+ expect(userinfoAuthorization).toBe('Bearer access-token');
+ expect(result.tokens.accessToken).toBe('access-token');
+ expect(result.tokens.refreshToken).toBe('refresh-token');
+ expect(result.user).toMatchObject({
+ sub: 'user_123',
+ email: 'test@example.com',
+ });
+
+ await expect(auth.getAccessToken()).resolves.toBe('access-token');
+ await expect(auth.whoami()).resolves.toMatchObject({ sub: 'user_123' });
+ });
+
+ it('cascades revocation: access token is rejected by userinfo after logout', async () => {
+ const revoked = new Set();
+ const refreshToAccess = new Map([['refresh-token', 'access-token']]);
+
+ const issuerServer = createServer(
+ asyncServerHandler(async (req, res) => {
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
+ const authHeader = req.headers.authorization ?? '';
+ const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
+
+ if (req.method === 'GET' && url.pathname === '/oauth/userinfo') {
+ if (revoked.has(bearer)) {
+ json(res, 401, { error: 'invalid_token' });
+ return;
+ }
+ json(res, 200, { sub: 'user_123' });
+ return;
+ }
+
+ if (req.method === 'POST' && url.pathname === '/oauth/token/revoke') {
+ const body = new URLSearchParams(await readBody(req));
+ const token = body.get('token') ?? '';
+ revoked.add(token);
+ const cascaded = refreshToAccess.get(token);
+ if (cascaded) {
+ revoked.add(cascaded);
+ }
+ res.writeHead(200);
+ res.end();
+ return;
+ }
+
+ json(res, 404, { error: 'not_found' });
+ }),
+ );
+ servers.push(issuerServer);
+ await new Promise(resolve => issuerServer.listen(0, '127.0.0.1', resolve));
+ const issuer = `http://127.0.0.1:${(issuerServer.address() as AddressInfo).port}`;
+
+ const auth = new ClerkCliAuth({
+ clientId: 'client_123',
+ issuer,
+ storage: seededStore({
+ tokens: {
+ accessToken: 'access-token',
+ refreshToken: 'refresh-token',
+ },
+ }),
+ });
+
+ const preLogout = await fetch(`${issuer}/oauth/userinfo`, {
+ headers: { Authorization: 'Bearer access-token' },
+ });
+ expect(preLogout.status).toBe(200);
+
+ await auth.logout();
+
+ const postLogout = await fetch(`${issuer}/oauth/userinfo`, {
+ headers: { Authorization: 'Bearer access-token' },
+ });
+ expect(postLogout.status).toBe(401);
+ });
+
+ it('does NOT cascade revocation when logout({ revoke: false }) is called', async () => {
+ const revoked = new Set();
+ const refreshToAccess = new Map([['refresh-token', 'access-token']]);
+
+ const issuerServer = createServer(
+ asyncServerHandler(async (req, res) => {
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
+ const authHeader = req.headers.authorization ?? '';
+ const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
+
+ if (req.method === 'GET' && url.pathname === '/oauth/userinfo') {
+ if (revoked.has(bearer)) {
+ json(res, 401, { error: 'invalid_token' });
+ return;
+ }
+ json(res, 200, { sub: 'user_123' });
+ return;
+ }
+
+ if (req.method === 'POST' && url.pathname === '/oauth/token/revoke') {
+ const body = new URLSearchParams(await readBody(req));
+ const token = body.get('token') ?? '';
+ revoked.add(token);
+ const cascaded = refreshToAccess.get(token);
+ if (cascaded) {
+ revoked.add(cascaded);
+ }
+ res.writeHead(200);
+ res.end();
+ return;
+ }
+
+ json(res, 404, { error: 'not_found' });
+ }),
+ );
+ servers.push(issuerServer);
+ await new Promise(resolve => issuerServer.listen(0, '127.0.0.1', resolve));
+ const issuer = `http://127.0.0.1:${(issuerServer.address() as AddressInfo).port}`;
+
+ const auth = new ClerkCliAuth({
+ clientId: 'client_123',
+ issuer,
+ storage: seededStore({
+ tokens: {
+ accessToken: 'access-token',
+ refreshToken: 'refresh-token',
+ },
+ }),
+ });
+
+ await auth.logout({ revoke: false });
+
+ const postLogout = await fetch(`${issuer}/oauth/userinfo`, {
+ headers: { Authorization: 'Bearer access-token' },
+ });
+ expect(postLogout.status).toBe(200);
+ });
+
+ it('revokes the refresh token on logout and clears local state', async () => {
+ let revokeRequest: URLSearchParams | undefined;
+
+ const issuerServer = createServer(
+ asyncServerHandler(async (req, res) => {
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
+ if (req.method === 'POST' && url.pathname === '/oauth/token/revoke') {
+ revokeRequest = new URLSearchParams(await readBody(req));
+ res.writeHead(200);
+ res.end();
+ return;
+ }
+ json(res, 404, { error: 'not_found' });
+ }),
+ );
+ servers.push(issuerServer);
+ await new Promise(resolve => issuerServer.listen(0, '127.0.0.1', resolve));
+ const issuer = `http://127.0.0.1:${(issuerServer.address() as AddressInfo).port}`;
+
+ const auth = new ClerkCliAuth({
+ clientId: 'client_123',
+ issuer,
+ storage: seededStore({
+ tokens: {
+ accessToken: 'access-token',
+ refreshToken: 'refresh-token',
+ },
+ user: { sub: 'user_123' },
+ }),
+ });
+
+ await auth.logout();
+
+ expect(revokeRequest?.get('client_id')).toBe('client_123');
+ expect(revokeRequest?.get('token')).toBe('refresh-token');
+ expect(revokeRequest?.get('token_type_hint')).toBe('refresh_token');
+ await expect(auth.getTokenSet()).resolves.toBeNull();
+ await expect(auth.whoami()).resolves.toBeNull();
+ });
+
+ it('still clears local state when the revoke endpoint fails', async () => {
+ let revokeCalls = 0;
+
+ const issuerServer = createServer((req, res) => {
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
+ if (req.method === 'POST' && url.pathname === '/oauth/token/revoke') {
+ revokeCalls += 1;
+ json(res, 500, { error: 'server_error' });
+ return;
+ }
+ json(res, 404, { error: 'not_found' });
+ });
+ servers.push(issuerServer);
+ await new Promise(resolve => issuerServer.listen(0, '127.0.0.1', resolve));
+ const issuer = `http://127.0.0.1:${(issuerServer.address() as AddressInfo).port}`;
+
+ const auth = new ClerkCliAuth({
+ clientId: 'client_123',
+ issuer,
+ storage: seededStore({
+ tokens: {
+ accessToken: 'access-token',
+ refreshToken: 'refresh-token',
+ },
+ }),
+ });
+
+ await expect(auth.logout()).resolves.toBeUndefined();
+ expect(revokeCalls).toBe(1);
+ await expect(auth.getTokenSet()).resolves.toBeNull();
+ });
+
+ it('skips the revoke call when revoke: false is passed', async () => {
+ let revokeCalls = 0;
+
+ const issuerServer = createServer((req, res) => {
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
+ if (req.method === 'POST' && url.pathname === '/oauth/token/revoke') {
+ revokeCalls += 1;
+ res.writeHead(200);
+ res.end();
+ return;
+ }
+ json(res, 404, { error: 'not_found' });
+ });
+ servers.push(issuerServer);
+ await new Promise(resolve => issuerServer.listen(0, '127.0.0.1', resolve));
+ const issuer = `http://127.0.0.1:${(issuerServer.address() as AddressInfo).port}`;
+
+ const auth = new ClerkCliAuth({
+ clientId: 'client_123',
+ issuer,
+ storage: seededStore({
+ tokens: {
+ accessToken: 'access-token',
+ refreshToken: 'refresh-token',
+ },
+ }),
+ });
+
+ await auth.logout({ revoke: false });
+ expect(revokeCalls).toBe(0);
+ await expect(auth.getTokenSet()).resolves.toBeNull();
+ });
+
+ it('aborts with ClerkCliAuthError("timeout") when /oauth/userinfo exceeds requestTimeoutMs', async () => {
+ const hangingSockets: Socket[] = [];
+
+ const issuerServer = createServer((req, _res) => {
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
+ if (url.pathname === '/oauth/userinfo') {
+ // Hold the socket open so the client times out instead of getting a response.
+ hangingSockets.push(req.socket);
+ return;
+ }
+ _res.writeHead(404).end();
+ });
+ servers.push(issuerServer);
+ await new Promise(resolve => issuerServer.listen(0, '127.0.0.1', resolve));
+ const issuer = `http://127.0.0.1:${(issuerServer.address() as AddressInfo).port}`;
+
+ const auth = new ClerkCliAuth({
+ clientId: 'client_123',
+ issuer,
+ requestTimeoutMs: 50,
+ storage: seededStore({
+ tokens: { accessToken: 'access-token' },
+ }),
+ });
+
+ await expect(auth.whoami()).rejects.toMatchObject({
+ name: 'ClerkCliAuthError',
+ code: 'timeout',
+ });
+
+ // Drop the held sockets so the server can close cleanly in afterEach.
+ for (const socket of hangingSockets) {
+ socket.destroy();
+ }
+ });
+});
diff --git a/packages/cli-auth/src/__tests__/credential-store.test.ts b/packages/cli-auth/src/__tests__/credential-store.test.ts
new file mode 100644
index 00000000000..9ce6effdd20
--- /dev/null
+++ b/packages/cli-auth/src/__tests__/credential-store.test.ts
@@ -0,0 +1,57 @@
+import { mkdtemp, rm, stat } from 'node:fs/promises';
+import { join } from 'node:path';
+
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { createCredentialStore } from '../lib/credential-store';
+
+const tempDirs: string[] = [];
+
+async function makeTempDir(): Promise {
+ const dir = await mkdtemp(join(process.cwd(), '.tmp-credential-store-'));
+ tempDirs.push(dir);
+ return dir;
+}
+
+afterEach(async () => {
+ await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })));
+});
+
+describe('credential store', () => {
+ it('round-trips values in memory', async () => {
+ const store = createCredentialStore('memory');
+
+ await store.set('tokens', 'value');
+ await expect(store.get('tokens')).resolves.toBe('value');
+
+ await store.delete('tokens');
+ await expect(store.get('tokens')).resolves.toBeNull();
+ });
+
+ it('creates a chmod 600 JSON file', async () => {
+ const dir = await makeTempDir();
+ const filePath = join(dir, 'credentials.json');
+ const store = createCredentialStore('file', { filePath });
+
+ await store.set('tokens', 'secret');
+
+ await expect(store.get('tokens')).resolves.toBe('secret');
+ const mode = (await stat(filePath)).mode & 0o777;
+ expect(mode).toBe(0o600);
+ });
+
+ it('serializes concurrent file writes and reads', async () => {
+ const dir = await makeTempDir();
+ const filePath = join(dir, 'credentials.json');
+ const store = createCredentialStore('file', { filePath });
+ const entries: Array<[string, string]> = Array.from({ length: 20 }, (_, index) => [
+ `key-${index}`,
+ `value-${index}`,
+ ]);
+
+ await Promise.all(entries.map(([key, value]) => store.set(key, value)));
+
+ const values = await Promise.all(entries.map(([key]) => store.get(key)));
+ expect(values).toEqual(entries.map(([, value]) => value));
+ });
+});
diff --git a/packages/cli-auth/src/__tests__/integration/server.test.ts b/packages/cli-auth/src/__tests__/integration/server.test.ts
new file mode 100644
index 00000000000..f6ab8aabca3
--- /dev/null
+++ b/packages/cli-auth/src/__tests__/integration/server.test.ts
@@ -0,0 +1,163 @@
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import { handle } from '../../server';
+import type { Identity } from '../../types';
+import {
+ bearerRequest,
+ type IntegrationFixtures,
+ provisionFixtures,
+ skipWhenNoSecret,
+ teardownFixtures,
+} from './setup';
+
+describe.skipIf(skipWhenNoSecret)('cli-auth server integration', () => {
+ let fx: IntegrationFixtures;
+
+ beforeAll(async () => {
+ fx = await provisionFixtures();
+ });
+
+ afterAll(async () => {
+ await teardownFixtures(fx);
+ });
+
+ describe('auth.verifyToken — API key per subject kind', () => {
+ it('verifies a user-scoped API key (subject = user_*)', async () => {
+ const info = await fx.auth.verifyToken(fx.userApiKey.secret!);
+ expect(info.type).toBe('api_key');
+ expect(info.subject).toBe(fx.user.id);
+ expect(info.subject).toMatch(/^user_/);
+ expect(info.scopes).toEqual(expect.arrayContaining(['cli:read']));
+ });
+
+ it('verifies an org-scoped API key (subject = org_*)', async () => {
+ const info = await fx.auth.verifyToken(fx.orgApiKey.secret!);
+ expect(info.type).toBe('api_key');
+ expect(info.subject).toBe(fx.org.id);
+ expect(info.subject).toMatch(/^org_/);
+ });
+
+ it('verifies a machine-scoped API key (subject = mch_*)', async () => {
+ const info = await fx.auth.verifyToken(fx.machineApiKey.secret!);
+ expect(info.type).toBe('api_key');
+ expect(info.subject).toBe(fx.machine.id);
+ expect(info.subject).toMatch(/^mch_/);
+ });
+ });
+
+ describe('auth.verifyTokenFromRequest', () => {
+ it('reads the Bearer header and verifies', async () => {
+ const req = bearerRequest(fx.userApiKey.secret!);
+ const info = await fx.auth.verifyTokenFromRequest(req, { accepts: 'api_key' });
+ expect(info.subject).toBe(fx.user.id);
+ expect(info.type).toBe('api_key');
+ });
+
+ it('throws on a missing Authorization header', async () => {
+ const req = new Request('http://test.local/cli');
+ await expect(fx.auth.verifyTokenFromRequest(req)).rejects.toThrow(/Authorization/);
+ });
+ });
+
+ describe('rejects credentials that are not API keys or OAuth tokens', () => {
+ it('rejects a raw unrecognized credential', async () => {
+ await expect(fx.auth.verifyToken('not-a-real-token')).rejects.toThrow();
+ });
+
+ it('rejects an unknown prefix', async () => {
+ await expect(fx.auth.verifyToken('xyz_unknown_prefix_token')).rejects.toThrow();
+ });
+
+ it('rejects an M2M-prefixed token (mt_*) — m2m is not a CLI credential', async () => {
+ // Fake mt_ value; we don't need a real M2M token to prove the gate. BAPI rejects it,
+ // and our code surfaces the rejection.
+ await expect(fx.auth.verifyToken('mt_not_a_real_m2m_token_value')).rejects.toThrow();
+ });
+ });
+
+ describe('auth.resolveAuthInfo (default)', () => {
+ it('projects subject + claims into Identity', async () => {
+ const info = await fx.auth.verifyToken(fx.userApiKey.secret!);
+ const identity = await fx.auth.resolveAuthInfo({
+ tokenInfo: info,
+ request: bearerRequest(fx.userApiKey.secret!),
+ });
+ expect(identity.sub).toBe(fx.user.id);
+ });
+ });
+
+ describe('handle() end-to-end', () => {
+ it('200 with Identity body for a user-scoped API key', async () => {
+ const route = handle({ auth: fx.auth, accepts: 'api_key' });
+ const res = await route(bearerRequest(fx.userApiKey.secret!));
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as Identity;
+ expect(body.sub).toBe(fx.user.id);
+ });
+
+ it('200 with Identity body for an org-scoped API key', async () => {
+ const route = handle({ auth: fx.auth, accepts: 'api_key' });
+ const res = await route(bearerRequest(fx.orgApiKey.secret!));
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as Identity;
+ expect(body.sub).toBe(fx.org.id);
+ });
+
+ it('200 with Identity body for a machine-scoped API key', async () => {
+ const route = handle({ auth: fx.auth, accepts: 'api_key' });
+ const res = await route(bearerRequest(fx.machineApiKey.secret!));
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as Identity;
+ expect(body.sub).toBe(fx.machine.id);
+ });
+
+ it('401 when no Authorization header is present', async () => {
+ const route = handle({ auth: fx.auth, accepts: 'any' });
+ const res = await route(new Request('http://test.local/cli'));
+ expect(res.status).toBe(401);
+ });
+
+ it('401 for unsupported credential types', async () => {
+ const route = handle({ auth: fx.auth, accepts: 'any' });
+ const res = await route(bearerRequest('not-a-real-token'));
+ expect(res.status).toBe(401);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toBe('not_authenticated');
+ });
+
+ it('honors a custom resolveAuthInfo override', async () => {
+ const route = handle({
+ auth: fx.auth,
+ accepts: 'api_key',
+ resolveAuthInfo: ({ tokenInfo }) => ({
+ sub: tokenInfo.subject,
+ custom_field: 'enriched',
+ }),
+ });
+ const res = await route(bearerRequest(fx.userApiKey.secret!));
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as Identity & { custom_field?: string };
+ expect(body.sub).toBe(fx.user.id);
+ expect(body.custom_field).toBe('enriched');
+ });
+
+ it('honors a custom verifyToken override', async () => {
+ const route = handle({
+ auth: fx.auth,
+ accepts: 'api_key',
+ verifyToken: async ({ token, type, clerk }) => {
+ // Replace the verifier with our own — still using the bound `clerk` to keep this
+ // real. Asserts the override path correctly receives token + auto-detected type.
+ expect(type).toBe('api_key');
+ const verified = await clerk.apiKeys.verify(token);
+ return { subject: verified.subject, type, scopes: verified.scopes };
+ },
+ });
+
+ const res = await route(bearerRequest(fx.userApiKey.secret!));
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as Identity;
+ expect(body.sub).toBe(fx.user.id);
+ });
+ });
+});
diff --git a/packages/cli-auth/src/__tests__/integration/setup.ts b/packages/cli-auth/src/__tests__/integration/setup.ts
new file mode 100644
index 00000000000..2472d10dc0d
--- /dev/null
+++ b/packages/cli-auth/src/__tests__/integration/setup.ts
@@ -0,0 +1,157 @@
+import {
+ type APIKey,
+ type ClerkClient,
+ createClerkClient,
+ type Machine,
+ type Organization,
+ type User,
+} from '@clerk/backend';
+
+import { cliAuth, type CliAuthInstance } from '../../server';
+
+export const INTEGRATION_SECRET_KEY = process.env.CLERK_SECRET_KEY;
+const INTEGRATION_PUBLISHABLE_KEY = process.env.CLERK_PUBLISHABLE_KEY;
+
+/** Skip the suite when keys aren't configured — CI without them is a no-op. */
+export const skipWhenNoSecret = !INTEGRATION_SECRET_KEY || !INTEGRATION_PUBLISHABLE_KEY;
+
+/**
+ * Module-scope Clerk client + cliAuth instance. `createClerkClient` doesn't validate
+ * keys at construction (only when methods are called), so this is safe to build even
+ * when the suite is skipped — the factory helpers below just never get called.
+ *
+ * `clerk.authenticateRequest` requires *both* keys (publishable for JWKs and audience
+ * resolution, secret for BAPI calls), which is why we set them here rather than relying
+ * on the env at call-time.
+ */
+const clerk: ClerkClient = createClerkClient({
+ secretKey: INTEGRATION_SECRET_KEY ?? '',
+ publishableKey: INTEGRATION_PUBLISHABLE_KEY ?? '',
+});
+const auth: CliAuthInstance = cliAuth({ client: clerk });
+
+export interface IntegrationFixtures {
+ clerk: ClerkClient;
+ auth: CliAuthInstance;
+ /** Identities used as API key subjects. */
+ user: User;
+ org: Organization;
+ machine: Machine;
+ /** API keys, one per supported subject kind. */
+ userApiKey: APIKey;
+ orgApiKey: APIKey;
+ machineApiKey: APIKey;
+}
+
+// ---------------------------------------------------------------------------
+// Factory helpers — small, named primitives. Each call generates its own
+// unique slug so reruns in parallel CI shards don't collide on names. Inputs
+// other than slug are explicit.
+// ---------------------------------------------------------------------------
+
+function uniqueSlug(): string {
+ return `${Date.now()}${Math.random().toString(36).slice(2, 8)}`;
+}
+
+async function createTestUser(): Promise {
+ const slug = uniqueSlug();
+ try {
+ return await clerk.users.createUser({
+ username: `cliauthint${slug}`,
+ password: `Test_${slug}_${Date.now()}`,
+ skipPasswordChecks: true,
+ });
+ } catch (err) {
+ // Surface BAPI validation errors so failures are actionable, not opaque.
+ const detail = (err as { errors?: unknown }).errors;
+ throw new Error(
+ `clerk.users.createUser failed: ${(err as Error).message}${detail ? `\n${JSON.stringify(detail, null, 2)}` : ''}`,
+ );
+ }
+}
+
+async function createTestOrg(createdBy: string): Promise {
+ return clerk.organizations.createOrganization({
+ name: `cli-auth integration ${uniqueSlug()}`,
+ createdBy,
+ });
+}
+
+async function createTestMachine(): Promise {
+ return clerk.machines.create({ name: `cli-auth integration ${uniqueSlug()}` });
+}
+
+async function createTestApiKey(opts: { subject: string; scopes?: string[] }): Promise {
+ return clerk.apiKeys.create({
+ name: `cli-auth integration ${uniqueSlug()}`,
+ subject: opts.subject,
+ scopes: opts.scopes ?? [],
+ });
+}
+
+// ---------------------------------------------------------------------------
+
+/**
+ * Provision the throwaway Clerk resources we verify against: a user, an org owned by that
+ * user, and a machine — plus one API key per subject kind (user/org/machine). Shared
+ * across the suite via `beforeAll` and torn down in `afterAll`.
+ */
+export async function provisionFixtures(): Promise {
+ if (!INTEGRATION_SECRET_KEY) {
+ throw new Error('CLERK_SECRET_KEY is required for integration tests');
+ }
+
+ // Wave 1: user and machine are independent; provision in parallel.
+ const [user, machine] = await Promise.all([createTestUser(), createTestMachine()]);
+
+ // Wave 2: org needs an owner (createdBy = user.id).
+ const org = await createTestOrg(user.id);
+
+ // Wave 3: one API key per subject kind, all independent.
+ const [userApiKey, orgApiKey, machineApiKey] = await Promise.all([
+ createTestApiKey({ subject: user.id, scopes: ['cli:read'] }),
+ createTestApiKey({ subject: org.id, scopes: ['cli:read'] }),
+ createTestApiKey({ subject: machine.id, scopes: ['cli:read'] }),
+ ]);
+
+ return { clerk, auth, user, org, machine, userApiKey, orgApiKey, machineApiKey };
+}
+
+/** Tear down everything `provisionFixtures` created. Surfaces any cleanup failures. */
+export async function teardownFixtures(fixtures: IntegrationFixtures | undefined): Promise {
+ if (!fixtures) {
+ return;
+ }
+ const { user, org, machine, userApiKey, orgApiKey, machineApiKey } = fixtures;
+
+ const results = await Promise.allSettled([
+ clerk.apiKeys.delete(userApiKey.id),
+ clerk.apiKeys.delete(orgApiKey.id),
+ clerk.apiKeys.delete(machineApiKey.id),
+ clerk.organizations.deleteOrganization(org.id),
+ clerk.machines.delete(machine.id),
+ clerk.users.deleteUser(user.id),
+ ]);
+
+ const labels = [
+ 'userApiKey delete',
+ 'orgApiKey delete',
+ 'machineApiKey delete',
+ 'org delete',
+ 'machine delete',
+ 'user delete',
+ ];
+ const failures = results.map((r, i) => ({ r, label: labels[i] })).filter(({ r }) => r.status === 'rejected');
+ if (failures.length) {
+ for (const { r, label } of failures) {
+ const reason = (r as PromiseRejectedResult).reason;
+ console.error(`[cli-auth integration] ${label} failed:`, reason);
+ }
+ throw new Error(`Integration teardown failed: ${failures.map(f => f.label).join(', ')}`);
+ }
+}
+
+/** Build a Web `Request` with `Authorization: Bearer ` for handle() tests. */
+export function bearerRequest(token: string, url = 'http://test.local/cli'): Request {
+ return new Request(url, { headers: { Authorization: `Bearer ${token}` } });
+}
diff --git a/packages/cli-auth/src/__tests__/pkce.test.ts b/packages/cli-auth/src/__tests__/pkce.test.ts
new file mode 100644
index 00000000000..8ff2cd0f1b5
--- /dev/null
+++ b/packages/cli-auth/src/__tests__/pkce.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest';
+
+import { generateCodeChallenge, generateCodeVerifier, generateState } from '../lib/pkce';
+
+describe('pkce', () => {
+ it('generates a 43 character base64url verifier', () => {
+ const verifier = generateCodeVerifier();
+
+ expect(verifier).toHaveLength(43);
+ expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/);
+ });
+
+ it('generates the RFC 7636 S256 challenge vector', async () => {
+ await expect(generateCodeChallenge('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk')).resolves.toBe(
+ 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
+ );
+ });
+
+ it('generates unique state values', () => {
+ const states = new Set(Array.from({ length: 100 }, () => generateState()));
+
+ expect(states.size).toBe(100);
+ for (const state of states) {
+ expect(state).toMatch(/^[A-Za-z0-9_-]+$/);
+ }
+ });
+});
diff --git a/packages/cli-auth/src/__tests__/server-oauth.test.ts b/packages/cli-auth/src/__tests__/server-oauth.test.ts
new file mode 100644
index 00000000000..d40ff05ea99
--- /dev/null
+++ b/packages/cli-auth/src/__tests__/server-oauth.test.ts
@@ -0,0 +1,87 @@
+import type { ClerkClient } from '@clerk/backend';
+import { describe, expect, it, vi } from 'vitest';
+
+import { cliAuth } from '../server/cli-auth';
+import { handle } from '../server/handle';
+
+function bearer(token: string) {
+ return new Request('http://test.local/cli', { headers: { Authorization: `Bearer ${token}` } });
+}
+
+/**
+ * Build a fake `ClerkClient` whose `authenticateRequest` returns a stub `RequestState` —
+ * matching the discriminated shape `verify-token.ts` consumes. We only need to model the
+ * surface the cli-auth verifier touches: `isAuthenticated`, `toAuth()`, `reason`.
+ */
+function fakeClerkClient(
+ authResult:
+ | { isAuthenticated: true; auth: { subject: string; scopes: string[]; tokenType: string; claims?: unknown } }
+ | { isAuthenticated: false; reason?: string },
+): ClerkClient {
+ return {
+ authenticateRequest: vi.fn(async () => {
+ await Promise.resolve();
+ if (authResult.isAuthenticated) {
+ return {
+ isAuthenticated: true,
+ toAuth: () => authResult.auth,
+ };
+ }
+ return {
+ isAuthenticated: false,
+ reason: authResult.reason ?? 'rejected',
+ };
+ }),
+ } as unknown as ClerkClient;
+}
+
+const FAKE_OAUTH_TOKEN = 'oat_fake_test_token_value_here';
+const FAKE_SUBJECT = 'user_2abcDEFghiJKLmnoPQRstuVWXyz';
+
+describe('cli-auth server: oauth_token path (mocked)', () => {
+ it('verifyToken returns TokenInfo with type=oauth_token', async () => {
+ const client = fakeClerkClient({
+ isAuthenticated: true,
+ auth: { subject: FAKE_SUBJECT, scopes: ['profile', 'email'], tokenType: 'oauth_token' },
+ });
+ const auth = cliAuth({ client });
+
+ const info = await auth.verifyToken(FAKE_OAUTH_TOKEN);
+ expect(info.type).toBe('oauth_token');
+ expect(info.subject).toBe(FAKE_SUBJECT);
+ expect(info.scopes).toEqual(['profile', 'email']);
+ });
+
+ it('handle() returns 200 + Identity body for a verified OAuth token', async () => {
+ const client = fakeClerkClient({
+ isAuthenticated: true,
+ auth: { subject: FAKE_SUBJECT, scopes: ['profile'], tokenType: 'oauth_token' },
+ });
+ const auth = cliAuth({ client });
+
+ const route = handle({ auth, accepts: 'oauth_token' });
+ const res = await route(bearer(FAKE_OAUTH_TOKEN));
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { sub: string };
+ expect(body.sub).toBe(FAKE_SUBJECT);
+ });
+
+ it('surfaces clerk.authenticateRequest rejection as not_authenticated', async () => {
+ const client = fakeClerkClient({ isAuthenticated: false, reason: 'OAuth token expired' });
+ const auth = cliAuth({ client });
+
+ await expect(auth.verifyToken(FAKE_OAUTH_TOKEN)).rejects.toThrow(/OAuth token expired/);
+ });
+
+ it('preserves api_key claims field when present', async () => {
+ const client = fakeClerkClient({
+ isAuthenticated: true,
+ auth: { subject: 'user_abc', scopes: ['cli:read'], tokenType: 'api_key', claims: { tenant_id: 'org_xyz' } },
+ });
+ const auth = cliAuth({ client });
+
+ const info = await auth.verifyToken('ak_fake_value');
+ expect(info.type).toBe('api_key');
+ expect(info.claims).toEqual({ tenant_id: 'org_xyz' });
+ });
+});
diff --git a/packages/cli-auth/src/clerk-cli-auth.ts b/packages/cli-auth/src/clerk-cli-auth.ts
new file mode 100644
index 00000000000..7480e6bb87d
--- /dev/null
+++ b/packages/cli-auth/src/clerk-cli-auth.ts
@@ -0,0 +1,288 @@
+import { spawn } from 'node:child_process';
+
+import { ClerkCliAuthError } from './errors';
+import { startAuthServer } from './lib/auth-server';
+import { createCredentialStore } from './lib/credential-store';
+import { generateCodeChallenge, generateCodeVerifier, generateState } from './lib/pkce';
+import { exchangeCodeForTokens, fetchIdentity, refreshAccessToken, revokeToken } from './lib/token-exchange';
+import { verifyToken as verifyTokenRequest } from './lib/verify-token';
+import type {
+ ClerkCliAuthConfig,
+ CredentialStore,
+ Identity,
+ LoginResult,
+ OAuthScope,
+ TokenSet,
+ TokenSource,
+ UserIdentity,
+} from './types';
+
+const DEFAULT_SCOPES: OAuthScope[] = ['profile', 'email', 'openid', 'offline_access'];
+
+function normalizeIssuer(issuer: string): string {
+ const normalized = issuer.trim().replace(/\/+$/, '');
+ try {
+ const url = new URL(normalized);
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
+ throw new Error('issuer must use http or https');
+ }
+ return normalized;
+ } catch (error) {
+ throw new ClerkCliAuthError('config', `issuer must be a valid URL: ${(error as Error).message}`);
+ }
+}
+
+function storageError(operation: string, error: unknown): ClerkCliAuthError {
+ if (error instanceof ClerkCliAuthError) {
+ return error;
+ }
+ const detail = error instanceof Error ? error.message : String(error);
+ return new ClerkCliAuthError('storage', `Failed to ${operation}: ${detail}`);
+}
+
+async function openBrowserFallback(url: string): Promise {
+ console.log(`Open this URL to sign in:\n${url}`);
+
+ const platform = process.platform;
+ const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
+ const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
+
+ await new Promise(resolve => {
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' });
+ child.once('error', () => resolve());
+ child.unref();
+ resolve();
+ });
+}
+
+export class ClerkCliAuth {
+ private readonly config: Required<
+ Omit
+ > & {
+ storage: CredentialStore;
+ openBrowser?: (url: string) => Promise;
+ identityEndpoint?: string;
+ tokenEnvVar?: string;
+ };
+
+ constructor(config: ClerkCliAuthConfig) {
+ if (!config.clientId) {
+ throw new ClerkCliAuthError('config', 'clientId is required');
+ }
+ if (!config.issuer) {
+ throw new ClerkCliAuthError('config', 'issuer is required');
+ }
+
+ const environment = config.environment ?? 'default';
+ const storage =
+ typeof config.storage === 'object' && config.storage !== null
+ ? config.storage
+ : createCredentialStore(config.storage ?? 'keychain', {
+ environment,
+ keychainService: config.keychainService,
+ });
+
+ this.config = {
+ clientId: config.clientId,
+ issuer: normalizeIssuer(config.issuer),
+ scopes: config.scopes ?? DEFAULT_SCOPES,
+ storage,
+ keychainService: config.keychainService ?? 'clerk-cli-auth',
+ environment,
+ callbackPort: config.callbackPort ?? 0,
+ loginTimeoutMs: config.loginTimeoutMs ?? 120_000,
+ requestTimeoutMs: config.requestTimeoutMs ?? 30_000,
+ openBrowser: config.openBrowser,
+ identityEndpoint: config.identityEndpoint,
+ tokenEnvVar: config.tokenEnvVar,
+ };
+ }
+
+ async login(): Promise {
+ const codeVerifier = generateCodeVerifier();
+ const codeChallenge = await generateCodeChallenge(codeVerifier);
+ const state = generateState();
+
+ const server = await startAuthServer({
+ expectedState: state,
+ port: this.config.callbackPort,
+ timeoutMs: this.config.loginTimeoutMs,
+ });
+
+ try {
+ const authorizeUrl = new URL(`${this.config.issuer}/oauth/authorize`);
+ authorizeUrl.searchParams.set('response_type', 'code');
+ authorizeUrl.searchParams.set('client_id', this.config.clientId);
+ authorizeUrl.searchParams.set('redirect_uri', server.redirectUri);
+ authorizeUrl.searchParams.set('scope', this.config.scopes.join(' '));
+ authorizeUrl.searchParams.set('state', state);
+ authorizeUrl.searchParams.set('code_challenge', codeChallenge);
+ authorizeUrl.searchParams.set('code_challenge_method', 'S256');
+
+ try {
+ await (this.config.openBrowser ?? openBrowserFallback)(authorizeUrl.toString());
+ } catch (error) {
+ throw new ClerkCliAuthError('config', `Failed to open authorization URL: ${(error as Error).message}`);
+ }
+
+ const { code } = await server.waitForCallback();
+ const tokens = await exchangeCodeForTokens({
+ issuer: this.config.issuer,
+ clientId: this.config.clientId,
+ code,
+ codeVerifier,
+ redirectUri: server.redirectUri,
+ timeoutMs: this.config.requestTimeoutMs,
+ });
+
+ await this.setJson('tokens', tokens);
+ const user = await fetchIdentity({
+ issuer: this.config.issuer,
+ accessToken: tokens.accessToken,
+ timeoutMs: this.config.requestTimeoutMs,
+ });
+
+ return { tokens, user };
+ } finally {
+ server.close();
+ }
+ }
+
+ async getAccessToken(): Promise {
+ const tokens = await this.getTokenSet();
+ if (!tokens) {
+ return null;
+ }
+
+ const expiresAt = tokens.expiresAt ?? Number.POSITIVE_INFINITY;
+ if (expiresAt >= Date.now() + 30_000) {
+ return tokens.accessToken;
+ }
+
+ if (!tokens.refreshToken) {
+ return null;
+ }
+
+ const refreshed = await refreshAccessToken({
+ issuer: this.config.issuer,
+ clientId: this.config.clientId,
+ refreshToken: tokens.refreshToken,
+ scopes: this.config.scopes,
+ timeoutMs: this.config.requestTimeoutMs,
+ });
+ const nextTokens = {
+ ...tokens,
+ ...refreshed,
+ refreshToken: refreshed.refreshToken ?? tokens.refreshToken,
+ };
+ await this.setJson('tokens', nextTokens);
+ return nextTokens.accessToken;
+ }
+
+ async whoami(): Promise {
+ const accessToken = await this.getAccessToken();
+ if (!accessToken) {
+ return null;
+ }
+
+ return fetchIdentity({
+ issuer: this.config.issuer,
+ accessToken,
+ timeoutMs: this.config.requestTimeoutMs,
+ });
+ }
+
+ async verifyToken(token: string): Promise {
+ if (!this.config.identityEndpoint) {
+ throw new ClerkCliAuthError('config', 'identityEndpoint is not configured.');
+ }
+ return verifyTokenRequest({
+ endpoint: this.config.identityEndpoint,
+ token,
+ timeoutMs: this.config.requestTimeoutMs,
+ });
+ }
+
+ /**
+ * Pick the credential the CLI should send. Returns the token plus where it came from,
+ * which lets callers branch on the *trust model* (refreshable OAuth vs. user-supplied
+ * env/arg) instead of having to introspect the bearer's shape.
+ *
+ * Resolution order: `tokenFromArg` → `tokenEnvVar` env var → cached OAuth session.
+ */
+ async resolveToken(opts: { tokenFromArg?: string } = {}): Promise<{ token: string; source: TokenSource }> {
+ if (opts.tokenFromArg) {
+ return { token: opts.tokenFromArg, source: 'arg' };
+ }
+ if (this.config.tokenEnvVar) {
+ const fromEnv = process.env[this.config.tokenEnvVar];
+ if (fromEnv) {
+ return { token: fromEnv, source: 'env' };
+ }
+ }
+ const accessToken = await this.getAccessToken();
+ if (accessToken) {
+ return { token: accessToken, source: 'oauth' };
+ }
+
+ const envHint = this.config.tokenEnvVar ? ` or set $${this.config.tokenEnvVar}` : '';
+ throw new ClerkCliAuthError('not_authenticated', `Not logged in. Run \`auth login\`${envHint}.`);
+ }
+
+ async logout(options: { revoke?: boolean } = {}): Promise {
+ const shouldRevoke = options.revoke ?? true;
+ if (shouldRevoke) {
+ const tokens = await this.getTokenSet().catch(() => null);
+ const token = tokens?.refreshToken ?? tokens?.accessToken;
+ if (tokens && token) {
+ try {
+ await revokeToken({
+ issuer: this.config.issuer,
+ clientId: this.config.clientId,
+ token,
+ tokenTypeHint: tokens.refreshToken ? 'refresh_token' : 'access_token',
+ timeoutMs: this.config.requestTimeoutMs,
+ });
+ } catch {
+ // Revoke is best-effort — local cleanup proceeds regardless.
+ }
+ }
+ }
+
+ try {
+ await this.config.storage.delete('tokens');
+ } catch (error) {
+ throw storageError('clear stored credentials', error);
+ }
+ }
+
+ async getTokenSet(): Promise {
+ return this.getJson('tokens');
+ }
+
+ private async getJson(key: string): Promise {
+ let raw: string | null;
+ try {
+ raw = await this.config.storage.get(key);
+ } catch (error) {
+ throw storageError(`read ${key}`, error);
+ }
+ if (!raw) {
+ return null;
+ }
+
+ try {
+ return JSON.parse(raw) as T;
+ } catch (error) {
+ throw storageError(`parse stored ${key}`, error);
+ }
+ }
+
+ private async setJson(key: string, value: unknown): Promise {
+ try {
+ await this.config.storage.set(key, JSON.stringify(value));
+ } catch (error) {
+ throw storageError(`write ${key}`, error);
+ }
+ }
+}
diff --git a/packages/cli-auth/src/errors.ts b/packages/cli-auth/src/errors.ts
new file mode 100644
index 00000000000..096d0d50f91
--- /dev/null
+++ b/packages/cli-auth/src/errors.ts
@@ -0,0 +1,38 @@
+export const EXIT_CODE = {
+ SUCCESS: 0,
+ GENERAL: 1,
+ USAGE: 2,
+ SIGINT: 130,
+} as const;
+
+export type ExitCode = (typeof EXIT_CODE)[keyof typeof EXIT_CODE];
+
+export type ErrorCode =
+ | 'not_authenticated'
+ | 'config'
+ | 'storage'
+ | 'token_exchange'
+ | 'userinfo'
+ | 'revoke'
+ | 'timeout'
+ | 'verify_api_key';
+
+export interface ClerkCliAuthErrorOptions {
+ exitCode?: ExitCode;
+ cause?: unknown;
+}
+
+export class ClerkCliAuthError extends Error {
+ code: ErrorCode | (string & {});
+ exitCode: ExitCode;
+ constructor(code: ErrorCode | (string & {}), message: string, options?: ClerkCliAuthErrorOptions) {
+ super(message, options?.cause ? { cause: options.cause } : undefined);
+ this.name = 'ClerkCliAuthError';
+ this.code = code;
+ this.exitCode = options?.exitCode ?? EXIT_CODE.GENERAL;
+ }
+}
+
+export const isClerkCliAuthError = (error: unknown): error is ClerkCliAuthError => {
+ return error instanceof ClerkCliAuthError;
+};
diff --git a/packages/cli-auth/src/index.ts b/packages/cli-auth/src/index.ts
new file mode 100644
index 00000000000..a54599f1d8b
--- /dev/null
+++ b/packages/cli-auth/src/index.ts
@@ -0,0 +1,17 @@
+/**
+ * Public entry point for @clerk/cli-auth.
+ *
+ * The main class is `ClerkCliAuth` — instantiate with config, then:
+ * - await auth.login() → opens browser, starts localhost callback, exchanges code, stores tokens
+ * - await auth.getAccessToken() → returns cached token, refreshes if expired
+ * - await auth.whoami() → calls /oauth/userinfo with cached token
+ * - await auth.logout() → clears stored tokens (and revokes at issuer by default)
+ *
+ * Implementation lives in ./clerk-cli-auth.ts and ./lib/*.
+ */
+
+export { ClerkCliAuth } from './clerk-cli-auth';
+export * from './types';
+export * from './errors';
+export { fetchIdentity, revokeToken } from './lib/token-exchange';
+export { verifyToken } from './lib/verify-token';
diff --git a/packages/cli-auth/src/lib/auth-server.ts b/packages/cli-auth/src/lib/auth-server.ts
new file mode 100644
index 00000000000..1bdd79e3c9b
--- /dev/null
+++ b/packages/cli-auth/src/lib/auth-server.ts
@@ -0,0 +1,170 @@
+import { createServer, type Server } from 'node:http';
+import type { AddressInfo } from 'node:net';
+
+import { ClerkCliAuthError } from '../errors';
+
+export interface AuthServerOptions {
+ expectedState: string;
+ port?: number;
+ timeoutMs?: number;
+ successHtml?: string;
+ errorHtml?: string;
+}
+
+export interface AuthServerHandle {
+ port: number;
+ redirectUri: string;
+ waitForCallback(): Promise<{ code: string; state: string }>;
+ close(): void;
+}
+
+const DEFAULT_SUCCESS_HTML = `
+
+Authentication complete
+Authentication complete You can close this tab and return to your terminal.
+`;
+
+const DEFAULT_ERROR_HTML = `
+
+Authentication failed
+Authentication failed You can close this tab and return to your terminal.
+`;
+
+function oauthCallbackError(code: string, message: string): ClerkCliAuthError {
+ return new ClerkCliAuthError(code, message);
+}
+
+export function startAuthServer(options: AuthServerOptions): Promise {
+ const {
+ expectedState,
+ port = 0,
+ timeoutMs = 120_000,
+ successHtml = DEFAULT_SUCCESS_HTML,
+ errorHtml = DEFAULT_ERROR_HTML,
+ } = options;
+
+ let timeout: NodeJS.Timeout | undefined;
+ let settled = false;
+ let closed = false;
+ let resolveCallback!: (value: { code: string; state: string }) => void;
+ let rejectCallback!: (reason: ClerkCliAuthError) => void;
+
+ const callbackPromise = new Promise<{ code: string; state: string }>((resolve, reject) => {
+ resolveCallback = resolve;
+ rejectCallback = reject;
+ });
+
+ const closeListening = (server: Server) => {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ server.close();
+ };
+
+ const server = createServer((req, res) => {
+ if (settled) {
+ res.writeHead(410, { 'Content-Type': 'text/plain; charset=utf-8' });
+ res.end('Authentication callback already handled.');
+ return;
+ }
+
+ if (req.method !== 'GET' || !req.url) {
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
+ res.end('Not found');
+ return;
+ }
+
+ const url = new URL(req.url, 'http://127.0.0.1');
+ if (url.pathname !== '/callback') {
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
+ res.end('Clerk CLI auth server is waiting for /callback.');
+ return;
+ }
+
+ const code = url.searchParams.get('code');
+ const state = url.searchParams.get('state');
+ const error = url.searchParams.get('error');
+ const errorDescription = url.searchParams.get('error_description') ?? error;
+
+ const settle = (statusCode: number, html: string, errorToReject?: ClerkCliAuthError) => {
+ settled = true;
+ if (timeout) {
+ clearTimeout(timeout);
+ }
+ res.writeHead(statusCode, {
+ 'Content-Type': 'text/html; charset=utf-8',
+ });
+ res.end(html, () => {
+ if (errorToReject) {
+ rejectCallback(errorToReject);
+ } else if (code && state) {
+ resolveCallback({ code, state });
+ }
+ closeListening(server);
+ });
+ };
+
+ if (error) {
+ settle(
+ 400,
+ errorHtml,
+ oauthCallbackError('token_exchange', `OAuth authorization failed: ${errorDescription ?? 'unknown error'}`),
+ );
+ return;
+ }
+
+ if (state !== expectedState) {
+ settle(400, errorHtml, oauthCallbackError('state_mismatch', 'OAuth callback state did not match.'));
+ return;
+ }
+
+ if (!code) {
+ settle(
+ 400,
+ errorHtml,
+ oauthCallbackError('token_exchange', 'OAuth callback did not include an authorization code.'),
+ );
+ return;
+ }
+
+ settle(200, successHtml);
+ });
+
+ return new Promise((resolve, reject) => {
+ server.once('error', error => {
+ reject(new ClerkCliAuthError('config', `Failed to start local auth callback server: ${error.message}`));
+ });
+
+ server.listen(port, '127.0.0.1', () => {
+ const address = server.address() as AddressInfo;
+ const actualPort = address.port;
+ const redirectUri = `http://127.0.0.1:${actualPort}/callback`;
+
+ timeout = setTimeout(() => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ rejectCallback(new ClerkCliAuthError('timeout', `OAuth callback timed out after ${timeoutMs}ms.`));
+ closeListening(server);
+ }, timeoutMs);
+
+ resolve({
+ port: actualPort,
+ redirectUri,
+ waitForCallback: () => callbackPromise,
+ close: () => {
+ if (timeout) {
+ clearTimeout(timeout);
+ }
+ if (!settled) {
+ settled = true;
+ rejectCallback(new ClerkCliAuthError('timeout', 'OAuth callback server was closed.'));
+ }
+ closeListening(server);
+ },
+ });
+ });
+ });
+}
diff --git a/packages/cli-auth/src/lib/classify-token.ts b/packages/cli-auth/src/lib/classify-token.ts
new file mode 100644
index 00000000000..2c0380eb237
--- /dev/null
+++ b/packages/cli-auth/src/lib/classify-token.ts
@@ -0,0 +1,56 @@
+import { type MachineTokenType, TokenType } from '@clerk/backend/internal';
+import { decodeJwt } from '@clerk/backend/jwt';
+
+import { ClerkCliAuthError } from '../errors';
+
+/**
+ * Token kinds cli-auth accepts as CLI credentials. Narrower than `@clerk/backend`'s
+ * `MachineTokenType` — we drop `m2m_token` because M2M tokens are minted by machines for
+ * server-to-server BAPI calls, not for a CLI user to send to a backend. For machine
+ * identities, use an API key with a `mch_*` subject instead.
+ */
+export type TokenKind = Exclude;
+
+const API_KEY_PREFIX = 'ak_';
+const OAUTH_TOKEN_PREFIX = 'oat_';
+const JWT_FORMAT = /^[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+$/;
+/** RFC 9068 OAuth 2.0 access token `typ` header values. */
+const OAUTH_JWT_TYP_VALUES = ['at+jwt', 'application/at+jwt'] as const;
+
+function isJwtFormat(token: string): boolean {
+ return JWT_FORMAT.test(token);
+}
+
+/**
+ * Classify a credential as an API key or an OAuth access token by prefix / JWT shape:
+ *
+ * - `ak_*` → `'api_key'` (user/org/machine API keys all share this prefix)
+ * - `oat_*` or JWT with `typ: at+jwt` (RFC 9068) → `'oauth_token'`
+ *
+ * Throws on M2M tokens (`mt_*` or `mch_` subject JWT) and on any unknown shape — M2M is
+ * intentionally rejected at the CLI boundary.
+ */
+export function classifyToken(token: string): TokenKind {
+ if (token.startsWith(API_KEY_PREFIX)) {
+ return TokenType.ApiKey;
+ }
+ if (token.startsWith(OAUTH_TOKEN_PREFIX)) {
+ return TokenType.OAuthToken;
+ }
+ if (isJwtFormat(token)) {
+ const result = decodeJwt(token) as {
+ data?: { header: { typ?: unknown } };
+ errors?: unknown;
+ };
+ if (!result.errors && result.data) {
+ const typ = result.data.header.typ;
+ if (typeof typ === 'string' && (OAUTH_JWT_TYP_VALUES as readonly string[]).includes(typ)) {
+ return TokenType.OAuthToken;
+ }
+ }
+ }
+ throw new ClerkCliAuthError(
+ 'not_authenticated',
+ 'Unsupported credential. Expected an API key or OAuth access token.',
+ );
+}
diff --git a/packages/cli-auth/src/lib/credential-store.ts b/packages/cli-auth/src/lib/credential-store.ts
new file mode 100644
index 00000000000..5ffd8936e15
--- /dev/null
+++ b/packages/cli-auth/src/lib/credential-store.ts
@@ -0,0 +1,216 @@
+import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
+import { homedir } from 'node:os';
+import { dirname, join } from 'node:path';
+
+import { ClerkCliAuthError } from '../errors';
+import type { CredentialStore, StorageKind } from '../types';
+
+export interface CreateStoreOptions {
+ keychainService?: string;
+ environment?: string;
+ filePath?: string;
+}
+
+// keyring is imported lazily in loadKeyring() so the native binding can fail soft.
+// eslint-disable-next-line @typescript-eslint/consistent-type-imports
+type KeyringModule = typeof import('@napi-rs/keyring');
+
+function storageError(message: string, error: unknown): ClerkCliAuthError {
+ if (error instanceof ClerkCliAuthError) {
+ return error;
+ }
+ const detail = error instanceof Error ? error.message : String(error);
+ return new ClerkCliAuthError('storage', `${message}: ${detail}`);
+}
+
+function environmentName(options?: CreateStoreOptions): string {
+ return options?.environment ?? 'default';
+}
+
+function defaultFilePath(environment: string): string {
+ return join(homedir(), '.config', 'clerk-cli-auth', `${environment}.json`);
+}
+
+class MemoryCredentialStore implements CredentialStore {
+ private readonly values = new Map();
+
+ async get(key: string): Promise {
+ await Promise.resolve();
+ return this.values.get(key) ?? null;
+ }
+
+ async set(key: string, value: string): Promise {
+ await Promise.resolve();
+ this.values.set(key, value);
+ }
+
+ async delete(key: string): Promise {
+ await Promise.resolve();
+ this.values.delete(key);
+ }
+}
+
+class FileCredentialStore implements CredentialStore {
+ private queue: Promise = Promise.resolve();
+
+ constructor(private readonly filePath: string) {}
+
+ private enqueue(operation: () => Promise): Promise {
+ const next = this.queue.then(operation, operation);
+ this.queue = next.catch(() => undefined);
+ return next;
+ }
+
+ private async readAll(): Promise> {
+ try {
+ const content = await readFile(this.filePath, 'utf8');
+ if (!content.trim()) {
+ return {};
+ }
+ const parsed = JSON.parse(content) as unknown;
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ return {};
+ }
+
+ const result: Record = {};
+ for (const [key, value] of Object.entries(parsed)) {
+ if (typeof value === 'string') {
+ result[key] = value;
+ }
+ }
+ return result;
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
+ return {};
+ }
+ throw storageError(`Failed to read credential file ${this.filePath}`, error);
+ }
+ }
+
+ private async writeAll(values: Record): Promise {
+ try {
+ await mkdir(dirname(this.filePath), { recursive: true });
+ await writeFile(this.filePath, `${JSON.stringify(values, null, 2)}\n`, {
+ mode: 0o600,
+ });
+ await chmod(this.filePath, 0o600);
+ } catch (error) {
+ throw storageError(`Failed to write credential file ${this.filePath}`, error);
+ }
+ }
+
+ async get(key: string): Promise {
+ return this.enqueue(async () => {
+ const values = await this.readAll();
+ return values[key] ?? null;
+ });
+ }
+
+ async set(key: string, value: string): Promise {
+ return this.enqueue(async () => {
+ const values = await this.readAll();
+ values[key] = value;
+ await this.writeAll(values);
+ });
+ }
+
+ async delete(key: string): Promise {
+ return this.enqueue(async () => {
+ const values = await this.readAll();
+ delete values[key];
+ await this.writeAll(values);
+ });
+ }
+}
+
+class KeychainCredentialStore implements CredentialStore {
+ private keyringPromise: Promise | null = null;
+
+ constructor(
+ private readonly service: string,
+ private readonly environment: string,
+ private readonly fallback: CredentialStore,
+ ) {}
+
+ private account(key: string): string {
+ return `${this.environment}:${key}`;
+ }
+
+ private warnFallback(operation: string, error: unknown): void {
+ const detail = error instanceof Error ? error.message : String(error);
+ console.warn(`Keychain ${operation} failed; falling back to file store. ${detail}`);
+ }
+
+ private async loadKeyring(): Promise {
+ this.keyringPromise ??= import('@napi-rs/keyring').catch((error: unknown) => {
+ this.warnFallback('initialization', error);
+ return null;
+ });
+ return this.keyringPromise;
+ }
+
+ async get(key: string): Promise {
+ try {
+ const keyring = await this.loadKeyring();
+ if (!keyring) {
+ return this.fallback.get(key);
+ }
+
+ const entry = new keyring.Entry(this.service, this.account(key));
+ return entry.getPassword() ?? (await this.fallback.get(key));
+ } catch (error) {
+ this.warnFallback('get', error);
+ return this.fallback.get(key);
+ }
+ }
+
+ async set(key: string, value: string): Promise {
+ try {
+ const keyring = await this.loadKeyring();
+ if (!keyring) {
+ await this.fallback.set(key, value);
+ return;
+ }
+
+ const entry = new keyring.Entry(this.service, this.account(key));
+ entry.setPassword(value);
+ // Clear any stale fallback so a later keychain failure can't resurrect an old value.
+ await this.fallback.delete(key).catch(() => undefined);
+ } catch (error) {
+ this.warnFallback('set', error);
+ await this.fallback.set(key, value);
+ }
+ }
+
+ async delete(key: string): Promise {
+ try {
+ const keyring = await this.loadKeyring();
+ if (keyring) {
+ const entry = new keyring.Entry(this.service, this.account(key));
+ entry.deletePassword();
+ }
+ } catch (error) {
+ this.warnFallback('delete', error);
+ }
+ await this.fallback.delete(key);
+ }
+}
+
+function createFileStore(options?: CreateStoreOptions): CredentialStore {
+ const environment = environmentName(options);
+ return new FileCredentialStore(options?.filePath ?? defaultFilePath(environment));
+}
+
+export function createCredentialStore(kind: StorageKind, options?: CreateStoreOptions): CredentialStore {
+ if (kind === 'memory') {
+ return new MemoryCredentialStore();
+ }
+ if (kind === 'file') {
+ return createFileStore(options);
+ }
+
+ const fallback = createFileStore(options);
+ const service = options?.keychainService ?? 'clerk-cli-auth';
+ const environment = environmentName(options);
+ return new KeychainCredentialStore(service, environment, fallback);
+}
diff --git a/packages/cli-auth/src/lib/http.ts b/packages/cli-auth/src/lib/http.ts
new file mode 100644
index 00000000000..90297523d99
--- /dev/null
+++ b/packages/cli-auth/src/lib/http.ts
@@ -0,0 +1,95 @@
+import { ClerkCliAuthError, type ErrorCode } from '../errors';
+
+export interface RequestOptions extends Omit {
+ /** Error code to attach when the request fails (network, timeout, non-2xx, parse). */
+ errorCode: ErrorCode;
+ /** Per-request timeout in ms. Defaults to 30_000. */
+ timeoutMs?: number;
+ /** Optional caller signal — aborts merge with the internal timeout. */
+ signal?: AbortSignal;
+}
+
+const DEFAULT_TIMEOUT_MS = 30_000;
+
+async function parseBody(response: Response): Promise {
+ const contentType = response.headers.get('content-type') ?? '';
+ if (contentType.includes('application/json')) {
+ return response.json();
+ }
+ return response.text();
+}
+
+function messageFromBody(body: unknown, fallback: string): string {
+ if (typeof body === 'string' && body.trim()) {
+ return body.trim();
+ }
+ if (body && typeof body === 'object') {
+ const record = body as Record;
+ for (const key of ['error_description', 'message', 'error']) {
+ const value = record[key];
+ if (typeof value === 'string' && value.trim()) {
+ return value.trim();
+ }
+ }
+ }
+ return fallback;
+}
+
+function linkSignals(externalSignal: AbortSignal | undefined, controller: AbortController): () => void {
+ if (!externalSignal) {
+ return () => undefined;
+ }
+ if (externalSignal.aborted) {
+ controller.abort(externalSignal.reason);
+ return () => undefined;
+ }
+ const onAbort = () => controller.abort(externalSignal.reason);
+ externalSignal.addEventListener('abort', onAbort, { once: true });
+ return () => externalSignal.removeEventListener('abort', onAbort);
+}
+
+/**
+ * Shared HTTP helper: applies a per-request timeout via AbortController, parses the body,
+ * and maps every failure mode to a ClerkCliAuthError tagged with the caller's errorCode.
+ * Timeout aborts always surface as ClerkCliAuthError('timeout').
+ */
+export async function request(url: string, options: RequestOptions): Promise<{ response: Response; body: unknown }> {
+ const { errorCode, timeoutMs = DEFAULT_TIMEOUT_MS, signal, ...init } = options;
+
+ const controller = new AbortController();
+ const timeoutHandle = setTimeout(
+ () => controller.abort(new Error(`Request timed out after ${timeoutMs}ms`)),
+ timeoutMs,
+ );
+ const unlinkSignal = linkSignals(signal, controller);
+
+ let response: Response;
+ try {
+ response = await fetch(url, { ...init, signal: controller.signal });
+ } catch (error) {
+ const aborted = controller.signal.aborted && signal?.aborted !== true;
+ if (aborted) {
+ throw new ClerkCliAuthError('timeout', `Request to ${url} timed out after ${timeoutMs}ms.`);
+ }
+ throw new ClerkCliAuthError(errorCode, `Request to ${url} failed: ${(error as Error).message}`);
+ } finally {
+ clearTimeout(timeoutHandle);
+ unlinkSignal();
+ }
+
+ let body: unknown;
+ try {
+ body = await parseBody(response);
+ } catch (error) {
+ throw new ClerkCliAuthError(errorCode, `Response from ${url} could not be parsed: ${(error as Error).message}`);
+ }
+
+ if (!response.ok) {
+ throw new ClerkCliAuthError(
+ errorCode,
+ messageFromBody(body, `Request to ${url} failed with HTTP ${response.status}.`),
+ );
+ }
+
+ return { response, body };
+}
diff --git a/packages/cli-auth/src/lib/pkce.ts b/packages/cli-auth/src/lib/pkce.ts
new file mode 100644
index 00000000000..0e2063f68da
--- /dev/null
+++ b/packages/cli-auth/src/lib/pkce.ts
@@ -0,0 +1,18 @@
+import { createHash, randomBytes } from 'node:crypto';
+
+function base64Url(buffer: Buffer): string {
+ return buffer.toString('base64url');
+}
+
+export function generateCodeVerifier(): string {
+ return base64Url(randomBytes(32));
+}
+
+export async function generateCodeChallenge(verifier: string): Promise {
+ await Promise.resolve();
+ return base64Url(createHash('sha256').update(verifier).digest());
+}
+
+export function generateState(): string {
+ return base64Url(randomBytes(32));
+}
diff --git a/packages/cli-auth/src/lib/token-exchange.ts b/packages/cli-auth/src/lib/token-exchange.ts
new file mode 100644
index 00000000000..5f267e8a59c
--- /dev/null
+++ b/packages/cli-auth/src/lib/token-exchange.ts
@@ -0,0 +1,155 @@
+import { ClerkCliAuthError } from '../errors';
+import type { TokenSet, UserIdentity } from '../types';
+import { request } from './http';
+
+export interface ExchangeParams {
+ issuer: string;
+ clientId: string;
+ code: string;
+ codeVerifier: string;
+ redirectUri: string;
+ timeoutMs?: number;
+}
+
+export interface RefreshParams {
+ issuer: string;
+ clientId: string;
+ refreshToken: string;
+ scopes?: string[];
+ timeoutMs?: number;
+}
+
+export interface FetchIdentityParams {
+ issuer: string;
+ accessToken: string;
+ timeoutMs?: number;
+}
+
+export interface RevokeParams {
+ issuer: string;
+ clientId: string;
+ token: string;
+ tokenTypeHint?: 'access_token' | 'refresh_token';
+ timeoutMs?: number;
+}
+
+interface OAuthTokenResponse {
+ access_token?: unknown;
+ refresh_token?: unknown;
+ id_token?: unknown;
+ expires_in?: unknown;
+ scope?: unknown;
+ token_type?: unknown;
+}
+
+function endpoint(issuer: string, path: string): string {
+ return `${issuer.replace(/\/+$/, '')}${path}`;
+}
+
+function mapTokenResponse(data: OAuthTokenResponse): TokenSet {
+ if (typeof data.access_token !== 'string' || data.access_token.length === 0) {
+ throw new ClerkCliAuthError('token_exchange', 'Token response did not include access_token.');
+ }
+
+ const tokenSet: TokenSet = {
+ accessToken: data.access_token,
+ };
+
+ if (typeof data.refresh_token === 'string') {
+ tokenSet.refreshToken = data.refresh_token;
+ }
+ if (typeof data.id_token === 'string') {
+ tokenSet.idToken = data.id_token;
+ }
+ if (typeof data.scope === 'string') {
+ tokenSet.scope = data.scope;
+ }
+ if (typeof data.token_type === 'string') {
+ tokenSet.tokenType = data.token_type;
+ }
+ if (typeof data.expires_in === 'number') {
+ tokenSet.expiresAt = Date.now() + data.expires_in * 1000;
+ }
+
+ return tokenSet;
+}
+
+async function requestTokens(issuer: string, body: URLSearchParams, timeoutMs?: number): Promise {
+ const { body: parsed } = await request(endpoint(issuer, '/oauth/token'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: body.toString(),
+ errorCode: 'token_exchange',
+ timeoutMs,
+ });
+
+ if (!parsed || typeof parsed !== 'object') {
+ throw new ClerkCliAuthError('token_exchange', 'Token response was not JSON.');
+ }
+
+ return mapTokenResponse(parsed as OAuthTokenResponse);
+}
+
+export async function exchangeCodeForTokens(params: ExchangeParams): Promise {
+ const body = new URLSearchParams({
+ grant_type: 'authorization_code',
+ client_id: params.clientId,
+ code: params.code,
+ code_verifier: params.codeVerifier,
+ redirect_uri: params.redirectUri,
+ });
+
+ return requestTokens(params.issuer, body, params.timeoutMs);
+}
+
+export async function refreshAccessToken(params: RefreshParams): Promise {
+ const body = new URLSearchParams({
+ grant_type: 'refresh_token',
+ client_id: params.clientId,
+ refresh_token: params.refreshToken,
+ });
+
+ if (params.scopes?.length) {
+ body.set('scope', params.scopes.join(' '));
+ }
+
+ return requestTokens(params.issuer, body, params.timeoutMs);
+}
+
+export async function revokeToken(params: RevokeParams): Promise {
+ const body = new URLSearchParams({
+ client_id: params.clientId,
+ token: params.token,
+ });
+ if (params.tokenTypeHint) {
+ body.set('token_type_hint', params.tokenTypeHint);
+ }
+
+ await request(endpoint(params.issuer, '/oauth/token/revoke'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: body.toString(),
+ errorCode: 'revoke',
+ timeoutMs: params.timeoutMs,
+ });
+}
+
+export async function fetchIdentity(params: FetchIdentityParams): Promise {
+ const { body: parsed } = await request(endpoint(params.issuer, '/oauth/userinfo'), {
+ headers: { Authorization: `Bearer ${params.accessToken}` },
+ errorCode: 'userinfo',
+ timeoutMs: params.timeoutMs,
+ });
+
+ if (!parsed || typeof parsed !== 'object') {
+ throw new ClerkCliAuthError('userinfo', 'Userinfo response was not JSON.');
+ }
+
+ // `/oauth/userinfo` returns a user subject per OAuth/OIDC spec.
+ const identity = parsed as UserIdentity;
+ if (typeof identity.sub !== 'string' || identity.sub.length === 0) {
+ throw new ClerkCliAuthError('userinfo', 'Userinfo response did not include sub.');
+ }
+
+ return identity;
+}
diff --git a/packages/cli-auth/src/lib/verify-token.ts b/packages/cli-auth/src/lib/verify-token.ts
new file mode 100644
index 00000000000..7fd80bbd5ea
--- /dev/null
+++ b/packages/cli-auth/src/lib/verify-token.ts
@@ -0,0 +1,33 @@
+import { ClerkCliAuthError } from '../errors';
+import type { Identity } from '../types';
+import { request } from './http';
+
+export interface VerifyTokenParams {
+ endpoint: string;
+ token: string;
+ timeoutMs?: number;
+}
+
+/**
+ * POST a credential (API key or OAuth access token) to a consumer-hosted `identityEndpoint`
+ * and return the verified `Identity`. The endpoint is responsible for verifying the
+ * credential server-side.
+ */
+export async function verifyToken(params: VerifyTokenParams): Promise {
+ const { body: parsed } = await request(params.endpoint, {
+ headers: { Authorization: `Bearer ${params.token}` },
+ errorCode: 'verify_api_key',
+ timeoutMs: params.timeoutMs,
+ });
+
+ if (!parsed || typeof parsed !== 'object') {
+ throw new ClerkCliAuthError('verify_api_key', 'Identity endpoint response was not a JSON object.');
+ }
+
+ const identity = parsed as Identity;
+ if (typeof identity.sub !== 'string' || !identity.sub) {
+ throw new ClerkCliAuthError('verify_api_key', 'Identity endpoint response did not include sub.');
+ }
+
+ return identity;
+}
diff --git a/packages/cli-auth/src/server/cli-auth.ts b/packages/cli-auth/src/server/cli-auth.ts
new file mode 100644
index 00000000000..73023a14e79
--- /dev/null
+++ b/packages/cli-auth/src/server/cli-auth.ts
@@ -0,0 +1,123 @@
+import { type ClerkClient, type ClerkOptions, createClerkClient } from '@clerk/backend';
+
+import { ClerkCliAuthError } from '../errors';
+import type { TokenKind } from '../lib/classify-token';
+import { resolveAuthInfo as defaultResolveAuthInfo } from './resolve-auth';
+import type {
+ AcceptsToken,
+ CliAuthFactoryOptions,
+ CliAuthInstance,
+ ClientArg,
+ ResolveAuthInfoContext,
+ TokenInfo,
+} from './types';
+import { verifyTokenWithClerk } from './verify-token';
+
+/** Synthesize a Request so the bare-token verifier can reuse `clerk.authenticateRequest`. */
+function requestForToken(token: string): Request {
+ return new Request('http://cli-auth.local/verify', { headers: { Authorization: `Bearer ${token}` } });
+}
+
+/** Build a getClerk thunk for the given factory options, with a single-flight cache. */
+function makeClerkGetter(
+ client: ClientArg | undefined,
+ clientConfig: ClerkOptions | undefined,
+): () => Promise {
+ let cached: ClerkClient | null = null;
+ let pending: Promise | null = null;
+
+ return async function getClerk(): Promise {
+ if (cached) {
+ return cached;
+ }
+ if (pending) {
+ return pending;
+ }
+
+ pending = (async () => {
+ if (client) {
+ const resolved = typeof client === 'function' ? await client() : client;
+ cached = resolved;
+ return resolved;
+ }
+ if (clientConfig) {
+ cached = createClerkClient(clientConfig);
+ return cached;
+ }
+ const secretKey = process.env.CLERK_SECRET_KEY;
+ if (!secretKey) {
+ throw new ClerkCliAuthError(
+ 'config',
+ 'cliAuth() needs a Clerk Backend client. Pass `client`, `clientConfig`, or set CLERK_SECRET_KEY in the env.',
+ );
+ }
+ cached = createClerkClient({ secretKey });
+ return cached;
+ })();
+
+ try {
+ return await pending;
+ } finally {
+ pending = null;
+ }
+ };
+}
+
+/**
+ * Factory: bind a Clerk Backend client (via `client`, `clientConfig`, or auto-built from
+ * `CLERK_SECRET_KEY`) and return an instance with verifier helpers. Pair the instance with
+ * the standalone {@link handle} export to wire up route handlers.
+ *
+ * `client` accepts either a resolved `ClerkClient` or a factory function (sync or async)
+ * — pass `@clerk/nextjs/server`'s `clerkClient` directly, no top-level `await` required.
+ *
+ * @example
+ * ```ts
+ * // lib/clerk-cli.ts
+ * import { cliAuth } from '@clerk/cli-auth/server';
+ * import { clerkClient } from '@clerk/nextjs/server';
+ *
+ * export const auth = cliAuth({ client: clerkClient });
+ *
+ * // app/api/cli/verify/route.ts
+ * import { handle } from '@clerk/cli-auth/server';
+ * import { auth } from '@/lib/clerk-cli';
+ *
+ * export const GET = handle({ auth, accepts: ['api_key', 'oauth_token'] });
+ *
+ * // Or use the primitive directly inside a custom protected route:
+ * const tokenInfo = await auth.verifyTokenFromRequest(request, { accepts: 'api_key' });
+ * ```
+ */
+export function cliAuth(options: CliAuthFactoryOptions = {}): CliAuthInstance {
+ const getClerk = makeClerkGetter(options.client, options.clientConfig);
+
+ async function verifyTokenFromRequest(
+ request: Request,
+ verifyOptions?: { accepts?: AcceptsToken },
+ ): Promise> {
+ const clerk = await getClerk();
+ const info = await verifyTokenWithClerk(request, { accepts: verifyOptions?.accepts, clerk });
+ return info as TokenInfo;
+ }
+
+ async function verifyToken(
+ token: string,
+ verifyOptions?: { accepts?: AcceptsToken },
+ ): Promise> {
+ return verifyTokenFromRequest(requestForToken(token), verifyOptions);
+ }
+
+ function resolveAuthInfo(
+ ctx: Omit, 'clerk'> & { clerk?: ClerkClient },
+ ): ReturnType {
+ return defaultResolveAuthInfo(ctx as ResolveAuthInfoContext);
+ }
+
+ return {
+ verifyToken,
+ verifyTokenFromRequest,
+ resolveAuthInfo,
+ getClerk,
+ };
+}
diff --git a/packages/cli-auth/src/server/handle.ts b/packages/cli-auth/src/server/handle.ts
new file mode 100644
index 00000000000..7ecac9331ba
--- /dev/null
+++ b/packages/cli-auth/src/server/handle.ts
@@ -0,0 +1,87 @@
+import { ClerkCliAuthError, EXIT_CODE } from '../errors';
+import { classifyToken, type TokenKind } from '../lib/classify-token';
+import { resolveAuthInfo as defaultResolveAuthInfo, validateIdentity } from './resolve-auth';
+import type { HandleOptions, TokenInfo } from './types';
+import { readBearer } from './verify-token';
+
+function statusFor(code: string): number {
+ switch (code) {
+ case 'not_authenticated':
+ case 'verify_api_key':
+ case 'userinfo':
+ return 401;
+ case 'config':
+ return 500;
+ case 'timeout':
+ return 504;
+ default:
+ return 500;
+ }
+}
+
+function jsonError(error: ClerkCliAuthError): Response {
+ return Response.json(
+ { error: error.code, error_description: error.message, exit_code: error.exitCode ?? EXIT_CODE.GENERAL },
+ { status: statusFor(error.code) },
+ );
+}
+
+async function verifyForHandle(
+ request: Request,
+ options: HandleOptions,
+): Promise> {
+ if (!options.verifyToken) {
+ return options.auth.verifyTokenFromRequest(request, { accepts: options.accepts });
+ }
+ // Custom verifier: classify, then pass the bound Clerk client through.
+ const token = readBearer(request);
+ const type = classifyToken(token) as T;
+ const clerk = await options.auth.getClerk();
+ return options.verifyToken({ token, type, request, clerk });
+}
+
+/**
+ * Standalone route handler: wraps a `cliAuth()` instance into a Web `Request → Response`
+ * function for any framework that speaks the Fetch API (Next.js App Router, Hono,
+ * SvelteKit, Remix, plain Node `fetch`, etc.).
+ *
+ * @example
+ * ```ts
+ * // lib/clerk-cli.ts
+ * import { cliAuth } from '@clerk/cli-auth/server';
+ * import { clerkClient } from '@clerk/nextjs/server';
+ *
+ * export const auth = cliAuth({ client: clerkClient });
+ *
+ * // app/api/cli/identity/route.ts
+ * import { handle } from '@clerk/cli-auth/server';
+ * import { auth } from '@/lib/clerk-cli';
+ *
+ * export const GET = handle({
+ * auth,
+ * accepts: ['api_key', 'oauth_token'],
+ * // Optional overrides:
+ * // verifyToken: ({ token, type, request, clerk }) => ...
+ * // resolveAuthInfo: ({ tokenInfo, request, clerk }) => ...
+ * });
+ * ```
+ */
+export function handle(
+ options: HandleOptions,
+): (request: Request) => Promise {
+ return async function routeHandler(request: Request): Promise {
+ try {
+ const tokenInfo = await verifyForHandle(request, options);
+ const clerk = await options.auth.getClerk();
+ const resolver = options.resolveAuthInfo ?? defaultResolveAuthInfo;
+ const raw = await resolver({ tokenInfo, request, clerk });
+ const info = validateIdentity(raw);
+ return Response.json(info, { status: 200 });
+ } catch (error) {
+ if (error instanceof ClerkCliAuthError) {
+ return jsonError(error);
+ }
+ return jsonError(new ClerkCliAuthError('config', `Unexpected error: ${(error as Error).message}`));
+ }
+ };
+}
diff --git a/packages/cli-auth/src/server/index.ts b/packages/cli-auth/src/server/index.ts
new file mode 100644
index 00000000000..946d63200a7
--- /dev/null
+++ b/packages/cli-auth/src/server/index.ts
@@ -0,0 +1,17 @@
+export { cliAuth } from './cli-auth';
+export { handle } from './handle';
+
+export type {
+ AcceptsToken,
+ CliAuthFactoryOptions,
+ CliAuthInstance,
+ HandleOptions,
+ ResolveAuthInfoContext,
+ ResolveAuthInfoFn,
+ TokenInfo,
+ VerifyTokenContext,
+ VerifyTokenFn,
+} from './types';
+
+// Cli-auth's narrowed `TokenKind` ('api_key' | 'oauth_token') — m2m is intentionally excluded.
+export type { TokenKind } from '../lib/classify-token';
diff --git a/packages/cli-auth/src/server/resolve-auth.ts b/packages/cli-auth/src/server/resolve-auth.ts
new file mode 100644
index 00000000000..9a91a145d14
--- /dev/null
+++ b/packages/cli-auth/src/server/resolve-auth.ts
@@ -0,0 +1,75 @@
+import { ClerkCliAuthError } from '../errors';
+import type { TokenKind } from '../lib/classify-token';
+import type { Identity } from '../types';
+import type { ResolveAuthInfoContext, TokenInfo } from './types';
+
+/**
+ * Canonical Clerk subject formats: `user_*`, `org_*`, `mch_*`, `scim_*` — each
+ * followed by 27 word chars. Verified tokens should always match.
+ */
+const SUBJECT_PATTERN = /^(user|org|mch|scim)_\w{27}$/;
+
+/** Extract a canonical Clerk subject from `tokenInfo.subject` or any claim that holds one. */
+function extractSubject(tokenInfo: TokenInfo): string {
+ if (SUBJECT_PATTERN.test(tokenInfo.subject)) {
+ return tokenInfo.subject;
+ }
+ // Fallback: scan claims for the first canonical subject value.
+ if (tokenInfo.claims) {
+ for (const value of Object.values(tokenInfo.claims)) {
+ if (typeof value === 'string' && SUBJECT_PATTERN.test(value)) {
+ return value;
+ }
+ }
+ }
+ throw new ClerkCliAuthError(
+ 'verify_api_key',
+ `Verified token has no canonical Clerk subject (got "${tokenInfo.subject}").`,
+ );
+}
+
+/**
+ * Default info resolver — runs on a verified `TokenInfo` and projects it into a
+ * `Identity`. Validates the subject against `^(user|org|mch|scim)_\w{27}$`
+ * to catch verifier output that doesn't look like a Clerk resource ID.
+ *
+ * Consumers override this by passing `resolveAuthInfo` to `handle()` when they want
+ * richer profile/org data (e.g. fetching the user via the bound Clerk client, or
+ * pulling org claims out of an API key's `claims` bag).
+ */
+export function resolveAuthInfo(ctx: ResolveAuthInfoContext): Identity {
+ const { tokenInfo } = ctx;
+ const sub = extractSubject(tokenInfo);
+
+ const info: Identity = { sub };
+
+ // Pass through anything the verifier surfaced under `claims` — keeps the response useful
+ // without forcing every consumer to write a resolver.
+ if (tokenInfo.claims) {
+ for (const [key, value] of Object.entries(tokenInfo.claims)) {
+ if (info[key] === undefined) {
+ info[key] = value;
+ }
+ }
+ }
+
+ return info;
+}
+
+/** Internal: enforces the `Identity` contract on whatever the resolver returned. */
+export function validateIdentity(info: unknown): Identity {
+ if (!info || typeof info !== 'object') {
+ throw new ClerkCliAuthError(
+ 'verify_api_key',
+ 'resolveAuthInfo must return an Identity object with a non-empty `sub`.',
+ );
+ }
+ const candidate = info as Partial;
+ if (typeof candidate.sub !== 'string' || !candidate.sub) {
+ throw new ClerkCliAuthError(
+ 'verify_api_key',
+ 'resolveAuthInfo returned an object without a non-empty `sub` field.',
+ );
+ }
+ return candidate as Identity;
+}
diff --git a/packages/cli-auth/src/server/types.ts b/packages/cli-auth/src/server/types.ts
new file mode 100644
index 00000000000..d0cff5458c3
--- /dev/null
+++ b/packages/cli-auth/src/server/types.ts
@@ -0,0 +1,147 @@
+import type { ClerkClient, ClerkOptions } from '@clerk/backend';
+
+import type { TokenKind } from '../lib/classify-token';
+import type { Identity } from '../types';
+
+/**
+ * Which token types this endpoint accepts: a single {@link TokenKind}, a readonly tuple of
+ * them, or the literal `'any'`. Narrower than `@clerk/backend`'s `acceptsToken` — m2m tokens
+ * and session tokens are both intentionally excluded from the CLI threat model.
+ */
+export type AcceptsToken = TokenKind | readonly TokenKind[] | 'any';
+
+/**
+ * A Clerk Backend client, either resolved or wrapped in a factory. Passing the factory
+ * lets you forward `@clerk/nextjs/server`'s `clerkClient` directly — the SDK calls it
+ * lazily on first use, so consumers don't need to `await` at module scope.
+ */
+export type ClientArg = ClerkClient | (() => ClerkClient | Promise);
+
+/**
+ * Verified token payload. Returned by `verifyToken` / `verifyTokenFromRequest`
+ * and passed into `resolveAuthInfo` as `tokenInfo`.
+ */
+export interface TokenInfo {
+ /** The verified token's subject — `user_*`, `org_*`, `mch_*`, or `scim_*`. */
+ subject: string;
+ /** The verified token type (`api_key` | `oauth_token`). */
+ type: T;
+ /** Scopes attached to the token, when applicable. */
+ scopes?: string[];
+ /** Raw verified claims/data from `@clerk/backend`. Shape varies by token type. */
+ claims?: Record;
+}
+
+/**
+ * Context passed to a `verifyToken` override. `token` is the raw, unverified bearer.
+ * `type` is the auto-detected token type; `clerk` is the resolved Clerk Backend client.
+ */
+export interface VerifyTokenContext {
+ /** Raw bearer token from the `Authorization` header. */
+ token: string;
+ /** Token type auto-detected from the token's prefix / JWT shape. */
+ type: T;
+ /** Original incoming `Request`. */
+ request: Request;
+ /** Clerk Backend client, ready to use (`clerk.apiKeys.verify(...)`, `clerk.authenticateRequest(...)`, etc.). */
+ clerk: ClerkClient;
+}
+
+/**
+ * Context passed to a `resolveAuthInfo` callback. The token has already been verified;
+ * downstream code reads `tokenInfo.subject` / `.claims`, the original `request`, and the
+ * resolved Clerk Backend client.
+ */
+export interface ResolveAuthInfoContext {
+ /** The verified token, including subject, type, scopes, and claims. */
+ tokenInfo: TokenInfo;
+ /** Original incoming `Request`. */
+ request: Request;
+ /** Clerk Backend client, ready to use (e.g. `clerk.users.getUser(tokenInfo.subject)`). */
+ clerk: ClerkClient;
+}
+
+/** A `verifyToken` callback returns a verified `TokenInfo` or throws on rejection. */
+export type VerifyTokenFn = (
+ ctx: VerifyTokenContext,
+) => Promise> | TokenInfo;
+
+/** A `resolveAuthInfo` callback shapes the verified token into a `Identity` payload. */
+export type ResolveAuthInfoFn = (
+ ctx: ResolveAuthInfoContext,
+) => Promise | Identity;
+
+/**
+ * Options for the `cliAuth()` factory. Pass either a Clerk Backend client (or a factory
+ * returning one) via `client`, or the config used to construct one via `clientConfig`.
+ * If neither is given, the factory builds a client from `CLERK_SECRET_KEY` at first use.
+ *
+ * `clientConfig.secretKey` (or `CLERK_SECRET_KEY`) is also what `verifyToken` /
+ * `verifyTokenFromRequest` pass to `@clerk/backend`'s `verifyMachineAuthToken`.
+ */
+export interface CliAuthFactoryOptions {
+ /** Pre-configured Clerk Backend client, or a thunk returning one (e.g. `@clerk/nextjs/server`'s `clerkClient`). */
+ client?: ClientArg;
+ /** Or: the `ClerkOptions` shape `createClerkClient(...)` accepts. */
+ clientConfig?: ClerkOptions;
+}
+
+/**
+ * Options for the standalone `handle()` export. Pass the `cliAuth()` instance you want to
+ * bind the route to via `auth`. Per-route Clerk client overrides aren't supported — make
+ * another `cliAuth()` instance instead.
+ */
+export interface HandleOptions {
+ /** `cliAuth()` instance whose bound Clerk client and verifier/resolver this route should use. */
+ auth: CliAuthInstance;
+ /** Which token types this endpoint accepts. Rejects every other type with 401. Defaults to `'any'`. */
+ accepts?: AcceptsToken;
+ /** Override the verification step. Defaults to a `verifyMachineAuthToken`-backed verifier. */
+ verifyToken?: VerifyTokenFn;
+ /** Override the info-resolution step. Defaults to extracting subject + claims into `Identity`. */
+ resolveAuthInfo?: ResolveAuthInfoFn;
+}
+
+/**
+ * Shape returned by `cliAuth({ client })`. Pair it with the standalone `handle()` export, or
+ * call its instance methods directly inside your own route logic.
+ *
+ * @example
+ * ```ts
+ * // lib/clerk-cli.ts
+ * import { cliAuth } from '@clerk/cli-auth/server';
+ * import { clerkClient } from '@clerk/nextjs/server';
+ *
+ * export const auth = cliAuth({ client: clerkClient });
+ *
+ * // app/api/cli/verify/route.ts
+ * import { handle } from '@clerk/cli-auth/server';
+ * import { auth } from '@/lib/clerk-cli';
+ *
+ * export const GET = handle({ auth, accepts: ['api_key', 'oauth_token'] });
+ * ```
+ */
+export interface CliAuthInstance {
+ /**
+ * Primitive verifier — raw bearer in, verified `TokenInfo` out. Auto-detects token type
+ * via `@clerk/backend`'s `verifyMachineAuthToken`, optionally gating against `accepts`.
+ */
+ verifyToken: (
+ token: string,
+ options?: { accepts?: AcceptsToken },
+ ) => Promise>;
+ /**
+ * Request-level verifier — reads `Authorization: Bearer `, then defers to
+ * `verifyToken(token, options)`.
+ */
+ verifyTokenFromRequest: (
+ request: Request,
+ options?: { accepts?: AcceptsToken },
+ ) => Promise>;
+ /** Default resolver: project a verified `TokenInfo` into `Identity`. Sync today, but typed `Identity | Promise` so consumer-supplied resolvers can be async. */
+ resolveAuthInfo: (
+ ctx: Omit, 'clerk'> & { clerk?: ClerkClient },
+ ) => Identity | Promise;
+ /** Resolve the bound Clerk Backend SDK client. Cached after the first call. */
+ getClerk: () => Promise;
+}
diff --git a/packages/cli-auth/src/server/verify-token.ts b/packages/cli-auth/src/server/verify-token.ts
new file mode 100644
index 00000000000..f7dd1d6c719
--- /dev/null
+++ b/packages/cli-auth/src/server/verify-token.ts
@@ -0,0 +1,73 @@
+import type { ClerkClient } from '@clerk/backend';
+
+import { ClerkCliAuthError } from '../errors';
+import type { TokenKind } from '../lib/classify-token';
+import type { AcceptsToken, TokenInfo } from './types';
+
+const BEARER_PREFIX = /^Bearer\s+/i;
+
+/** cli-auth's canonical accepted set — m2m and session are intentionally never accepted. */
+const DEFAULT_ACCEPTS: readonly TokenKind[] = ['api_key', 'oauth_token'] as const;
+
+export function readBearer(request: Request): string {
+ const header = request.headers.get('authorization');
+ if (!header) {
+ throw new ClerkCliAuthError('not_authenticated', 'Missing Authorization header.');
+ }
+ const token = header.replace(BEARER_PREFIX, '').trim();
+ if (!token) {
+ throw new ClerkCliAuthError('not_authenticated', 'Authorization header is empty.');
+ }
+ return token;
+}
+
+/**
+ * Normalize cli-auth's {@link AcceptsToken} to the `acceptsToken` array
+ * `@clerk/backend` expects. `'any'` collapses to the canonical narrowed set so session
+ * and m2m tokens are never accidentally accepted even when the caller asks for "any".
+ */
+function normalizeAccepts(accepts: AcceptsToken | undefined): readonly TokenKind[] {
+ if (!accepts || accepts === 'any') {
+ return DEFAULT_ACCEPTS;
+ }
+ return Array.isArray(accepts) ? accepts : [accepts as TokenKind];
+}
+
+/**
+ * Default token verifier — delegates to `clerk.authenticateRequest`, which detects the
+ * token type, looks up the right verification primitive (BAPI for opaque, JWKs for JWTs),
+ * and returns an `AuthObject`. We narrow the result to the {@link TokenInfo} shape that
+ * downstream consumers (`resolveAuthInfo`, custom `verifyToken` overrides) work with.
+ *
+ * Consumers who want richer claims for JWT-shaped tokens (e.g. the full RFC 9068 payload)
+ * can decode the bearer inside a custom `resolveAuthInfo` callback — that hook receives
+ * the original `Request`, so the raw token is recoverable via `readBearer(request)`.
+ */
+export async function verifyTokenWithClerk(
+ request: Request,
+ options: { accepts?: AcceptsToken; clerk: ClerkClient },
+): Promise {
+ // Cli-auth is bearer-only — require the header up front so consumers get a clear
+ // "Missing Authorization header" rather than `authenticateRequest`'s
+ // `token-type-mismatch` reason after it falls through to cookie auth and fails.
+ readBearer(request);
+
+ const acceptsToken = normalizeAccepts(options.accepts);
+ const state = await options.clerk.authenticateRequest(request, { acceptsToken });
+ if (state.isAuthenticated === false) {
+ throw new ClerkCliAuthError('not_authenticated', state.reason ?? 'Token rejected.');
+ }
+
+ const authObj = state.toAuth();
+ // `subject`, `scopes`, `tokenType` are top-level on every AuthenticatedMachineObject.
+ // `claims` is only present on the `api_key` arm of MachineObjectExtendedProperties —
+ // check the property at runtime to avoid leaking the type machinery here.
+ const claims = 'claims' in authObj && authObj.claims ? (authObj.claims as Record) : undefined;
+
+ return {
+ subject: authObj.subject,
+ type: authObj.tokenType as TokenKind,
+ scopes: authObj.scopes,
+ claims,
+ };
+}
diff --git a/packages/cli-auth/src/types.ts b/packages/cli-auth/src/types.ts
new file mode 100644
index 00000000000..0de0a08403e
--- /dev/null
+++ b/packages/cli-auth/src/types.ts
@@ -0,0 +1,158 @@
+/**
+ * Public types for @clerk/cli-auth.
+ * This file is the CONTRACT — Codex agents building the implementation
+ * and the demo consumer both import from here. Keep signatures stable.
+ */
+
+export type StorageKind = 'keychain' | 'file' | 'memory';
+
+export interface CredentialStore {
+ get(key: string): Promise;
+ set(key: string, value: string): Promise;
+ delete(key: string): Promise;
+}
+
+export type OAuthScope =
+ | 'profile'
+ | 'email'
+ | 'openid'
+ | 'offline_access'
+ | 'user:org:read'
+ | 'public_metadata'
+ | (string & {});
+
+export interface ClerkCliAuthConfig {
+ /** OAuth Application client_id from Clerk Dashboard (public client, PKCE). */
+ clientId: string;
+ /** Frontend API base URL, e.g. https://clerk.myapp.com (no trailing slash). */
+ issuer: string;
+ /** OAuth scopes to request. Default: ["profile", "email", "openid", "offline_access"]. */
+ scopes?: OAuthScope[];
+ /** Credential storage strategy. Default: "keychain" (with file fallback). */
+ storage?: StorageKind | CredentialStore;
+ /** Keychain service name (macOS/Linux/Windows credential manager). Default: "clerk-cli-auth". */
+ keychainService?: string;
+ /** Environment label to namespace stored tokens. Default: "default". */
+ environment?: string;
+ /** Override the port the ephemeral callback server binds to. Default: 0 (random). */
+ callbackPort?: number;
+ /** How long `login()` waits for the user to complete browser sign-in and redirect back to the localhost callback. Default: 120000 (2min). */
+ loginTimeoutMs?: number;
+ /** Per-HTTP-request timeout in ms for token exchange, refresh, revoke, userinfo, and API key verification. Default: 30000 (30s). */
+ requestTimeoutMs?: number;
+ /** Injected opener for the browser step (for testing). Default: auto-detect. */
+ openBrowser?: (url: string) => Promise;
+ /**
+ * Backend endpoint that returns the verified `Identity` for a credential (API key
+ * or OAuth access token). Called with `Authorization: Bearer `. The server is
+ * responsible for verifying the credential (e.g. via `clerk.apiKeys.verify()` from
+ * `@clerk/nextjs/server`) and responding with an `Identity` payload — verification
+ * stays server-side, never in the CLI. Setting this enables `verifyToken()`.
+ */
+ identityEndpoint?: string;
+ /**
+ * Env var to read a non-OAuth credential from (e.g. `'MYAPP_API_KEY'`). When set,
+ * `resolveToken()` falls back to this env var before the cached OAuth session.
+ */
+ tokenEnvVar?: string;
+}
+
+export interface TokenSet {
+ accessToken: string;
+ refreshToken?: string;
+ idToken?: string;
+ expiresAt?: number;
+ scope?: string;
+ tokenType?: string;
+}
+
+/**
+ * Where a credential resolved by {@link ClerkCliAuth.resolveToken} came from. Lets
+ * callers branch on the *trust model* rather than the wire-level token type:
+ *
+ * - `'arg'` — passed in directly via `tokenFromArg` (typically a `--token` CLI flag)
+ * - `'env'` — read from the env var named by `tokenEnvVar`
+ * - `'oauth'` — cached from a prior `login()`; refreshable and revokable
+ */
+export type TokenSource = 'arg' | 'env' | 'oauth';
+
+/**
+ * Verified identity returned by `/oauth/userinfo` or your `identityEndpoint`. Discriminated
+ * by the `sub` prefix:
+ *
+ * - `user_*` → {@link UserIdentity} — a person (OAuth flows + user-scoped API keys)
+ * - `org_*` → {@link OrgIdentity} — an org (org-scoped API keys)
+ * - `mch_*` → {@link MachineIdentity} — a machine (M2M tokens, machine-scoped API keys)
+ * - `scim_*` → {@link ScimIdentity} — a SCIM directory resource
+ *
+ * Narrow via the {@link isUserIdentity} / {@link isOrgIdentity} / {@link isMachineIdentity} /
+ * {@link isScimIdentity} guards.
+ */
+export type Identity = UserIdentity | OrgIdentity | MachineIdentity | ScimIdentity;
+
+interface IdentityBase {
+ sub: string;
+ /** Arbitrary additional claims surfaced by the issuer or your identity endpoint. */
+ [key: string]: unknown;
+}
+
+/** A person — OAuth subjects and user-scoped API keys. */
+export interface UserIdentity extends IdentityBase {
+ /** Clerk user id. Returned by identity endpoints; OAuth `/oauth/userinfo` puts it in `sub` instead. */
+ user_id?: string;
+ email?: string;
+ email_verified?: boolean;
+ name?: string;
+ given_name?: string;
+ family_name?: string;
+ picture?: string;
+ preferred_username?: string;
+ username?: string;
+ public_metadata?: Record;
+ unsafe_metadata?: Record;
+ /** Org context. Present when the user authenticated in an org scope. */
+ org_id?: string;
+ org_slug?: string;
+ org_name?: string;
+ org_role?: string;
+ org_permissions?: string[];
+}
+
+/** An organization — org-scoped API keys. */
+export interface OrgIdentity extends IdentityBase {
+ org_id?: string;
+ org_slug?: string;
+ org_name?: string;
+}
+
+/** A machine — M2M tokens, machine-scoped API keys. */
+export type MachineIdentity = IdentityBase;
+
+/** A SCIM directory resource. */
+export type ScimIdentity = IdentityBase;
+
+/** True if `identity.sub` starts with `user_`. Narrows to {@link UserIdentity}. */
+export function isUserIdentity(identity: Identity): identity is UserIdentity {
+ return identity.sub.startsWith('user_');
+}
+
+/** True if `identity.sub` starts with `org_`. Narrows to {@link OrgIdentity}. */
+export function isOrgIdentity(identity: Identity): identity is OrgIdentity {
+ return identity.sub.startsWith('org_');
+}
+
+/** True if `identity.sub` starts with `mch_`. Narrows to {@link MachineIdentity}. */
+export function isMachineIdentity(identity: Identity): identity is MachineIdentity {
+ return identity.sub.startsWith('mch_');
+}
+
+/** True if `identity.sub` starts with `scim_`. Narrows to {@link ScimIdentity}. */
+export function isScimIdentity(identity: Identity): identity is ScimIdentity {
+ return identity.sub.startsWith('scim_');
+}
+
+export interface LoginResult {
+ tokens: TokenSet;
+ /** OAuth subjects are always users; typed accordingly. */
+ user: UserIdentity;
+}
diff --git a/packages/cli-auth/tsconfig.json b/packages/cli-auth/tsconfig.json
new file mode 100644
index 00000000000..b5ddee18eed
--- /dev/null
+++ b/packages/cli-auth/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "declaration": true,
+ "declarationMap": false,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "importHelpers": true,
+ "isolatedModules": true,
+ "moduleResolution": "NodeNext",
+ "module": "NodeNext",
+ "noImplicitReturns": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "resolveJsonModule": true,
+ "sourceMap": false,
+ "strict": true,
+ "target": "ES2020",
+ "outDir": "dist"
+ },
+ "exclude": ["node_modules"],
+ "include": ["src"]
+}
diff --git a/packages/cli-auth/tsup.config.ts b/packages/cli-auth/tsup.config.ts
new file mode 100644
index 00000000000..b3fe802effb
--- /dev/null
+++ b/packages/cli-auth/tsup.config.ts
@@ -0,0 +1,27 @@
+import { defineConfig } from 'tsup';
+
+import { name, version } from './package.json';
+
+export default defineConfig(overrideOptions => {
+ const isWatch = !!overrideOptions.watch;
+ const shouldPublish = !!overrideOptions.env?.publish;
+
+ return {
+ entry: {
+ index: './src/index.ts',
+ server: './src/server/index.ts',
+ },
+ format: ['cjs', 'esm'],
+ bundle: true,
+ clean: true,
+ minify: false,
+ sourcemap: true,
+ dts: true,
+ onSuccess: shouldPublish ? 'pkglab pub --ping' : undefined,
+ define: {
+ PACKAGE_NAME: `"${name}"`,
+ PACKAGE_VERSION: `"${version}"`,
+ __DEV__: `${isWatch}`,
+ },
+ };
+});
diff --git a/packages/cli-auth/turbo.json b/packages/cli-auth/turbo.json
new file mode 100644
index 00000000000..4379d6c1c35
--- /dev/null
+++ b/packages/cli-auth/turbo.json
@@ -0,0 +1,19 @@
+{
+ "extends": ["//"],
+ "tasks": {
+ "build": {
+ "inputs": [
+ "*.d.ts",
+ "**/package.json",
+ "src/**",
+ "tsconfig.json",
+ "tsup.config.ts",
+
+ "!**/__tests__/**",
+ "!**/__snapshots__/**",
+ "!coverage/**",
+ "!node_modules/**"
+ ]
+ }
+ }
+}
diff --git a/packages/cli-auth/vitest.config.mts b/packages/cli-auth/vitest.config.mts
new file mode 100644
index 00000000000..d6435eaaf53
--- /dev/null
+++ b/packages/cli-auth/vitest.config.mts
@@ -0,0 +1,16 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ plugins: [],
+ test: {
+ coverage: {
+ provider: 'v8',
+ enabled: true,
+ reporter: ['text', 'json', 'html'],
+ },
+ setupFiles: './vitest.setup.mts',
+ // Integration tests live under src/__tests__/integration/ and require a real
+ // CLERK_SECRET_KEY. Default `vitest run` is unit-only; use `pnpm test:integration`.
+ exclude: ['**/node_modules/**', '**/dist/**', '**/__tests__/integration/**'],
+ },
+});
diff --git a/packages/cli-auth/vitest.integration.config.mts b/packages/cli-auth/vitest.integration.config.mts
new file mode 100644
index 00000000000..ddd24a5c583
--- /dev/null
+++ b/packages/cli-auth/vitest.integration.config.mts
@@ -0,0 +1,23 @@
+import { defineConfig } from 'vitest/config';
+
+/**
+ * Integration tests hit a real Clerk instance — they create users, API keys, and M2M
+ * tokens via the Backend SDK and clean up in `afterAll`. Run with `pnpm test:integration`
+ * and a `CLERK_SECRET_KEY` set in the environment. Suites `describe.skipIf` themselves
+ * when the secret is missing, so CI without it is a no-op rather than a failure.
+ */
+export default defineConfig({
+ plugins: [],
+ test: {
+ include: ['src/__tests__/integration/**/*.test.ts'],
+ setupFiles: './vitest.setup.mts',
+ // Network calls + BAPI rate limits — longer timeouts than the unit suite.
+ testTimeout: 30_000,
+ hookTimeout: 30_000,
+ // Sequential to keep BAPI traffic predictable; we share resources via beforeAll.
+ fileParallelism: false,
+ coverage: {
+ enabled: false,
+ },
+ },
+});
diff --git a/packages/cli-auth/vitest.setup.mts b/packages/cli-auth/vitest.setup.mts
new file mode 100644
index 00000000000..81cb45b14f7
--- /dev/null
+++ b/packages/cli-auth/vitest.setup.mts
@@ -0,0 +1,6 @@
+import { beforeAll } from 'vitest';
+
+globalThis.PACKAGE_NAME = '@clerk/cli-auth';
+globalThis.PACKAGE_VERSION = '0.0.0-test';
+
+beforeAll(() => {});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ce9136a8c0d..8a165b8b2d1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -511,6 +511,18 @@ importers:
specifier: ^5.10.0
version: 5.10.0
+ packages/cli-auth:
+ dependencies:
+ '@clerk/backend':
+ specifier: workspace:^
+ version: link:../backend
+ '@napi-rs/keyring':
+ specifier: ^1.1.7
+ version: 1.3.0
+ tslib:
+ specifier: catalog:repo
+ version: 2.8.1
+
packages/dev-cli:
dependencies:
commander:
@@ -2727,7 +2739,7 @@ packages:
'@expo/bunyan@4.0.1':
resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==}
- engines: {node: '>=0.10.0'}
+ engines: {'0': node >=0.10.0}
'@expo/cli@0.22.28':
resolution: {integrity: sha512-lvt72KNitGuixYD2l3SZmRKVu2G4zJpmg5V7WfUBNpmUU5oODBw/6qmiJ6kSLAlfDozscUk+BBGknBBzxUrwrA==}
@@ -3410,6 +3422,87 @@ packages:
resolution: {integrity: sha512-D0nkS5+sx87mYpxFqASImCineYoEl9wGlUPrzkuS0ohzG8wfophLpE+I76qGJ0slLAVI19do5SI9pWJNCVf4fg==}
engines: {node: '>=18'}
+ '@napi-rs/keyring-darwin-arm64@1.3.0':
+ resolution: {integrity: sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@napi-rs/keyring-darwin-x64@1.3.0':
+ resolution: {integrity: sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@napi-rs/keyring-freebsd-x64@1.3.0':
+ resolution: {integrity: sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0':
+ resolution: {integrity: sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm]
+ os: [linux]
+
+ '@napi-rs/keyring-linux-arm64-gnu@1.3.0':
+ resolution: {integrity: sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@napi-rs/keyring-linux-arm64-musl@1.3.0':
+ resolution: {integrity: sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@napi-rs/keyring-linux-riscv64-gnu@1.3.0':
+ resolution: {integrity: sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==}
+ engines: {node: '>= 10'}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@napi-rs/keyring-linux-x64-gnu@1.3.0':
+ resolution: {integrity: sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@napi-rs/keyring-linux-x64-musl@1.3.0':
+ resolution: {integrity: sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@napi-rs/keyring-win32-arm64-msvc@1.3.0':
+ resolution: {integrity: sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@napi-rs/keyring-win32-ia32-msvc@1.3.0':
+ resolution: {integrity: sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==}
+ engines: {node: '>= 10'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@napi-rs/keyring-win32-x64-msvc@1.3.0':
+ resolution: {integrity: sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@napi-rs/keyring@1.3.0':
+ resolution: {integrity: sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==}
+ engines: {node: '>= 10'}
+
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
@@ -17742,6 +17835,57 @@ snapshots:
outvariant: 1.4.3
strict-event-emitter: 0.5.1
+ '@napi-rs/keyring-darwin-arm64@1.3.0':
+ optional: true
+
+ '@napi-rs/keyring-darwin-x64@1.3.0':
+ optional: true
+
+ '@napi-rs/keyring-freebsd-x64@1.3.0':
+ optional: true
+
+ '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0':
+ optional: true
+
+ '@napi-rs/keyring-linux-arm64-gnu@1.3.0':
+ optional: true
+
+ '@napi-rs/keyring-linux-arm64-musl@1.3.0':
+ optional: true
+
+ '@napi-rs/keyring-linux-riscv64-gnu@1.3.0':
+ optional: true
+
+ '@napi-rs/keyring-linux-x64-gnu@1.3.0':
+ optional: true
+
+ '@napi-rs/keyring-linux-x64-musl@1.3.0':
+ optional: true
+
+ '@napi-rs/keyring-win32-arm64-msvc@1.3.0':
+ optional: true
+
+ '@napi-rs/keyring-win32-ia32-msvc@1.3.0':
+ optional: true
+
+ '@napi-rs/keyring-win32-x64-msvc@1.3.0':
+ optional: true
+
+ '@napi-rs/keyring@1.3.0':
+ optionalDependencies:
+ '@napi-rs/keyring-darwin-arm64': 1.3.0
+ '@napi-rs/keyring-darwin-x64': 1.3.0
+ '@napi-rs/keyring-freebsd-x64': 1.3.0
+ '@napi-rs/keyring-linux-arm-gnueabihf': 1.3.0
+ '@napi-rs/keyring-linux-arm64-gnu': 1.3.0
+ '@napi-rs/keyring-linux-arm64-musl': 1.3.0
+ '@napi-rs/keyring-linux-riscv64-gnu': 1.3.0
+ '@napi-rs/keyring-linux-x64-gnu': 1.3.0
+ '@napi-rs/keyring-linux-x64-musl': 1.3.0
+ '@napi-rs/keyring-win32-arm64-msvc': 1.3.0
+ '@napi-rs/keyring-win32-ia32-msvc': 1.3.0
+ '@napi-rs/keyring-win32-x64-msvc': 1.3.0
+
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.10.0