Skip to content

Commit 1de7b1d

Browse files
authored
feat: add clerk impersonate command (#373)
* feat(impersonate): scaffold clerk impersonate command wiring * feat(impersonate): add login gate and actor stamp helper * feat(impersonate): add user argument resolution * feat(impersonate): implement actor token creation flow * feat(impersonate): implement actor token revoke flow * test(impersonate): cover completion for imp/impersonate/revoke and regenerate root README * docs(changeset): add impersonate command changeset * docs(impersonate): sync root README help transcript with clerk --help (add bird row) * feat(impersonate): prompt for instance instead of silently defaulting to development Interactive flows now ask which instance to use when the resolved app has more than one and no --instance flag pins it. Zero-match user lookups name the searched app and instance, and instances targeted by raw ID are labeled by environment type so the production warning always fires. Claude-Session: https://claude.ai/code/session_01HoHwrFq6AhrW13j2PkBQjc * refactor(users): collapse instance-prompt condition to a single guard The picker branch already guarantees human mode and an untouched instance hint, so both ternary arms were equivalent; drop the dead appPickedInteractively flag. Claude-Session: https://claude.ai/code/session_01HoHwrFq6AhrW13j2PkBQjc * test(impersonate): cover sk_live_ production warning without instance label * feat(impersonate): print a revoke hint after creating a token BAPI has no list endpoint for actor tokens, so the creation output is the only place a human can learn the act_... ID needed to revoke. Print "Revoke with: clerk imp revoke <id>" to stderr, keeping stdout pipe-clean. Claude-Session: https://claude.ai/code/session_01HoHwrFq6AhrW13j2PkBQjc * refactor(impersonate): extract typed BAPI actor-token and user-search helpers Address review feedback by moving library-esque BAPI calls out of the commands into named, typed helpers: - lib/actor-tokens.ts: createActorToken/revokeActorToken own the actor-token request/response contract; impersonate and revoke consume them. - lib/users.ts: searchUsers centralizes GET /users; resolve-user and pick-user drop their hand-rolled query/limit/body-guard duplication. - lib/errors.ts: add BillingError for standardized plan-gating (402) and quota (422) failures, carrying a reason plus limit/used. Behavior is unchanged; existing command tests plus new lib unit tests cover it. * fix(impersonate): resolve instance explicitly instead of defaulting in agent mode wyattjoh flagged that defaulting to development is data-dependent: the same command resolves a different instance once the app gains a production instance, breaking repeatability. Now, when an app has multiple instances and --instance isn't pinned, human mode prompts and agent mode errors asking for an explicit --instance rather than silently picking development. Also pin --app/--instance in the revoke hint so it can't target a different instance than the token was created on.
1 parent 1409a22 commit 1de7b1d

28 files changed

Lines changed: 1743 additions & 59 deletions

.changeset/impersonate.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"clerk": minor
3+
---
4+
5+
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.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"clerk": patch
3+
---
4+
5+
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.

README.md

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -33,22 +33,24 @@ Options:
3333
-h, --help Display help for command
3434
3535
Commands:
36-
init [options] Initialize Clerk in your project
37-
auth Manage authentication
38-
link [options] Link this project to a Clerk application
39-
unlink [options] Unlink this project from its Clerk application
40-
whoami [options] Show the current logged-in user and linked application
41-
open Open Clerk resources in your browser
42-
apps Manage your Clerk applications
43-
users [options] Manage Clerk users
44-
env Manage environment variables
45-
config Manage instance configuration
46-
enable Enable Clerk features on the linked instance
47-
disable Disable Clerk features on the linked instance
48-
api [options] [endpoint] [filter] Make authenticated requests to the Clerk API
49-
doctor [options] Check your project's Clerk integration health
50-
completion [shell] Generate shell autocompletion script
51-
update [options] Update the Clerk CLI to the latest version
52-
deploy Deploy a Clerk application to production
53-
help [command] Display help for command
36+
init [options] Initialize Clerk in your project
37+
auth Manage authentication
38+
link [options] Link this project to a Clerk application
39+
unlink [options] Unlink this project from its Clerk application
40+
whoami [options] Show the current logged-in user and linked application
41+
open Open Clerk resources in your browser
42+
apps Manage your Clerk applications
43+
users [options] Manage Clerk users
44+
impersonate|imp [options] [user] Impersonate a Clerk user
45+
env Manage environment variables
46+
config Manage instance configuration
47+
enable Enable Clerk features on the linked instance
48+
disable Disable Clerk features on the linked instance
49+
api [options] [endpoint] [filter] Make authenticated requests to the Clerk API
50+
doctor [options] Check your project's Clerk integration health
51+
completion [shell] Generate shell autocompletion script
52+
update [options] Update the Clerk CLI to the latest version
53+
deploy Deploy a Clerk application to production
54+
help [command] Display help for command
55+
bird Play Clerk Bird, a Flappy Bird game in your terminal
5456
```

packages/cli-core/src/cli-program.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { registerWhoami } from "./commands/whoami/index.ts";
1010
import { registerOpen } from "./commands/open/index.ts";
1111
import { registerApps } from "./commands/apps/index.ts";
1212
import { registerUsers } from "./commands/users/index.ts";
13+
import { registerImpersonate } from "./commands/impersonate/index.ts";
1314
import { registerEnv } from "./commands/env/index.ts";
1415
import { registerConfig } from "./commands/config/index.ts";
1516
import { registerToggles } from "./commands/toggles/index.ts";
@@ -60,6 +61,7 @@ const registrants: CommandRegistrant[] = [
6061
registerOpen,
6162
registerApps,
6263
registerUsers,
64+
registerImpersonate,
6365
registerEnv,
6466
registerConfig,
6567
registerToggles,
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# `clerk impersonate`
2+
3+
Create a short-lived actor token for a Clerk user and print the sign-in URL that
4+
lets you sign in as them ("impersonate"). Alias: `clerk imp`.
5+
6+
## Auth
7+
8+
`clerk impersonate` and `clerk impersonate revoke` both **require `clerk auth
9+
login`** — there is no `--secret-key`-only bypass. The actor token's
10+
`actor.sub` field is stamped with your logged-in email (`cli:<email>`, or
11+
`cli:<email>+<context>` with `--actor <context>`) so every impersonation
12+
session is traceable back to a real Clerk account, not just an API key.
13+
14+
## Usage
15+
16+
```sh
17+
clerk imp # pick a user interactively, then confirm
18+
clerk imp user_2x9k # impersonate a specific user
19+
clerk imp alice@example.com # resolve by exact email match
20+
clerk imp alice --open # open the sign-in URL in your browser immediately
21+
clerk imp user_2x9k --print # print the URL only, no prompt, no browser
22+
clerk imp user_2x9k --yes --expires-in 900 # skip confirmation, 15-minute token
23+
clerk imp user_2x9k --actor oncall # stamp the actor as cli:<you>+oncall
24+
clerk imp revoke act_29w9... # revoke a pending actor token
25+
```
26+
27+
## Options
28+
29+
| Flag | Applies to | Description |
30+
| ------------------------ | ---------- | ---------------------------------------------------------------------------------------------------------------- |
31+
| `[user]` | create | `user_...` ID, exact email, or fuzzy search term. Omit to pick interactively. |
32+
| `<actorTokenId>` | revoke | Actor token ID to revoke (required) |
33+
| `--secret-key <key>` | both | Backend API secret key to use |
34+
| `--app <id>` | both | Application ID to target (works from any directory) |
35+
| `--instance <id>` | both | Instance to target (`dev`, `prod`, or a full instance ID) |
36+
| `--actor <context>` | create | Extra context appended to the actor stamp: `cli:<email>+<context>` |
37+
| `--expires-in <seconds>` | create | Actor token lifetime in seconds, integer >= 1. Defaults to 3600 (1 hour), matching the dashboard's short expiry. |
38+
| `--open` | create | Open the sign-in URL in your browser immediately, skipping the prompt |
39+
| `--print` | create | Print the sign-in URL only — no prompt, no browser |
40+
| `--yes` | create | Skip the confirmation prompt |
41+
42+
`clerk impersonate revoke` never prompts for confirmation — it's a low-risk
43+
operation on an already-pending token.
44+
45+
## User resolution
46+
47+
Given `[user]`:
48+
49+
1. `/^user_[A-Za-z0-9]+$/` → used directly, no lookup.
50+
2. Contains `@` → exact match via `GET /v1/users?email_address=<email>`.
51+
3. Otherwise → fuzzy match via `GET /v1/users?query=<term>`.
52+
53+
Then:
54+
55+
- 0 matches → usage error naming the searched app and instance, e.g.
56+
`No user found matching "alice@example.com" on My Application (development).`
57+
- 1 match → used directly.
58+
- 2+ matches, human mode → an interactive picker (the picker's search box
59+
always starts empty — there's no way to prefill it with your original
60+
search term; see `commands/users/interactive/pick-user.ts`).
61+
- 2+ matches, agent mode → usage error listing candidate user IDs so you can
62+
re-run with a specific `user_...` ID.
63+
- No `[user]` argument, human mode → the interactive picker.
64+
- No `[user]` argument, agent mode → usage error (agent mode never prompts).
65+
66+
## Instance resolution
67+
68+
The app comes from `--app`, the linked profile, or — in human mode with no
69+
linked project — an interactive app picker. In human mode, whenever the
70+
resolved app has more than one instance and no `--instance` flag was passed,
71+
`clerk impersonate` and `clerk impersonate revoke` prompt
72+
"Select an instance to use:" instead of silently defaulting to development —
73+
even in a linked project. Users usually exist on only one instance (and actor
74+
tokens are instance-scoped), so a silent development default makes lookups and
75+
revokes fail confusingly. Agent mode never prompts: when the app has more than
76+
one instance it errors and asks for an explicit `--instance` rather than
77+
defaulting, so the same command can't resolve a different instance depending on
78+
which instances the app happens to have. `--instance` or `--secret-key` always
79+
pins the instance.
80+
81+
## Confirmation and the production guardrail
82+
83+
In human mode, unless `--yes` is passed, `clerk impersonate` asks
84+
"Impersonate `<user>` on `<app>` (`<instance>`)?" defaulting to **No**. When
85+
the target instance's label is `production`, a warning is printed above the
86+
prompt:
87+
88+
> production — signs you in as this user and bypasses their MFA. may count
89+
> against your monthly impersonation quota.
90+
91+
The CLI cannot read your live impersonation quota (Backend API has no
92+
endpoint for it), so this is a generic reminder, not a number. Agent mode
93+
never prompts and proceeds directly.
94+
95+
## Output modes
96+
97+
The sign-in URL from BAPI's response is always printed **verbatim** via
98+
`log.data()` — never reconstructed client-side. Human mode also prints a
99+
`Revoke with: clerk imp revoke <id>` hint to stderr — BAPI has no list
100+
endpoint for actor tokens, so creation is the only moment the ID is visible.
101+
102+
| Condition | Behavior |
103+
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
104+
| `--print` | Print the URL, exit. No prompt, no browser. |
105+
| `--open` | Print the URL, open the browser immediately. No prompt. |
106+
| TTY, no `--print`/`--open` | Print the URL, then prompt "Press Enter to open in your browser (Ctrl+C to skip)". |
107+
| Non-TTY, human mode, no `--print`/`--open` | Same as `--print` — never hangs waiting for input that can't arrive. |
108+
| Agent mode | Emit one JSON line via `log.data()`: `{ url, id, userId, actor, appId, appLabel, instanceId, instanceLabel, expiresInSeconds }`. Never prompts, never opens a browser. |
109+
110+
## Clerk API endpoints
111+
112+
| Method | Path | Used by |
113+
| ------ | --------------------------------- | ---------------------------------------------------- |
114+
| `GET` | `/v1/users?email_address=<email>` | Resolving `[user]` when it contains `@` |
115+
| `GET` | `/v1/users?query=<term>` | Resolving `[user]` fuzzy search |
116+
| `GET` | `/v1/users?query=<term>&limit=21` | The interactive user picker (`pickUser`) |
117+
| `POST` | `/v1/actor_tokens` | Creating the actor token (`clerk impersonate`) |
118+
| `POST` | `/v1/actor_tokens/{id}/revoke` | Revoking an actor token (`clerk impersonate revoke`) |
119+
120+
`POST /v1/actor_tokens` returns `402` when impersonation isn't enabled on the
121+
app's subscription plan, and `422` when the impersonation quota is exhausted
122+
(the CLI surfaces `used`/`limit` from the error's `meta` when BAPI includes
123+
them).
124+
125+
No part of this command is mocked or stubbed.
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test";
2+
import { AuthError } from "../../lib/errors.ts";
3+
4+
const mockGetValidToken = mock();
5+
mock.module("../../lib/credential-store.ts", () => ({
6+
getValidToken: (...args: unknown[]) => mockGetValidToken(...args),
7+
}));
8+
9+
const mockFetchUserInfo = mock();
10+
mock.module("../../lib/token-exchange.ts", () => ({
11+
fetchUserInfo: (...args: unknown[]) => mockFetchUserInfo(...args),
12+
}));
13+
14+
const { requireLoginEmail, buildActorStamp, CLERK_CLI_ISSUER } = await import("./actor.ts");
15+
16+
describe("requireLoginEmail", () => {
17+
beforeEach(() => {
18+
mockGetValidToken.mockResolvedValue("valid-token");
19+
mockFetchUserInfo.mockResolvedValue({ userId: "user_admin", email: "admin@example.com" });
20+
});
21+
22+
afterEach(() => {
23+
mockGetValidToken.mockReset();
24+
mockFetchUserInfo.mockReset();
25+
});
26+
27+
test("returns the login email when a valid token exists", async () => {
28+
await expect(requireLoginEmail()).resolves.toBe("admin@example.com");
29+
});
30+
31+
test("throws AuthError(not_logged_in) when there is no stored token", async () => {
32+
mockGetValidToken.mockResolvedValue(null);
33+
34+
let error: unknown;
35+
try {
36+
await requireLoginEmail();
37+
} catch (caught) {
38+
error = caught;
39+
}
40+
41+
expect(error).toBeInstanceOf(AuthError);
42+
expect((error as AuthError).reason).toBe("not_logged_in");
43+
expect(mockFetchUserInfo).not.toHaveBeenCalled();
44+
});
45+
46+
test("throws AuthError(session_expired) when fetchUserInfo fails", async () => {
47+
mockFetchUserInfo.mockRejectedValue(new Error("401"));
48+
49+
let error: unknown;
50+
try {
51+
await requireLoginEmail();
52+
} catch (caught) {
53+
error = caught;
54+
}
55+
56+
expect(error).toBeInstanceOf(AuthError);
57+
expect((error as AuthError).reason).toBe("session_expired");
58+
});
59+
});
60+
61+
describe("buildActorStamp", () => {
62+
test("stamps `cli:<login-email>` with no --actor context", () => {
63+
expect(buildActorStamp("admin@example.com")).toEqual({
64+
sub: "cli:admin@example.com",
65+
iss: CLERK_CLI_ISSUER,
66+
});
67+
});
68+
69+
test("appends `+<context>` when a --actor context is supplied", () => {
70+
expect(buildActorStamp("admin@example.com", "oncall")).toEqual({
71+
sub: "cli:admin@example.com+oncall",
72+
iss: CLERK_CLI_ISSUER,
73+
});
74+
});
75+
76+
test("does not attempt to parse `+` characters already present in the email", () => {
77+
// Emails can legally contain '+' (plus-addressing). The stamp is a
78+
// write-only audit label — this locks in that we never try to be clever
79+
// about splitting it back apart.
80+
expect(buildActorStamp("admin+test@example.com", "oncall")).toEqual({
81+
sub: "cli:admin+test@example.com+oncall",
82+
iss: CLERK_CLI_ISSUER,
83+
});
84+
});
85+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { getValidToken } from "../../lib/credential-store.ts";
2+
import { AuthError } from "../../lib/errors.ts";
3+
import { fetchUserInfo } from "../../lib/token-exchange.ts";
4+
5+
export const CLERK_CLI_ISSUER = "clerk-cli";
6+
7+
/**
8+
* Verify the CLI has an active login session and return the operator's
9+
* login email. Impersonation always requires `clerk login` — there is no
10+
* secret-key bypass, because the actor stamp on every actor token must
11+
* identify a real Clerk account for audit purposes.
12+
*/
13+
export async function requireLoginEmail(): Promise<string> {
14+
const token = await getValidToken();
15+
if (!token) {
16+
throw new AuthError({ reason: "not_logged_in" });
17+
}
18+
19+
try {
20+
const userInfo = await fetchUserInfo(token);
21+
return userInfo.email;
22+
} catch {
23+
throw new AuthError({ reason: "session_expired" });
24+
}
25+
}
26+
27+
/**
28+
* Build the actor stamp embedded in every actor token: `cli:<login-email>`,
29+
* or `cli:<login-email>+<context>` when `--actor <context>` is supplied.
30+
* This is a write-only audit label — never parse it back apart.
31+
*/
32+
export function buildActorStamp(
33+
loginEmail: string,
34+
actorContext?: string,
35+
): { sub: string; iss: typeof CLERK_CLI_ISSUER } {
36+
const sub = actorContext ? `cli:${loginEmail}+${actorContext}` : `cli:${loginEmail}`;
37+
return { sub, iss: CLERK_CLI_ISSUER };
38+
}

0 commit comments

Comments
 (0)