diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 75964ad0..6578c04a 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` 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 @@ -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 @@ -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`. 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/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 be9ebfad..7c39615c 100644 --- a/packages/cli/src/commands/auth/whoami.ts +++ b/packages/cli/src/commands/auth/whoami.ts @@ -5,11 +5,16 @@ 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, ENVIRONMENT_CREDENTIAL_NOTICE, + UNVERIFIED_CREDENTIAL_NOTICE, } from "./credential-card"; const TITLE = "Showing the active authenticated identity."; @@ -22,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; @@ -41,30 +48,53 @@ 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. */ +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 + * 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), ]); 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 { + } catch (cause) { signal.throwIfAborted(); - return undefined; + if (CliStructuredError.is(cause)) { + if (cause.code === "CLI.CREDENTIALS_REQUIRED") { + return { kind: "signed-out" }; + } + if (cause.code === "AUTH.SERVICE_TOKEN_REJECTED") { + throw cause; + } + } + return { kind: "inconclusive" }; } } @@ -120,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] : []), @@ -134,16 +173,20 @@ export const authWhoamiCommand = defineCommand({ examples: ["auth whoami", "auth whoami --json"], }, handler: async (_args, ctx) => { - const credential = await ctx.activeCredential(); + const active = await ctx.activeCredential(); + const lookup = + active === null ? undefined : await fetchedIdentity(ctx.api, ctx.signal); + const credential = lookup?.kind === "signed-out" ? null : active; const identity = credential === null ? null : mergedIdentity( credential.identity, - await fetchedIdentity(ctx.api, ctx.signal), + 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 2a07e33d..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, @@ -804,19 +805,15 @@ 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, 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 () => { + /** 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(); 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(paths).toEqual(["/v1/me"]); }); diff --git a/packages/cli/tests/whoami.test.ts b/packages/cli/tests/whoami.test.ts index 8d9c9122..aa0f6767 100644 --- a/packages/cli/tests/whoami.test.ts +++ b/packages/cli/tests/whoami.test.ts @@ -3,8 +3,13 @@ * 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, + credentialsRequiredError, + defineCommand, + type ManagementApiClient, +} from "@prisma/cli-engine"; +import { type CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import { createTestCli, mintTestJwt, @@ -33,6 +38,14 @@ const OFFLINE_API = { }, } as unknown as ManagementApiClient; +function apiFailingWith(failure: CliStructuredError): ManagementApiClient { + return { + GET: async () => { + throw failure; + }, + } as unknown as ManagementApiClient; +} + const IDENTIFIED_API = { GET: async () => ({ data: { @@ -133,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",` + @@ -155,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", @@ -282,6 +297,68 @@ describe("prisma auth whoami", () => { }); }); + 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"]); + + 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, + verified: false, + workspace: null, + user: null, + source: null, + expiresAt: null, + }, + nextActions: [ + { + kind: "run-command", + label: "Sign in", + command: "prisma auth login", + }, + ], + }); + }); + + it("still answers from the claims when the auth service fails transiently", async () => { + const cli = makeCli({ + sessions: [SESSION], + selectedWorkspaceId: "ws_123", + client: apiFailingWith(authServiceError()), + }); + 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]; + if (frame.kind !== "result") { + throw new Error("expected a result frame"); + } + expect(frame.envelope).toMatchObject({ + result: { + authenticated: true, + verified: false, + user: { id: "usr_456", email: null, name: null }, + source: "stored", + }, + }); + }); + it("renders the unchanged presentation under --quiet (a log-level alias)", async () => { const result = await signedInCli().run(["auth", "whoami", "--quiet"], { isTty: { stdout: true },