Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/impersonate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": minor
---

Add `clerk impersonate` (alias `clerk imp`) to create a short-lived sign-in URL that logs you in as another user, plus `clerk impersonate revoke` to cancel a pending token. Both require `clerk login` and stamp the impersonation with your account for auditing.
5 changes: 5 additions & 0 deletions .changeset/interactive-instance-picker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": patch
---

Resolve the instance explicitly instead of silently defaulting to development: interactive flows (`clerk impersonate`, `clerk impersonate revoke`, `clerk users open`, the create wizard, and the application-picker fallback) now ask in human mode whenever the resolved app has more than one instance and no `--instance` flag pins one, and agent mode errors asking for an explicit `--instance` rather than defaulting — so the same command can't resolve a different instance depending on which instances the app happens to have. User lookups that find no match name the searched app and instance in the error, and instances targeted by raw ID are labeled by their environment type so the production impersonation warning always fires.
38 changes: 20 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,22 +33,24 @@ Options:
-h, --help Display help for command

Commands:
init [options] Initialize Clerk in your project
auth Manage authentication
link [options] Link this project to a Clerk application
unlink [options] Unlink this project from its Clerk application
whoami [options] Show the current logged-in user and linked application
open Open Clerk resources in your browser
apps Manage your Clerk applications
users [options] Manage Clerk users
env Manage environment variables
config Manage instance configuration
enable Enable Clerk features on the linked instance
disable Disable Clerk features on the linked instance
api [options] [endpoint] [filter] Make authenticated requests to the Clerk API
doctor [options] Check your project's Clerk integration health
completion [shell] Generate shell autocompletion script
update [options] Update the Clerk CLI to the latest version
deploy Deploy a Clerk application to production
help [command] Display help for command
init [options] Initialize Clerk in your project
auth Manage authentication
link [options] Link this project to a Clerk application
unlink [options] Unlink this project from its Clerk application
whoami [options] Show the current logged-in user and linked application
open Open Clerk resources in your browser
apps Manage your Clerk applications
users [options] Manage Clerk users
impersonate|imp [options] [user] Impersonate a Clerk user
env Manage environment variables
config Manage instance configuration
enable Enable Clerk features on the linked instance
disable Disable Clerk features on the linked instance
api [options] [endpoint] [filter] Make authenticated requests to the Clerk API
doctor [options] Check your project's Clerk integration health
completion [shell] Generate shell autocompletion script
update [options] Update the Clerk CLI to the latest version
deploy Deploy a Clerk application to production
help [command] Display help for command
bird Play Clerk Bird, a Flappy Bird game in your terminal
```
2 changes: 2 additions & 0 deletions packages/cli-core/src/cli-program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { registerWhoami } from "./commands/whoami/index.ts";
import { registerOpen } from "./commands/open/index.ts";
import { registerApps } from "./commands/apps/index.ts";
import { registerUsers } from "./commands/users/index.ts";
import { registerImpersonate } from "./commands/impersonate/index.ts";
import { registerEnv } from "./commands/env/index.ts";
import { registerConfig } from "./commands/config/index.ts";
import { registerToggles } from "./commands/toggles/index.ts";
Expand Down Expand Up @@ -60,6 +61,7 @@ const registrants: CommandRegistrant[] = [
registerOpen,
registerApps,
registerUsers,
registerImpersonate,
registerEnv,
registerConfig,
registerToggles,
Expand Down
125 changes: 125 additions & 0 deletions packages/cli-core/src/commands/impersonate/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# `clerk impersonate`

Create a short-lived actor token for a Clerk user and print the sign-in URL that
lets you sign in as them ("impersonate"). Alias: `clerk imp`.

## Auth

`clerk impersonate` and `clerk impersonate revoke` both **require `clerk auth
login`** — there is no `--secret-key`-only bypass. The actor token's
`actor.sub` field is stamped with your logged-in email (`cli:<email>`, or
`cli:<email>+<context>` with `--actor <context>`) so every impersonation
session is traceable back to a real Clerk account, not just an API key.

## Usage

```sh
clerk imp # pick a user interactively, then confirm
clerk imp user_2x9k # impersonate a specific user
clerk imp alice@example.com # resolve by exact email match
clerk imp alice --open # open the sign-in URL in your browser immediately
clerk imp user_2x9k --print # print the URL only, no prompt, no browser
clerk imp user_2x9k --yes --expires-in 900 # skip confirmation, 15-minute token
clerk imp user_2x9k --actor oncall # stamp the actor as cli:<you>+oncall
clerk imp revoke act_29w9... # revoke a pending actor token
```

## Options

| Flag | Applies to | Description |
| ------------------------ | ---------- | ---------------------------------------------------------------------------------------------------------------- |
| `[user]` | create | `user_...` ID, exact email, or fuzzy search term. Omit to pick interactively. |
| `<actorTokenId>` | revoke | Actor token ID to revoke (required) |
| `--secret-key <key>` | both | Backend API secret key to use |
| `--app <id>` | both | Application ID to target (works from any directory) |
| `--instance <id>` | both | Instance to target (`dev`, `prod`, or a full instance ID) |
| `--actor <context>` | create | Extra context appended to the actor stamp: `cli:<email>+<context>` |
| `--expires-in <seconds>` | create | Actor token lifetime in seconds, integer >= 1. Defaults to 3600 (1 hour), matching the dashboard's short expiry. |
| `--open` | create | Open the sign-in URL in your browser immediately, skipping the prompt |
| `--print` | create | Print the sign-in URL only — no prompt, no browser |
| `--yes` | create | Skip the confirmation prompt |

`clerk impersonate revoke` never prompts for confirmation — it's a low-risk
operation on an already-pending token.

## User resolution

Given `[user]`:

1. `/^user_[A-Za-z0-9]+$/` → used directly, no lookup.
2. Contains `@` → exact match via `GET /v1/users?email_address=<email>`.
3. Otherwise → fuzzy match via `GET /v1/users?query=<term>`.

Then:

- 0 matches → usage error naming the searched app and instance, e.g.
`No user found matching "alice@example.com" on My Application (development).`
- 1 match → used directly.
- 2+ matches, human mode → an interactive picker (the picker's search box
always starts empty — there's no way to prefill it with your original
search term; see `commands/users/interactive/pick-user.ts`).
- 2+ matches, agent mode → usage error listing candidate user IDs so you can
re-run with a specific `user_...` ID.
- No `[user]` argument, human mode → the interactive picker.
- No `[user]` argument, agent mode → usage error (agent mode never prompts).

## Instance resolution

The app comes from `--app`, the linked profile, or — in human mode with no
linked project — an interactive app picker. In human mode, whenever the
resolved app has more than one instance and no `--instance` flag was passed,
`clerk impersonate` and `clerk impersonate revoke` prompt
"Select an instance to use:" instead of silently defaulting to development —
even in a linked project. Users usually exist on only one instance (and actor
tokens are instance-scoped), so a silent development default makes lookups and
revokes fail confusingly. Agent mode never prompts: when the app has more than
one instance it errors and asks for an explicit `--instance` rather than
defaulting, so the same command can't resolve a different instance depending on
which instances the app happens to have. `--instance` or `--secret-key` always
pins the instance.

## Confirmation and the production guardrail

In human mode, unless `--yes` is passed, `clerk impersonate` asks
"Impersonate `<user>` on `<app>` (`<instance>`)?" defaulting to **No**. When
the target instance's label is `production`, a warning is printed above the
prompt:

> production — signs you in as this user and bypasses their MFA. may count
> against your monthly impersonation quota.

The CLI cannot read your live impersonation quota (Backend API has no
endpoint for it), so this is a generic reminder, not a number. Agent mode
never prompts and proceeds directly.

## Output modes

The sign-in URL from BAPI's response is always printed **verbatim** via
`log.data()` — never reconstructed client-side. Human mode also prints a
`Revoke with: clerk imp revoke <id>` hint to stderr — BAPI has no list
endpoint for actor tokens, so creation is the only moment the ID is visible.

| Condition | Behavior |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--print` | Print the URL, exit. No prompt, no browser. |
| `--open` | Print the URL, open the browser immediately. No prompt. |
| TTY, no `--print`/`--open` | Print the URL, then prompt "Press Enter to open in your browser (Ctrl+C to skip)". |
| Non-TTY, human mode, no `--print`/`--open` | Same as `--print` — never hangs waiting for input that can't arrive. |
| Agent mode | Emit one JSON line via `log.data()`: `{ url, id, userId, actor, appId, appLabel, instanceId, instanceLabel, expiresInSeconds }`. Never prompts, never opens a browser. |

## Clerk API endpoints

| Method | Path | Used by |
| ------ | --------------------------------- | ---------------------------------------------------- |
| `GET` | `/v1/users?email_address=<email>` | Resolving `[user]` when it contains `@` |
| `GET` | `/v1/users?query=<term>` | Resolving `[user]` fuzzy search |
| `GET` | `/v1/users?query=<term>&limit=21` | The interactive user picker (`pickUser`) |
| `POST` | `/v1/actor_tokens` | Creating the actor token (`clerk impersonate`) |
| `POST` | `/v1/actor_tokens/{id}/revoke` | Revoking an actor token (`clerk impersonate revoke`) |

`POST /v1/actor_tokens` returns `402` when impersonation isn't enabled on the
app's subscription plan, and `422` when the impersonation quota is exhausted
(the CLI surfaces `used`/`limit` from the error's `meta` when BAPI includes
them).

No part of this command is mocked or stubbed.
85 changes: 85 additions & 0 deletions packages/cli-core/src/commands/impersonate/actor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test";
import { AuthError } from "../../lib/errors.ts";

const mockGetValidToken = mock();
mock.module("../../lib/credential-store.ts", () => ({
getValidToken: (...args: unknown[]) => mockGetValidToken(...args),
}));

const mockFetchUserInfo = mock();
mock.module("../../lib/token-exchange.ts", () => ({
fetchUserInfo: (...args: unknown[]) => mockFetchUserInfo(...args),
}));

const { requireLoginEmail, buildActorStamp, CLERK_CLI_ISSUER } = await import("./actor.ts");

describe("requireLoginEmail", () => {
beforeEach(() => {
mockGetValidToken.mockResolvedValue("valid-token");
mockFetchUserInfo.mockResolvedValue({ userId: "user_admin", email: "admin@example.com" });
});

afterEach(() => {
mockGetValidToken.mockReset();
mockFetchUserInfo.mockReset();
});

test("returns the login email when a valid token exists", async () => {
await expect(requireLoginEmail()).resolves.toBe("admin@example.com");
});

test("throws AuthError(not_logged_in) when there is no stored token", async () => {
mockGetValidToken.mockResolvedValue(null);

let error: unknown;
try {
await requireLoginEmail();
} catch (caught) {
error = caught;
}

expect(error).toBeInstanceOf(AuthError);
expect((error as AuthError).reason).toBe("not_logged_in");
expect(mockFetchUserInfo).not.toHaveBeenCalled();
});

test("throws AuthError(session_expired) when fetchUserInfo fails", async () => {
mockFetchUserInfo.mockRejectedValue(new Error("401"));

let error: unknown;
try {
await requireLoginEmail();
} catch (caught) {
error = caught;
}

expect(error).toBeInstanceOf(AuthError);
expect((error as AuthError).reason).toBe("session_expired");
});
});

describe("buildActorStamp", () => {
test("stamps `cli:<login-email>` with no --actor context", () => {
expect(buildActorStamp("admin@example.com")).toEqual({
sub: "cli:admin@example.com",
iss: CLERK_CLI_ISSUER,
});
});

test("appends `+<context>` when a --actor context is supplied", () => {
expect(buildActorStamp("admin@example.com", "oncall")).toEqual({
sub: "cli:admin@example.com+oncall",
iss: CLERK_CLI_ISSUER,
});
});

test("does not attempt to parse `+` characters already present in the email", () => {
// Emails can legally contain '+' (plus-addressing). The stamp is a
// write-only audit label — this locks in that we never try to be clever
// about splitting it back apart.
expect(buildActorStamp("admin+test@example.com", "oncall")).toEqual({
sub: "cli:admin+test@example.com+oncall",
iss: CLERK_CLI_ISSUER,
});
});
});
38 changes: 38 additions & 0 deletions packages/cli-core/src/commands/impersonate/actor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { getValidToken } from "../../lib/credential-store.ts";
import { AuthError } from "../../lib/errors.ts";
import { fetchUserInfo } from "../../lib/token-exchange.ts";

export const CLERK_CLI_ISSUER = "clerk-cli";

/**
* Verify the CLI has an active login session and return the operator's
* login email. Impersonation always requires `clerk login` — there is no
* secret-key bypass, because the actor stamp on every actor token must
* identify a real Clerk account for audit purposes.
*/
export async function requireLoginEmail(): Promise<string> {
const token = await getValidToken();
if (!token) {
throw new AuthError({ reason: "not_logged_in" });
}

try {
const userInfo = await fetchUserInfo(token);
return userInfo.email;
} catch {
throw new AuthError({ reason: "session_expired" });
}
}

/**
* Build the actor stamp embedded in every actor token: `cli:<login-email>`,
* or `cli:<login-email>+<context>` when `--actor <context>` is supplied.
* This is a write-only audit label — never parse it back apart.
*/
export function buildActorStamp(
loginEmail: string,
actorContext?: string,
): { sub: string; iss: typeof CLERK_CLI_ISSUER } {
const sub = actorContext ? `cli:${loginEmail}+${actorContext}` : `cli:${loginEmail}`;
return { sub, iss: CLERK_CLI_ISSUER };
}
Loading