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
6 changes: 3 additions & 3 deletions docs/reference/error-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/commands/auth/credential-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
71 changes: 57 additions & 14 deletions packages/cli/src/commands/auth/whoami.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand All @@ -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;
Expand All @@ -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<CredentialIdentity | undefined> {
): Promise<Lookup> {
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" };
}
}

Expand Down Expand Up @@ -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] : []),
Expand All @@ -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
Expand Down
15 changes: 6 additions & 9 deletions packages/cli/tests/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"]);
});

Expand Down
83 changes: 80 additions & 3 deletions packages/cli/tests/whoami.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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",` +
Expand All @@ -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",
Expand Down Expand Up @@ -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 },
Expand Down
Loading