From d520013e4872c25a416f8c3b298f6c5c750864ac Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Mon, 21 Sep 2026 15:58:58 +0530 Subject: [PATCH 1/3] fix(auth): whoami stops reporting a session the engine found dead as signed in `auth whoami` looks the identity up with GET /v1/me as a best-effort enrichment and swallowed every failure of it, so it works offline. That blanket catch also swallowed the engine's definitive verdict on the credential: a refresh refused with invalid_grant (the stored session already compare-and-cleared), a credential that could never be renewed, or a session ended underneath the process. whoami then printed `authenticated: true` with the dead session's workspace. Automation trusts that answer: create-prisma runs `prisma auth whoami --json`, skips sign-in, and its next command fails with CLI.CREDENTIALS_REQUIRED. The lookup now tells the verdict apart from an outage by the structured error's code, as the engine raised it, never by origin or message: - CLI.CREDENTIALS_REQUIRED reads as signed out: `authenticated: false`, exit 0, the existing Sign in next action, the same result an unauthenticated user gets. This is the question whoami exists to answer, and it is what legacy did for a 401 on its lookup (1271c57d). - AUTH.SERVICE_TOKEN_REJECTED settles as itself, exit 2, as AUTH.SERVICE_TOKEN_EMPTY already does for this command. Signing in cannot help while PRISMA_SERVICE_TOKEN is set, so "signed out" with a Sign in action would send the reader the wrong way. - Everything else (network error, timeout, 5xx, CLI.AUTH_SERVICE_ERROR) still answers from the credential's own claims, unchanged. Ctrl-C still outranks whatever the interrupted request threw. No new engine API, result field, or flag. The one existing test that pinned the old behaviour for a 401 on an environment token now asserts the rejection; the never-answering-host test beside it keeps covering the claims fallback for that credential. Verified with the built binary against a local HTTP server: 401 plus invalid_grant gives `authenticated: false` and an emptied state file; 401 plus a 503 token endpoint still gives the claims and leaves the state file untouched. Co-Authored-By: Claude Fable 5.1 --- .../assets/s2/parity-divergences.md | 2 +- docs/product/error-conventions.md | 18 ++ docs/reference/error-reference.md | 4 +- packages/cli/src/commands/auth/whoami.ts | 81 +++++-- packages/cli/tests/auth.test.ts | 81 ++++++- packages/cli/tests/whoami.test.ts | 216 +++++++++++++++++- 6 files changed, 376 insertions(+), 26 deletions(-) diff --git a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md index ae15f6f4..116c6729 100644 --- a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md +++ b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md @@ -61,7 +61,7 @@ The legacy result was `AuthStateResult` (`authenticated`/`provider`/`user`/`work - `source` is new (`"stored"` | `"environment"`) and comes from the credential's origin; `expiresAt` is the credential's expiry. - `user` keeps `id`, `email` and `name`. There is one identity type for both the claimed and the fetched identity (design §11.6); a token's claims carry an id and an email, and only the online lookup supplies a name, so `name` is null offline. The human card's `user` row shows the email, or is omitted when there is none. - **A service token reports no user at all.** Its subject names a workspace rather than a person, so `user` is null and the workspace is read from that subject. Reporting `workspace:` as a user id was a defect. -- Identity display: the credential manager decodes the credential's own claims, and `/v1/me` is a best-effort online enrichment that wins field by field where it disagrees. whoami does not branch on the origin — it attempts the enrichment for an environment credential too, and falls back to the claims when the request fails. **This restores legacy behaviour that rev 5 had dropped:** a stored session offline now shows the claim-derived user again, where rev 5 showed the workspace and no user. +- Identity display: the credential manager decodes the credential's own claims, and `/v1/me` is a best-effort online enrichment that wins field by field where it disagrees. whoami does not branch on the origin — it attempts the enrichment for an environment credential too, and falls back to the claims when the request fails without a verdict on the credential (offline, timeout, 5xx, `CLI.AUTH_SERVICE_ERROR`). The engine's verdict is never overridden by the claims: `CLI.CREDENTIALS_REQUIRED` from the lookup reads as signed out (exit 0), as legacy read a 401 on its workspace lookup, and `AUTH.SERVICE_TOKEN_REJECTED` settles as itself. **This restores legacy behaviour that rev 5 had dropped:** a stored session offline now shows the claim-derived user again, where rev 5 showed the workspace and no user. - **A credential nothing names renders no workspace at all.** An environment token whose claims carry no workspace reports `"workspace": null` and omits the workspace row from the human card. It is never an empty string and never the literal `undefined` — rev 5 wrote `workspaceId: ""` in that case. - Signed out still exits 0. diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index 1f8503dd..aa7360b0 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -64,6 +64,24 @@ Examples: Operational errors may be translated into stable structured envelopes when that improves recovery, but they should not be disguised as programming bugs. +`auth whoami` looks up the signed-in identity online as a best-effort +enrichment, and an operational failure of that lookup never fails the command. +A network error, a timeout, a 5xx response, or `CLI.AUTH_SERVICE_ERROR` leaves +it answering from the stored credential's own claims, so it works offline. + +The engine's verdict on the credential itself is not such a failure, and whoami +never reports `authenticated: true` for a credential the same run found +unusable: + +- `CLI.CREDENTIALS_REQUIRED` — the session expired beyond refresh, or ended + while the command ran. That is the question whoami exists to answer, so it + answers: signed out, `authenticated: false`, exit `0`, with the `Sign in` next + action — the same result as when no credential is stored. +- `AUTH.SERVICE_TOKEN_REJECTED` — the API refused the `PRISMA_SERVICE_TOKEN` + credential. The error settles as itself, exit `2`, as `AUTH.SERVICE_TOKEN_EMPTY` + does: signing in cannot help while the variable is set, so the answer must name + the variable instead of suggesting `auth login`. + ### Bug An unexpected fault or invariant break where the system cannot reliably continue. diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 75964ad0..59824884 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -52,7 +52,7 @@ The `PRISMA_SERVICE_TOKEN` environment variable is set but blank; a blank token ### AUTH.SERVICE_TOKEN_REJECTED -The management API rejected (401) the service token supplied through `PRISMA_SERVICE_TOKEN`; such a token carries no refresh token and can never be renewed, and nothing stored is cleared. Built only through the shared `credentialRejectedError` dispatcher in the engine's API request path — the one place wording differs by credential origin (a stored session with the same failure gets `CLI.CREDENTIALS_REQUIRED` instead). The suggested action is to replace the variable with a valid service token or unset it to fall back to stored sessions. Meta: none. +The management API rejected (401) the service token supplied through `PRISMA_SERVICE_TOKEN`; such a token carries no refresh token and can never be renewed, and nothing stored is cleared. Built only through the shared `credentialRejectedError` dispatcher in the engine's API request path — the one place wording differs by credential origin (a stored session with the same failure gets `CLI.CREDENTIALS_REQUIRED` instead). `prisma auth whoami` lets this error settle as itself when its identity lookup meets it, rather than reporting the rejected token as signed in. The suggested action is to replace the variable with a valid service token or unset it to fall back to stored sessions. Meta: none. ### AUTH.SESSIONS_UNSUPPORTED @@ -146,7 +146,7 @@ The advisory lock on the stored-credentials file was held by another prisma proc ### CLI.CREDENTIALS_REQUIRED -The command needs a signed-in credential and none is usable. One constructor covers five reasons: not signed in at all, an expired session, a session expiring too soon for a command that hands credentials to a child process (which cannot refresh them), a workspace session that ended mid-run, and workspace sessions held with none selected as current. Raised identically by the engine's needs check, `ctx.activeCredential`, and the request path; next actions point at signing in or `prisma auth workspace use`. Meta: none. +The command needs a signed-in credential and none is usable. One constructor covers five reasons: not signed in at all, an expired session, a session expiring too soon for a command that hands credentials to a child process (which cannot refresh them), a workspace session that ended mid-run, and workspace sessions held with none selected as current. Raised identically by the engine's needs check, `ctx.activeCredential`, and the request path; next actions point at signing in or `prisma auth workspace use`. `prisma auth whoami` reports being signed out rather than failing with this code: no credential at all, or a session its identity lookup finds expired or ended, is `authenticated: false` with exit `0` (sessions held with none selected still fails, because signing in is not the only fix). Meta: none. ### CLI.CREDENTIALS_UNREADABLE diff --git a/packages/cli/src/commands/auth/whoami.ts b/packages/cli/src/commands/auth/whoami.ts index be9ebfad..a0cbcb37 100644 --- a/packages/cli/src/commands/auth/whoami.ts +++ b/packages/cli/src/commands/auth/whoami.ts @@ -5,7 +5,11 @@ import { type ManagementApiClient, type Presentations, } from "@prisma/cli-engine"; -import { type NextAction, ok } from "@prisma/cli-engine/protocol"; +import { + CliStructuredError, + type NextAction, + ok, +} from "@prisma/cli-engine/protocol"; import { CLI_NAME } from "../../cli-name"; import { credentialFieldRows, @@ -41,12 +45,46 @@ export interface WhoamiResult { * and never answers would otherwise hold the command for minutes. */ const ENRICHMENT_TIMEOUT_MS = 3_000; -/** Best-effort online enrichment: whoami works offline, so any failure - * leaves the identity as whatever the credential's own claims said. */ -async function fetchedIdentity( +/** + * What the `/v1/me` lookup established. `identity` is absent whenever + * the lookup could not supply one; `usable: false` is the engine's own + * verdict, reached during this run, that the stored session is expired + * beyond refresh or has ended underneath the process. + */ +type Lookup = + | { + readonly usable: true; + readonly identity: CredentialIdentity | undefined; + } + | { readonly usable: false }; + +const NO_IDENTITY: Lookup = { usable: true, identity: undefined }; + +/** + * Online enrichment, best-effort for everything that leaves the + * credential's standing unknown: whoami works offline, so a network + * failure, a timeout, a 5xx, or the auth service failing transiently + * (CLI.AUTH_SERVICE_ERROR — nothing was cleared) leaves the identity as + * whatever the credential's own claims said. + * + * Two failures are not unknowns. They are the engine's definitive + * verdict on the credential in force, told apart by the structured + * error's code exactly as the engine raised it — never by origin, and + * never by message: + * + * - CLI.CREDENTIALS_REQUIRED: the stored session is expired beyond + * refresh or has ended. That is the state whoami exists to report, so + * it is answered as signed out rather than raised. + * - AUTH.SERVICE_TOKEN_REJECTED: the environment's token was refused. + * Signing in cannot fix that while the variable is set, so "signed + * out" would send the reader the wrong way; the error names the + * variable and settles as itself, as AUTH.SERVICE_TOKEN_EMPTY already + * does for this command. + */ +async function lookUpIdentity( api: ManagementApiClient, signal: AbortSignal, -): Promise { +): Promise { const bounded = AbortSignal.any([ signal, AbortSignal.timeout(ENRICHMENT_TIMEOUT_MS), @@ -55,16 +93,27 @@ async function fetchedIdentity( const { data } = await api.GET("/v1/me", { signal: bounded }); const user = data?.data?.user; if (!user) { - return undefined; + return NO_IDENTITY; } return { - userId: user.id ?? undefined, - email: user.email ?? undefined, - name: user.name ?? undefined, + usable: true, + identity: { + userId: user.id ?? undefined, + email: user.email ?? undefined, + name: user.name ?? undefined, + }, }; - } catch { + } catch (cause) { signal.throwIfAborted(); - return undefined; + if (CliStructuredError.is(cause)) { + if (cause.code === "CLI.CREDENTIALS_REQUIRED") { + return { usable: false }; + } + if (cause.code === "AUTH.SERVICE_TOKEN_REJECTED") { + throw cause; + } + } + return NO_IDENTITY; } } @@ -134,13 +183,19 @@ export const authWhoamiCommand = defineCommand({ examples: ["auth whoami", "auth whoami --json"], }, handler: async (_args, ctx) => { - const credential = await ctx.activeCredential(); + const held = await ctx.activeCredential(); + const lookup = + held === null ? NO_IDENTITY : await lookUpIdentity(ctx.api, ctx.signal); + // A credential the engine has just ruled unusable is not one this + // process is signed in with: it is reported exactly as no + // credential is, never as the workspace it used to open. + const credential = lookup.usable ? held : null; const identity = credential === null ? null : mergedIdentity( credential.identity, - await fetchedIdentity(ctx.api, ctx.signal), + lookup.usable ? lookup.identity : undefined, ); const result: WhoamiResult = { authenticated: credential !== null, diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index 2a07e33d..a070b912 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -805,18 +805,20 @@ describe("the environment credential carries no refresh token", () => { }); /** §11.6: whoami does not branch on origin — it attempts the same - * online enrichment for an environment credential, and falls back to - * the token's own claims when the request fails. */ - it("falls back to the env token's own claims when the enrichment is rejected", async () => { + * online enrichment for an environment credential. A lookup that + * fails without a verdict falls back to the token's own claims (the + * next test); a 401 is the engine's verdict that the token is + * refused, and whoami does not answer "signed in" over it. Signing + * in would not help while the variable is set, so the error that + * names the variable settles as itself. */ + it("settles the engine's rejection when the API refuses the env token", async () => { const cli = await cliAgainstA401Server(); const result = await cli.run(["auth", "whoami", "--json"]); - expect(result.exitCode).toBe(0); - expect(resultOf(result)).toMatchObject({ - source: "environment", - user: { id: "usr_env", email: null }, - }); + expect(result.exitCode).toBe(2); + expect(errorOf(result).code).toBe("AUTH.SERVICE_TOKEN_REJECTED"); + expect(result.stdout).not.toContain('"authenticated":true'); expect(paths).toEqual(["/v1/me"]); }); @@ -864,6 +866,69 @@ describe("the environment credential carries no refresh token", () => { }, 20_000); }); +/** The request path end to end, with no structured error written by + * the test: the API answers 401, the token endpoint answers + * invalid_grant, the SDK compare-and-clears the stored session, and + * the engine maps that to the expired CLI.CREDENTIALS_REQUIRED. */ +describe("whoami when the refresh token is refused", () => { + let server: Server | undefined; + + afterEach(async () => { + const running = server; + server = undefined; + if (running !== undefined) { + await new Promise((resolve) => running.close(() => resolve())); + } + }); + + it("reports signed out, not the workspace of the session it just lost", async () => { + const paths: string[] = []; + server = createServer((request, response) => { + paths.push(request.url ?? ""); + const refused = request.url === "/token"; + response.writeHead(refused ? 400 : 401, { + "content-type": "application/json", + }); + response.end( + JSON.stringify( + refused ? { error: "invalid_grant" } : { error: "unauthorized" }, + ), + ); + }); + await new Promise((resolve) => { + server?.listen(0, "127.0.0.1", () => resolve()); + }); + const port = (server.address() as AddressInfo).port; + const baseUrl = `http://127.0.0.1:${port}`; + const cli = createTestCli({ + commands: COMMANDS, + groups: GROUPS, + sessions: [record("ws_1", "Acme Inc")], + selectedWorkspaceId: "ws_1", + managementApiClientConfig: { + clientId: "test-client-id", + redirectUri: `${baseUrl}/auth/callback`, + apiBaseUrl: baseUrl, + authBaseUrl: baseUrl, + }, + now: () => new Date(0), + }); + + const result = await cli.run(["auth", "whoami", "--json"]); + + expect(result.exitCode).toBe(0); + expect(resultOf(result)).toEqual({ + authenticated: false, + workspace: null, + user: null, + source: null, + expiresAt: null, + }); + expect(paths).toEqual(["/v1/me", "/token"]); + expect(cli.credentialManager.state().sessions).toEqual([]); + }); +}); + describe("a blank service token is never an override", () => { for (const [name, token] of [ ["blank", ""], diff --git a/packages/cli/tests/whoami.test.ts b/packages/cli/tests/whoami.test.ts index 8d9c9122..0bc622d3 100644 --- a/packages/cli/tests/whoami.test.ts +++ b/packages/cli/tests/whoami.test.ts @@ -3,8 +3,15 @@ * manager: the card, the json stream, and the engine's early * credentials failure. */ -import { defineCommand, type ManagementApiClient } from "@prisma/cli-engine"; -import { ok } from "@prisma/cli-engine/protocol"; +import { + authServiceError, + credentialRejectedError, + credentialsRequiredError, + defineCommand, + type ManagementApiClient, + SERVICE_TOKEN_ENV_VAR, +} from "@prisma/cli-engine"; +import { type CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import { createTestCli, mintTestJwt, @@ -33,6 +40,31 @@ const OFFLINE_API = { }, } as unknown as ManagementApiClient; +/** ctx.api as the engine's request path leaves it when a request + * fails: the structured error its own constructors build, thrown + * unwrapped. */ +function apiFailingWith(failure: CliStructuredError): ManagementApiClient { + return { + GET: async () => { + throw failure; + }, + } as unknown as ManagementApiClient; +} + +const SIGNED_OUT_RESULT = { + authenticated: false, + workspace: null, + user: null, + source: null, + expiresAt: null, +}; + +const SIGN_IN_ACTION = { + kind: "run-command", + label: "Sign in", + command: "prisma auth login", +}; + const IDENTIFIED_API = { GET: async () => ({ data: { @@ -282,6 +314,186 @@ describe("prisma auth whoami", () => { }); }); + /** The enrichment is best-effort for failures that leave the + * credential's standing unknown. The engine's verdict that the + * session is over is not one of those: automation reads + * `authenticated` to decide whether to sign in, and a `true` for a + * session the same run found dead makes its next command fail. */ + describe("when the engine rules the credential unusable during the lookup", () => { + for (const [name, reason] of [ + ["expired beyond refresh", "expired"], + ["ended underneath the process", "session-ended"], + ] as const) { + it(`reports a session ${name} as signed out, exit 0, with the Sign in action`, async () => { + const cli = makeCli({ + sessions: [SESSION], + selectedWorkspaceId: "ws_123", + client: apiFailingWith(credentialsRequiredError(reason)), + }); + + const json = await cli.run(["auth", "whoami", "--json"]); + + expect(json.exitCode).toBe(0); + const frame = json.json[0]; + if (frame.kind !== "result") { + throw new Error("expected a result frame"); + } + expect(frame.envelope).toEqual({ + ok: true, + commandId: "auth.whoami", + result: SIGNED_OUT_RESULT, + exitCode: 0, + diagnostics: [], + nextActions: [SIGN_IN_ACTION], + }); + expect(json.stdout).not.toContain("ws_123"); + expect(json.stdout).not.toContain("Acme Inc"); + + const human = await cli.run(["auth", "whoami"], { + isTty: { stdout: true }, + }); + + expect(human.exitCode).toBe(0); + expect(human.stdout).toBe("status: signed out\n"); + expect(human.stderr).toBe( + "ℹ Showing the active authenticated identity.\n" + + "\n" + + "status: signed out\n" + + "\n" + + "→ Sign in: prisma auth login\n", + ); + }); + } + + /** Signing in cannot fix a refused PRISMA_SERVICE_TOKEN: the + * variable keeps overriding whatever session a login stores. The + * honest answer names the variable, as the blank-token error + * already does for this command. */ + it("lets a rejected environment credential settle as its own error instead of claiming it", async () => { + const cli = makeCli({ + environmentCredential: { + token: mintTestJwt({ workspace_id: "ws_env", sub: "usr_env" }), + refreshToken: undefined, + expiresAt: undefined, + }, + client: apiFailingWith( + credentialRejectedError( + { source: "environment" }, + SERVICE_TOKEN_ENV_VAR, + ), + ), + }); + + const json = await cli.run(["auth", "whoami", "--json"]); + + expect(json.exitCode).toBe(2); + const frame = json.json[0]; + if (frame.kind !== "result") { + throw new Error("expected a result frame"); + } + expect(frame.envelope).toMatchObject({ + ok: false, + error: { code: "AUTH.SERVICE_TOKEN_REJECTED" }, + }); + expect(json.stdout).not.toContain('"authenticated":true'); + + const human = await cli.run(["auth", "whoami"], { + isTty: { stdout: true }, + }); + + expect(human.exitCode).toBe(2); + expect(human.stdout).toBe(""); + expect(human.stderr).toBe( + "✘ [AUTH.SERVICE_TOKEN_REJECTED] The management API rejected the service token from PRISMA_SERVICE_TOKEN.\n" + + "→ Replace PRISMA_SERVICE_TOKEN with a valid service token, or unset it to use your stored sessions.\n", + ); + }); + + /** The same dispatcher hands a stored credential that could never + * be renewed the expired wording, so it reads as signed out. */ + it("reports a rejected stored credential as signed out", async () => { + const result = await makeCli({ + sessions: [SESSION], + selectedWorkspaceId: "ws_123", + client: apiFailingWith( + credentialRejectedError({ source: "stored" }, SERVICE_TOKEN_ENV_VAR), + ), + }).run(["auth", "whoami", "--json"]); + + expect(result.exitCode).toBe(0); + const frame = result.json[0]; + if (frame.kind !== "result") { + throw new Error("expected a result frame"); + } + expect(frame.envelope).toMatchObject({ + ok: true, + result: SIGNED_OUT_RESULT, + nextActions: [SIGN_IN_ACTION], + }); + }); + }); + + /** CLI.AUTH_SERVICE_ERROR is the auth service failing, not the + * credential: nothing was cleared and signing in is not the fix, so + * it is one more way of being offline. */ + it("still answers from the claims when the auth service fails transiently", async () => { + const result = await makeCli({ + sessions: [SESSION], + selectedWorkspaceId: "ws_123", + client: apiFailingWith(authServiceError()), + }).run(["auth", "whoami", "--json"]); + + expect(result.exitCode).toBe(0); + const frame = result.json[0]; + if (frame.kind !== "result") { + throw new Error("expected a result frame"); + } + expect(frame.envelope).toMatchObject({ + ok: true, + result: { + authenticated: true, + workspace: { id: "ws_123", name: "Acme Inc" }, + user: { id: "usr_456", email: null, name: null }, + source: "stored", + }, + nextActions: [], + }); + }); + + /** Ctrl-C outranks whatever the interrupted request went on to throw + * — including a verdict on the credential, which must not turn an + * interrupt into an answer. */ + for (const [name, thrown] of [ + ["an abort error", () => new DOMException("aborted", "AbortError")], + ["a credentials verdict", () => credentialsRequiredError("expired")], + ] as const) { + it(`settles CLI.ABORTED when the interrupt lands mid-lookup and the request throws ${name}`, async () => { + const controller = new AbortController(); + const interrupted = { + GET: async () => { + controller.abort(); + throw thrown(); + }, + } as unknown as ManagementApiClient; + + const result = await makeCli({ + sessions: [SESSION], + selectedWorkspaceId: "ws_123", + client: interrupted, + }).run(["auth", "whoami", "--json"], { abort: controller.signal }); + + expect(result.exitCode).toBe(130); + const frame = result.json[0]; + if (frame.kind !== "result") { + throw new Error("expected a result frame"); + } + expect(frame.envelope).toMatchObject({ + ok: false, + error: { code: "CLI.ABORTED" }, + }); + }); + } + it("renders the unchanged presentation under --quiet (a log-level alias)", async () => { const result = await signedInCli().run(["auth", "whoami", "--quiet"], { isTty: { stdout: true }, From 0cc28a188590d733ea77402a65ef649ebe37152f Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Mon, 21 Sep 2026 16:31:17 +0530 Subject: [PATCH 2/3] chore(auth): trim the whoami fix to its minimum Shorten the comments in whoami.ts and keep fetchedIdentity and the handler's `credential` name, so the source diff stays small. Drop the error-conventions passage, restore the parity record, and keep one sentence per touched error-reference entry. Keep three tests: expired session reads as signed out, a refused env token settles as itself, and a transient auth-service failure still answers from the claims. Behaviour is unchanged. Co-Authored-By: Claude Fable 5.1 --- .../assets/s2/parity-divergences.md | 2 +- docs/product/error-conventions.md | 18 -- docs/reference/error-reference.md | 4 +- packages/cli/src/commands/auth/whoami.ts | 77 ++----- packages/cli/tests/auth.test.ts | 73 +------ packages/cli/tests/whoami.test.ts | 199 +++--------------- 6 files changed, 49 insertions(+), 324 deletions(-) diff --git a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md index 116c6729..ae15f6f4 100644 --- a/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md +++ b/.drive/projects/prisma-cli-v8/assets/s2/parity-divergences.md @@ -61,7 +61,7 @@ The legacy result was `AuthStateResult` (`authenticated`/`provider`/`user`/`work - `source` is new (`"stored"` | `"environment"`) and comes from the credential's origin; `expiresAt` is the credential's expiry. - `user` keeps `id`, `email` and `name`. There is one identity type for both the claimed and the fetched identity (design §11.6); a token's claims carry an id and an email, and only the online lookup supplies a name, so `name` is null offline. The human card's `user` row shows the email, or is omitted when there is none. - **A service token reports no user at all.** Its subject names a workspace rather than a person, so `user` is null and the workspace is read from that subject. Reporting `workspace:` as a user id was a defect. -- Identity display: the credential manager decodes the credential's own claims, and `/v1/me` is a best-effort online enrichment that wins field by field where it disagrees. whoami does not branch on the origin — it attempts the enrichment for an environment credential too, and falls back to the claims when the request fails without a verdict on the credential (offline, timeout, 5xx, `CLI.AUTH_SERVICE_ERROR`). The engine's verdict is never overridden by the claims: `CLI.CREDENTIALS_REQUIRED` from the lookup reads as signed out (exit 0), as legacy read a 401 on its workspace lookup, and `AUTH.SERVICE_TOKEN_REJECTED` settles as itself. **This restores legacy behaviour that rev 5 had dropped:** a stored session offline now shows the claim-derived user again, where rev 5 showed the workspace and no user. +- Identity display: the credential manager decodes the credential's own claims, and `/v1/me` is a best-effort online enrichment that wins field by field where it disagrees. whoami does not branch on the origin — it attempts the enrichment for an environment credential too, and falls back to the claims when the request fails. **This restores legacy behaviour that rev 5 had dropped:** a stored session offline now shows the claim-derived user again, where rev 5 showed the workspace and no user. - **A credential nothing names renders no workspace at all.** An environment token whose claims carry no workspace reports `"workspace": null` and omits the workspace row from the human card. It is never an empty string and never the literal `undefined` — rev 5 wrote `workspaceId: ""` in that case. - Signed out still exits 0. diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index aa7360b0..1f8503dd 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -64,24 +64,6 @@ Examples: Operational errors may be translated into stable structured envelopes when that improves recovery, but they should not be disguised as programming bugs. -`auth whoami` looks up the signed-in identity online as a best-effort -enrichment, and an operational failure of that lookup never fails the command. -A network error, a timeout, a 5xx response, or `CLI.AUTH_SERVICE_ERROR` leaves -it answering from the stored credential's own claims, so it works offline. - -The engine's verdict on the credential itself is not such a failure, and whoami -never reports `authenticated: true` for a credential the same run found -unusable: - -- `CLI.CREDENTIALS_REQUIRED` — the session expired beyond refresh, or ended - while the command ran. That is the question whoami exists to answer, so it - answers: signed out, `authenticated: false`, exit `0`, with the `Sign in` next - action — the same result as when no credential is stored. -- `AUTH.SERVICE_TOKEN_REJECTED` — the API refused the `PRISMA_SERVICE_TOKEN` - credential. The error settles as itself, exit `2`, as `AUTH.SERVICE_TOKEN_EMPTY` - does: signing in cannot help while the variable is set, so the answer must name - the variable instead of suggesting `auth login`. - ### Bug An unexpected fault or invariant break where the system cannot reliably continue. diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 59824884..8c05e80d 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -52,7 +52,7 @@ The `PRISMA_SERVICE_TOKEN` environment variable is set but blank; a blank token ### AUTH.SERVICE_TOKEN_REJECTED -The management API rejected (401) the service token supplied through `PRISMA_SERVICE_TOKEN`; such a token carries no refresh token and can never be renewed, and nothing stored is cleared. Built only through the shared `credentialRejectedError` dispatcher in the engine's API request path — the one place wording differs by credential origin (a stored session with the same failure gets `CLI.CREDENTIALS_REQUIRED` instead). `prisma auth whoami` lets this error settle as itself when its identity lookup meets it, rather than reporting the rejected token as signed in. The suggested action is to replace the variable with a valid service token or unset it to fall back to stored sessions. Meta: none. +The management API rejected (401) the service token supplied through `PRISMA_SERVICE_TOKEN`; such a token carries no refresh token and can never be renewed, and nothing stored is cleared. Built only through the shared `credentialRejectedError` dispatcher in the engine's API request path — the one place wording differs by credential origin (a stored session with the same failure gets `CLI.CREDENTIALS_REQUIRED` instead). `prisma auth whoami` fails with this code too rather than reporting the rejected token as signed in. The suggested action is to replace the variable with a valid service token or unset it to fall back to stored sessions. Meta: none. ### AUTH.SESSIONS_UNSUPPORTED @@ -146,7 +146,7 @@ The advisory lock on the stored-credentials file was held by another prisma proc ### CLI.CREDENTIALS_REQUIRED -The command needs a signed-in credential and none is usable. One constructor covers five reasons: not signed in at all, an expired session, a session expiring too soon for a command that hands credentials to a child process (which cannot refresh them), a workspace session that ended mid-run, and workspace sessions held with none selected as current. Raised identically by the engine's needs check, `ctx.activeCredential`, and the request path; next actions point at signing in or `prisma auth workspace use`. `prisma auth whoami` reports being signed out rather than failing with this code: no credential at all, or a session its identity lookup finds expired or ended, is `authenticated: false` with exit `0` (sessions held with none selected still fails, because signing in is not the only fix). Meta: none. +The command needs a signed-in credential and none is usable. One constructor covers five reasons: not signed in at all, an expired session, a session expiring too soon for a command that hands credentials to a child process (which cannot refresh them), a workspace session that ended mid-run, and workspace sessions held with none selected as current. Raised identically by the engine's needs check, `ctx.activeCredential`, and the request path; next actions point at signing in or `prisma auth workspace use`. When `prisma auth whoami` meets this code during its identity lookup it reports `authenticated: false` with exit `0` instead of failing. Meta: none. ### CLI.CREDENTIALS_UNREADABLE diff --git a/packages/cli/src/commands/auth/whoami.ts b/packages/cli/src/commands/auth/whoami.ts index a0cbcb37..f24859af 100644 --- a/packages/cli/src/commands/auth/whoami.ts +++ b/packages/cli/src/commands/auth/whoami.ts @@ -45,46 +45,14 @@ export interface WhoamiResult { * and never answers would otherwise hold the command for minutes. */ const ENRICHMENT_TIMEOUT_MS = 3_000; -/** - * What the `/v1/me` lookup established. `identity` is absent whenever - * the lookup could not supply one; `usable: false` is the engine's own - * verdict, reached during this run, that the stored session is expired - * beyond refresh or has ended underneath the process. - */ -type Lookup = - | { - readonly usable: true; - readonly identity: CredentialIdentity | undefined; - } - | { readonly usable: false }; - -const NO_IDENTITY: Lookup = { usable: true, identity: undefined }; - -/** - * Online enrichment, best-effort for everything that leaves the - * credential's standing unknown: whoami works offline, so a network - * failure, a timeout, a 5xx, or the auth service failing transiently - * (CLI.AUTH_SERVICE_ERROR — nothing was cleared) leaves the identity as - * whatever the credential's own claims said. - * - * Two failures are not unknowns. They are the engine's definitive - * verdict on the credential in force, told apart by the structured - * error's code exactly as the engine raised it — never by origin, and - * never by message: - * - * - CLI.CREDENTIALS_REQUIRED: the stored session is expired beyond - * refresh or has ended. That is the state whoami exists to report, so - * it is answered as signed out rather than raised. - * - AUTH.SERVICE_TOKEN_REJECTED: the environment's token was refused. - * Signing in cannot fix that while the variable is set, so "signed - * out" would send the reader the wrong way; the error names the - * variable and settles as itself, as AUTH.SERVICE_TOKEN_EMPTY already - * does for this command. - */ -async function lookUpIdentity( +/** Best-effort online enrichment: whoami works offline, so a transient + * failure leaves the identity as the credential's own claims said. + * CLI.CREDENTIALS_REQUIRED means signed out; AUTH.SERVICE_TOKEN_REJECTED + * is rethrown because signing in cannot fix an environment token. */ +async function fetchedIdentity( api: ManagementApiClient, signal: AbortSignal, -): Promise { +): Promise { const bounded = AbortSignal.any([ signal, AbortSignal.timeout(ENRICHMENT_TIMEOUT_MS), @@ -93,27 +61,24 @@ async function lookUpIdentity( const { data } = await api.GET("/v1/me", { signal: bounded }); const user = data?.data?.user; if (!user) { - return NO_IDENTITY; + return undefined; } return { - usable: true, - identity: { - userId: user.id ?? undefined, - email: user.email ?? undefined, - name: user.name ?? undefined, - }, + userId: user.id ?? undefined, + email: user.email ?? undefined, + name: user.name ?? undefined, }; } catch (cause) { signal.throwIfAborted(); if (CliStructuredError.is(cause)) { if (cause.code === "CLI.CREDENTIALS_REQUIRED") { - return { usable: false }; + return "signed-out"; } if (cause.code === "AUTH.SERVICE_TOKEN_REJECTED") { throw cause; } } - return NO_IDENTITY; + return undefined; } } @@ -183,20 +148,14 @@ export const authWhoamiCommand = defineCommand({ examples: ["auth whoami", "auth whoami --json"], }, handler: async (_args, ctx) => { - const held = await ctx.activeCredential(); - const lookup = - held === null ? NO_IDENTITY : await lookUpIdentity(ctx.api, ctx.signal); - // A credential the engine has just ruled unusable is not one this - // process is signed in with: it is reported exactly as no - // credential is, never as the workspace it used to open. - const credential = lookup.usable ? held : null; + const active = await ctx.activeCredential(); + const fetched = + active === null ? undefined : await fetchedIdentity(ctx.api, ctx.signal); + const credential = fetched === "signed-out" ? null : active; const identity = - credential === null + credential === null || fetched === "signed-out" ? null - : mergedIdentity( - credential.identity, - lookup.usable ? lookup.identity : undefined, - ); + : mergedIdentity(credential.identity, fetched); const result: WhoamiResult = { authenticated: credential !== null, workspace: diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index a070b912..1dcd80ed 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -804,13 +804,8 @@ describe("the environment credential carries no refresh token", () => { expect(paths).toEqual(["/v1/me"]); }); - /** §11.6: whoami does not branch on origin — it attempts the same - * online enrichment for an environment credential. A lookup that - * fails without a verdict falls back to the token's own claims (the - * next test); a 401 is the engine's verdict that the token is - * refused, and whoami does not answer "signed in" over it. Signing - * in would not help while the variable is set, so the error that - * names the variable settles as itself. */ + /** A refused env token is never reported as signed in, and signing + * in cannot fix it, so the rejection settles as itself. */ it("settles the engine's rejection when the API refuses the env token", async () => { const cli = await cliAgainstA401Server(); @@ -818,7 +813,6 @@ describe("the environment credential carries no refresh token", () => { expect(result.exitCode).toBe(2); expect(errorOf(result).code).toBe("AUTH.SERVICE_TOKEN_REJECTED"); - expect(result.stdout).not.toContain('"authenticated":true'); expect(paths).toEqual(["/v1/me"]); }); @@ -866,69 +860,6 @@ describe("the environment credential carries no refresh token", () => { }, 20_000); }); -/** The request path end to end, with no structured error written by - * the test: the API answers 401, the token endpoint answers - * invalid_grant, the SDK compare-and-clears the stored session, and - * the engine maps that to the expired CLI.CREDENTIALS_REQUIRED. */ -describe("whoami when the refresh token is refused", () => { - let server: Server | undefined; - - afterEach(async () => { - const running = server; - server = undefined; - if (running !== undefined) { - await new Promise((resolve) => running.close(() => resolve())); - } - }); - - it("reports signed out, not the workspace of the session it just lost", async () => { - const paths: string[] = []; - server = createServer((request, response) => { - paths.push(request.url ?? ""); - const refused = request.url === "/token"; - response.writeHead(refused ? 400 : 401, { - "content-type": "application/json", - }); - response.end( - JSON.stringify( - refused ? { error: "invalid_grant" } : { error: "unauthorized" }, - ), - ); - }); - await new Promise((resolve) => { - server?.listen(0, "127.0.0.1", () => resolve()); - }); - const port = (server.address() as AddressInfo).port; - const baseUrl = `http://127.0.0.1:${port}`; - const cli = createTestCli({ - commands: COMMANDS, - groups: GROUPS, - sessions: [record("ws_1", "Acme Inc")], - selectedWorkspaceId: "ws_1", - managementApiClientConfig: { - clientId: "test-client-id", - redirectUri: `${baseUrl}/auth/callback`, - apiBaseUrl: baseUrl, - authBaseUrl: baseUrl, - }, - now: () => new Date(0), - }); - - const result = await cli.run(["auth", "whoami", "--json"]); - - expect(result.exitCode).toBe(0); - expect(resultOf(result)).toEqual({ - authenticated: false, - workspace: null, - user: null, - source: null, - expiresAt: null, - }); - expect(paths).toEqual(["/v1/me", "/token"]); - expect(cli.credentialManager.state().sessions).toEqual([]); - }); -}); - describe("a blank service token is never an override", () => { for (const [name, token] of [ ["blank", ""], diff --git a/packages/cli/tests/whoami.test.ts b/packages/cli/tests/whoami.test.ts index 0bc622d3..907609da 100644 --- a/packages/cli/tests/whoami.test.ts +++ b/packages/cli/tests/whoami.test.ts @@ -5,11 +5,9 @@ */ import { authServiceError, - credentialRejectedError, credentialsRequiredError, defineCommand, type ManagementApiClient, - SERVICE_TOKEN_ENV_VAR, } from "@prisma/cli-engine"; import { type CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import { @@ -40,9 +38,6 @@ const OFFLINE_API = { }, } as unknown as ManagementApiClient; -/** ctx.api as the engine's request path leaves it when a request - * fails: the structured error its own constructors build, thrown - * unwrapped. */ function apiFailingWith(failure: CliStructuredError): ManagementApiClient { return { GET: async () => { @@ -51,20 +46,6 @@ function apiFailingWith(failure: CliStructuredError): ManagementApiClient { } as unknown as ManagementApiClient; } -const SIGNED_OUT_RESULT = { - authenticated: false, - workspace: null, - user: null, - source: null, - expiresAt: null, -}; - -const SIGN_IN_ACTION = { - kind: "run-command", - label: "Sign in", - command: "prisma auth login", -}; - const IDENTIFIED_API = { GET: async () => ({ data: { @@ -314,128 +295,37 @@ describe("prisma auth whoami", () => { }); }); - /** The enrichment is best-effort for failures that leave the - * credential's standing unknown. The engine's verdict that the - * session is over is not one of those: automation reads - * `authenticated` to decide whether to sign in, and a `true` for a - * session the same run found dead makes its next command fail. */ - describe("when the engine rules the credential unusable during the lookup", () => { - for (const [name, reason] of [ - ["expired beyond refresh", "expired"], - ["ended underneath the process", "session-ended"], - ] as const) { - it(`reports a session ${name} as signed out, exit 0, with the Sign in action`, async () => { - const cli = makeCli({ - sessions: [SESSION], - selectedWorkspaceId: "ws_123", - client: apiFailingWith(credentialsRequiredError(reason)), - }); - - const json = await cli.run(["auth", "whoami", "--json"]); - - expect(json.exitCode).toBe(0); - const frame = json.json[0]; - if (frame.kind !== "result") { - throw new Error("expected a result frame"); - } - expect(frame.envelope).toEqual({ - ok: true, - commandId: "auth.whoami", - result: SIGNED_OUT_RESULT, - exitCode: 0, - diagnostics: [], - nextActions: [SIGN_IN_ACTION], - }); - expect(json.stdout).not.toContain("ws_123"); - expect(json.stdout).not.toContain("Acme Inc"); - - const human = await cli.run(["auth", "whoami"], { - isTty: { stdout: true }, - }); - - expect(human.exitCode).toBe(0); - expect(human.stdout).toBe("status: signed out\n"); - expect(human.stderr).toBe( - "ℹ Showing the active authenticated identity.\n" + - "\n" + - "status: signed out\n" + - "\n" + - "→ Sign in: prisma auth login\n", - ); - }); - } + it("reports signed out when the lookup finds the session expired", async () => { + const result = await makeCli({ + sessions: [SESSION], + selectedWorkspaceId: "ws_123", + client: apiFailingWith(credentialsRequiredError("expired")), + }).run(["auth", "whoami", "--json"]); - /** Signing in cannot fix a refused PRISMA_SERVICE_TOKEN: the - * variable keeps overriding whatever session a login stores. The - * honest answer names the variable, as the blank-token error - * already does for this command. */ - it("lets a rejected environment credential settle as its own error instead of claiming it", async () => { - const cli = makeCli({ - environmentCredential: { - token: mintTestJwt({ workspace_id: "ws_env", sub: "usr_env" }), - refreshToken: undefined, - expiresAt: undefined, + expect(result.exitCode).toBe(0); + const frame = result.json[0]; + if (frame.kind !== "result") { + throw new Error("expected a result frame"); + } + expect(frame.envelope).toMatchObject({ + ok: true, + result: { + authenticated: false, + workspace: null, + user: null, + source: null, + expiresAt: null, + }, + nextActions: [ + { + kind: "run-command", + label: "Sign in", + command: "prisma auth login", }, - client: apiFailingWith( - credentialRejectedError( - { source: "environment" }, - SERVICE_TOKEN_ENV_VAR, - ), - ), - }); - - const json = await cli.run(["auth", "whoami", "--json"]); - - expect(json.exitCode).toBe(2); - const frame = json.json[0]; - if (frame.kind !== "result") { - throw new Error("expected a result frame"); - } - expect(frame.envelope).toMatchObject({ - ok: false, - error: { code: "AUTH.SERVICE_TOKEN_REJECTED" }, - }); - expect(json.stdout).not.toContain('"authenticated":true'); - - const human = await cli.run(["auth", "whoami"], { - isTty: { stdout: true }, - }); - - expect(human.exitCode).toBe(2); - expect(human.stdout).toBe(""); - expect(human.stderr).toBe( - "✘ [AUTH.SERVICE_TOKEN_REJECTED] The management API rejected the service token from PRISMA_SERVICE_TOKEN.\n" + - "→ Replace PRISMA_SERVICE_TOKEN with a valid service token, or unset it to use your stored sessions.\n", - ); - }); - - /** The same dispatcher hands a stored credential that could never - * be renewed the expired wording, so it reads as signed out. */ - it("reports a rejected stored credential as signed out", async () => { - const result = await makeCli({ - sessions: [SESSION], - selectedWorkspaceId: "ws_123", - client: apiFailingWith( - credentialRejectedError({ source: "stored" }, SERVICE_TOKEN_ENV_VAR), - ), - }).run(["auth", "whoami", "--json"]); - - expect(result.exitCode).toBe(0); - const frame = result.json[0]; - if (frame.kind !== "result") { - throw new Error("expected a result frame"); - } - expect(frame.envelope).toMatchObject({ - ok: true, - result: SIGNED_OUT_RESULT, - nextActions: [SIGN_IN_ACTION], - }); + ], }); }); - /** CLI.AUTH_SERVICE_ERROR is the auth service failing, not the - * credential: nothing was cleared and signing in is not the fix, so - * it is one more way of being offline. */ it("still answers from the claims when the auth service fails transiently", async () => { const result = await makeCli({ sessions: [SESSION], @@ -449,51 +339,14 @@ describe("prisma auth whoami", () => { throw new Error("expected a result frame"); } expect(frame.envelope).toMatchObject({ - ok: true, result: { authenticated: true, - workspace: { id: "ws_123", name: "Acme Inc" }, user: { id: "usr_456", email: null, name: null }, source: "stored", }, - nextActions: [], }); }); - /** Ctrl-C outranks whatever the interrupted request went on to throw - * — including a verdict on the credential, which must not turn an - * interrupt into an answer. */ - for (const [name, thrown] of [ - ["an abort error", () => new DOMException("aborted", "AbortError")], - ["a credentials verdict", () => credentialsRequiredError("expired")], - ] as const) { - it(`settles CLI.ABORTED when the interrupt lands mid-lookup and the request throws ${name}`, async () => { - const controller = new AbortController(); - const interrupted = { - GET: async () => { - controller.abort(); - throw thrown(); - }, - } as unknown as ManagementApiClient; - - const result = await makeCli({ - sessions: [SESSION], - selectedWorkspaceId: "ws_123", - client: interrupted, - }).run(["auth", "whoami", "--json"], { abort: controller.signal }); - - expect(result.exitCode).toBe(130); - const frame = result.json[0]; - if (frame.kind !== "result") { - throw new Error("expected a result frame"); - } - expect(frame.envelope).toMatchObject({ - ok: false, - error: { code: "CLI.ABORTED" }, - }); - }); - } - it("renders the unchanged presentation under --quiet (a log-level alias)", async () => { const result = await signedInCli().run(["auth", "whoami", "--quiet"], { isTty: { stdout: true }, From ee555ed54e9bdd104580e98e14235390a9aef01f Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Mon, 21 Sep 2026 18:41:31 +0530 Subject: [PATCH 3/3] feat(auth): whoami says whether the API confirmed the credential After an inconclusive /v1/me lookup (timeout, network error, 5xx, CLI.AUTH_SERVICE_ERROR) whoami still answered `authenticated: true` from the credential's claims with no hint that nothing was verified. The result gains `verified`: true only when the API accepted the credential during this run, false when answering from claims and whenever `authenticated` is false. Human output adds one info line for a held but unverified credential. Exit codes, `authenticated`, the 3s bound and the plain stdout rows are unchanged. Co-Authored-By: Claude Fable 5.1 --- docs/reference/error-reference.md | 2 +- .../cli/src/commands/auth/credential-card.ts | 3 + packages/cli/src/commands/auth/whoami.ts | 55 ++++++++++++++----- packages/cli/tests/auth.test.ts | 1 + packages/cli/tests/whoami.test.ts | 18 +++++- 5 files changed, 62 insertions(+), 17 deletions(-) diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 8c05e80d..6578c04a 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -98,7 +98,7 @@ The run's abort signal fired before the command completed — a thrown abort err ### CLI.AUTH_SERVICE_ERROR -The authentication service failed transiently while refreshing a stored OAuth session; the stored credentials are left untouched, and the guidance is to retry rather than sign in again, because the credentials themselves were not rejected. Meta: none. +The authentication service failed transiently while refreshing a stored OAuth session; the stored credentials are left untouched, and the guidance is to retry rather than sign in again, because the credentials themselves were not rejected. `prisma auth whoami` does not fail with this code: it answers from the credential's own claims and reports `verified: false`. Meta: none. ### CLI.BROWSER_WAIT_TIMEOUT diff --git a/packages/cli/src/commands/auth/credential-card.ts b/packages/cli/src/commands/auth/credential-card.ts index b2d0c093..48357f8b 100644 --- a/packages/cli/src/commands/auth/credential-card.ts +++ b/packages/cli/src/commands/auth/credential-card.ts @@ -11,6 +11,9 @@ export interface FieldRow { export const ENVIRONMENT_CREDENTIAL_NOTICE = `${SERVICE_TOKEN_ENV_VAR} supplies the credential in force; unset it to use your stored workspace sessions.`; +export const UNVERIFIED_CREDENTIAL_NOTICE = + "Could not reach Prisma to confirm this sign-in. Showing what the local credential says."; + /** The card rows for the active credential, or the signed-out row when * there is none. A credential nothing names — an environment token * whose claims carry no workspace — has no workspace row at all. */ diff --git a/packages/cli/src/commands/auth/whoami.ts b/packages/cli/src/commands/auth/whoami.ts index f24859af..7c39615c 100644 --- a/packages/cli/src/commands/auth/whoami.ts +++ b/packages/cli/src/commands/auth/whoami.ts @@ -14,6 +14,7 @@ import { CLI_NAME } from "../../cli-name"; import { credentialFieldRows, ENVIRONMENT_CREDENTIAL_NOTICE, + UNVERIFIED_CREDENTIAL_NOTICE, } from "./credential-card"; const TITLE = "Showing the active authenticated identity."; @@ -26,6 +27,8 @@ const SIGN_IN: NextAction = { export interface WhoamiResult { readonly authenticated: boolean; + /** True only when the API accepted the credential during this run. */ + readonly verified: boolean; readonly workspace: { readonly id: string; readonly name: string | null; @@ -45,6 +48,14 @@ export interface WhoamiResult { * and never answers would otherwise hold the command for minutes. */ const ENRICHMENT_TIMEOUT_MS = 3_000; +type Lookup = + | { + readonly kind: "confirmed"; + readonly identity: CredentialIdentity | undefined; + } + | { readonly kind: "signed-out" } + | { readonly kind: "inconclusive" }; + /** Best-effort online enrichment: whoami works offline, so a transient * failure leaves the identity as the credential's own claims said. * CLI.CREDENTIALS_REQUIRED means signed out; AUTH.SERVICE_TOKEN_REJECTED @@ -52,33 +63,38 @@ const ENRICHMENT_TIMEOUT_MS = 3_000; async function fetchedIdentity( api: ManagementApiClient, signal: AbortSignal, -): Promise { +): Promise { const bounded = AbortSignal.any([ signal, AbortSignal.timeout(ENRICHMENT_TIMEOUT_MS), ]); try { const { data } = await api.GET("/v1/me", { signal: bounded }); - const user = data?.data?.user; - if (!user) { - return undefined; + if (data === undefined) { + return { kind: "inconclusive" }; } + const user = data.data?.user; return { - userId: user.id ?? undefined, - email: user.email ?? undefined, - name: user.name ?? undefined, + kind: "confirmed", + identity: user + ? { + userId: user.id ?? undefined, + email: user.email ?? undefined, + name: user.name ?? undefined, + } + : undefined, }; } catch (cause) { signal.throwIfAborted(); if (CliStructuredError.is(cause)) { if (cause.code === "CLI.CREDENTIALS_REQUIRED") { - return "signed-out"; + return { kind: "signed-out" }; } if (cause.code === "AUTH.SERVICE_TOKEN_REJECTED") { throw cause; } } - return undefined; + return { kind: "inconclusive" }; } } @@ -134,6 +150,15 @@ function presentationsFor( } as const, ] : []), + ...(result.authenticated && !result.verified + ? [ + { + kind: "summary", + status: "info", + text: UNVERIFIED_CREDENTIAL_NOTICE, + } as const, + ] + : []), ], stdout: () => rows.map((row) => `${row.label}: ${row.value}`), next: () => (spec.credential === null ? [SIGN_IN] : []), @@ -149,15 +174,19 @@ export const authWhoamiCommand = defineCommand({ }, handler: async (_args, ctx) => { const active = await ctx.activeCredential(); - const fetched = + const lookup = active === null ? undefined : await fetchedIdentity(ctx.api, ctx.signal); - const credential = fetched === "signed-out" ? null : active; + const credential = lookup?.kind === "signed-out" ? null : active; const identity = - credential === null || fetched === "signed-out" + credential === null ? null - : mergedIdentity(credential.identity, fetched); + : mergedIdentity( + credential.identity, + lookup?.kind === "confirmed" ? lookup.identity : undefined, + ); const result: WhoamiResult = { authenticated: credential !== null, + verified: credential !== null && lookup?.kind === "confirmed", workspace: credential === null || credential.workspaceId === undefined ? null diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index 1dcd80ed..6f71fbef 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -298,6 +298,7 @@ describe("auth whoami", () => { expect(result.exitCode).toBe(0); expect(resultOf(result)).toEqual({ authenticated: false, + verified: false, workspace: null, user: null, source: null, diff --git a/packages/cli/tests/whoami.test.ts b/packages/cli/tests/whoami.test.ts index 907609da..aa0f6767 100644 --- a/packages/cli/tests/whoami.test.ts +++ b/packages/cli/tests/whoami.test.ts @@ -146,7 +146,8 @@ describe("prisma auth whoami", () => { expect(result.stderr).toBe(""); expect(result.stdout).toBe( `{"kind":"result","envelope":{"ok":true,"commandId":"auth.whoami",` + - `"result":{"authenticated":false,"workspace":null,"user":null,` + + `"result":{"authenticated":false,"verified":false,"workspace":null,` + + `"user":null,` + `"source":null,"expiresAt":null},"exitCode":0,"diagnostics":[],` + `"nextActions":[{"kind":"run-command","label":"Sign in",` + `"command":"prisma auth login"}]},"commandId":"auth.whoami",` + @@ -168,6 +169,7 @@ describe("prisma auth whoami", () => { commandId: "auth.whoami", result: { authenticated: true, + verified: true, workspace: { id: "ws_123", name: "Acme Inc" }, user: { id: "usr_456", email: "bob@example.com", name: "Bob" }, source: "stored", @@ -311,6 +313,7 @@ describe("prisma auth whoami", () => { ok: true, result: { authenticated: false, + verified: false, workspace: null, user: null, source: null, @@ -327,11 +330,19 @@ describe("prisma auth whoami", () => { }); it("still answers from the claims when the auth service fails transiently", async () => { - const result = await makeCli({ + const cli = makeCli({ sessions: [SESSION], selectedWorkspaceId: "ws_123", client: apiFailingWith(authServiceError()), - }).run(["auth", "whoami", "--json"]); + }); + const human = await cli.run(["auth", "whoami"], { + isTty: { stdout: true }, + }); + expect(human.stderr).toContain( + "ℹ Could not reach Prisma to confirm this sign-in. Showing what the local credential says.\n", + ); + + const result = await cli.run(["auth", "whoami", "--json"]); expect(result.exitCode).toBe(0); const frame = result.json[0]; @@ -341,6 +352,7 @@ describe("prisma auth whoami", () => { expect(frame.envelope).toMatchObject({ result: { authenticated: true, + verified: false, user: { id: "usr_456", email: null, name: null }, source: "stored", },