diff --git a/packages/cli/package.json b/packages/cli/package.json index 578ab22..c053a02 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -44,7 +44,7 @@ "@prisma/compute-sdk": "^0.19.0", "c12": "4.0.0-beta.4", "@prisma/credentials-store": "^7.7.0", - "@prisma/management-api-sdk": "^1.33.1", + "@prisma/management-api-sdk": "^1.34.0", "colorette": "^2.0.20", "commander": "^12.1.0", "magicast": "^0.3.5", diff --git a/packages/cli/src/lib/auth/auth-ops.ts b/packages/cli/src/lib/auth/auth-ops.ts index 90fd68a..c45178d 100644 --- a/packages/cli/src/lib/auth/auth-ops.ts +++ b/packages/cli/src/lib/auth/auth-ops.ts @@ -1,3 +1,4 @@ +import type { ManagementApiClient } from "@prisma/management-api-sdk"; import { FileTokenStorage } from "../../adapters/token-storage"; import type { AuthStateResult } from "../../types/auth"; import { SERVICE_TOKEN_ENV_VAR } from "./client"; @@ -10,7 +11,9 @@ function decodeJwtPayload(token: string): Record { try { const payload = token.split(".")[1]; if (!payload) return {}; - return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Record; + return JSON.parse( + Buffer.from(payload, "base64url").toString("utf8"), + ) as Record; } catch { return {}; } @@ -60,17 +63,35 @@ export async function readAuthState(env: NodeJS.ProcessEnv): Promise { + const client = await requireComputeAuth(env); + const currentPrincipal = await readCurrentPrincipalAuthState(client); + if (currentPrincipal) { + return currentPrincipal; + } + const claims = decodeJwtPayload(token); const workspaceId = workspaceIdFromClaims(claims); @@ -84,25 +105,33 @@ async function readServiceTokenAuthState( provider: null, user: null, workspace: null, + credential: null, }; } - return buildAuthState({ workspaceIdFromCredential: workspaceId, claims, env }); + return buildAuthState({ + workspaceIdFromCredential: workspaceId, + claims, + env, + client, + }); } async function buildAuthState({ workspaceIdFromCredential, claims, env, + client, }: { workspaceIdFromCredential: string; claims: Record; env: NodeJS.ProcessEnv; + client?: ManagementApiClient | null; }): Promise { let workspaceId = workspaceIdFromCredential; let workspaceName = workspaceIdFromCredential; - const client = await requireComputeAuth(env); + client ??= await requireComputeAuth(env); if (client) { try { @@ -111,7 +140,7 @@ async function buildAuthState({ }); // A 401 from the workspace lookup means the credential the caller // presented is fundamentally invalid (revoked, wrong signing key, - // expired) — surface signed-out state instead of returning a + // expired) - surface signed-out state instead of returning a // workspace shape that makes a broken token look fine. Other // statuses (404/5xx/network) keep the silent fallback so transient // lookup failures do not turn `auth whoami` into a hard error. @@ -121,6 +150,7 @@ async function buildAuthState({ provider: null, user: null, workspace: null, + credential: null, }; } if (data?.data?.id) { @@ -131,7 +161,7 @@ async function buildAuthState({ workspaceName = data.data.name; } } catch { - // fall through — use workspaceId as name + // fall through - use workspaceId as name } } @@ -144,9 +174,50 @@ async function buildAuthState({ id: workspaceId, name: workspaceName, }, + credential: null, }; } +async function readCurrentPrincipalAuthState( + client: ManagementApiClient | null, +): Promise { + if (!client) return null; + + try { + const { data, response } = await client.GET("/v1/me"); + + if (response?.status === 401) { + return { + authenticated: false, + provider: null, + user: null, + workspace: null, + credential: null, + }; + } + + const principal = data?.data; + if (!principal) return null; + if (!principal.credential) return null; + + return { + authenticated: true, + provider: null, + user: principal.user + ? { + id: principal.user.id, + email: principal.user.email, + name: principal.user.name, + } + : null, + workspace: principal.workspace, + credential: principal.credential, + }; + } catch { + return null; + } +} + export async function performLogout(env: NodeJS.ProcessEnv): Promise { await new FileTokenStorage(env).clearTokens(); } diff --git a/packages/cli/src/presenters/auth.ts b/packages/cli/src/presenters/auth.ts index 8045fcc..d6b45b3 100644 --- a/packages/cli/src/presenters/auth.ts +++ b/packages/cli/src/presenters/auth.ts @@ -16,8 +16,9 @@ export function renderAuthSuccess( rows.push({ key: "provider", value: providerLabel(result.provider) }); } - if (result.user) { - rows.push({ key: "user", value: result.user.email }); + const userLabel = authUserLabel(result); + if (userLabel) { + rows.push({ key: "user", value: userLabel }); } if (result.workspace?.name) { @@ -58,9 +59,13 @@ export function renderAuthSuccess( fields: result.authenticated ? [ { key: "status", value: "signed in", tone: "success" as const }, - ...(result.user ? [{ key: "user", value: result.user.email }] : []), - ...(result.provider ? [{ key: "provider", value: providerLabel(result.provider) }] : []), - ...(result.workspace?.name ? [{ key: "workspace", value: result.workspace.name }] : []), + ...authUserRows(result), + ...(result.provider + ? [{ key: "provider", value: providerLabel(result.provider) }] + : []), + ...(result.workspace?.name + ? [{ key: "workspace", value: result.workspace.name }] + : []), ] : [{ key: "status", value: "signed out", tone: "dim" as const }], }, @@ -79,3 +84,28 @@ function providerLabel(provider: AuthProviderId | null): string { return ""; } + +function authUserLabel(result: AuthStateResult): string | null { + return result.user?.email ?? credentialUserLabel(result); +} + +function authUserRows(result: AuthStateResult): Parameters[0]["fields"] { + const userLabel = authUserLabel(result); + return userLabel ? [{ key: "user", value: userLabel }] : []; +} + +function credentialUserLabel(result: AuthStateResult): string | null { + if (result.credential?.type === "service_token") { + return result.credential.name + ? `` + : ""; + } + + if (result.credential?.type === "management_token") { + return result.credential.name + ? `` + : ""; + } + + return null; +} diff --git a/packages/cli/src/types/auth.ts b/packages/cli/src/types/auth.ts index 72472b8..ac64227 100644 --- a/packages/cli/src/types/auth.ts +++ b/packages/cli/src/types/auth.ts @@ -1,7 +1,9 @@ export type AuthProviderId = "github" | "google"; export interface AuthUser { + id?: string; email: string; + name?: string | null; } export interface AuthWorkspace { @@ -9,9 +11,16 @@ export interface AuthWorkspace { name: string; } +export interface AuthCredential { + type: "oauth" | "service_token" | "management_token"; + id: string | null; + name: string | null; +} + export interface AuthStateResult { authenticated: boolean; provider: AuthProviderId | null; user: AuthUser | null; workspace: AuthWorkspace | null; + credential: AuthCredential | null; } diff --git a/packages/cli/src/use-cases/auth.ts b/packages/cli/src/use-cases/auth.ts index e88d075..0b2b091 100644 --- a/packages/cli/src/use-cases/auth.ts +++ b/packages/cli/src/use-cases/auth.ts @@ -97,6 +97,7 @@ async function resolveCurrentAuthState(dependencies: AuthUseCaseDependencies): P provider: null, user: null, workspace: null, + credential: null, }; } @@ -110,6 +111,7 @@ async function resolveCurrentAuthState(dependencies: AuthUseCaseDependencies): P provider: null, user: null, workspace: null, + credential: null, }; } @@ -117,8 +119,15 @@ async function resolveCurrentAuthState(dependencies: AuthUseCaseDependencies): P authenticated: true, provider: provider.id, user: { + id: user.id, email: user.email, + name: user.name, }, workspace, + credential: { + type: "oauth", + id: null, + name: null, + }, }; } diff --git a/packages/cli/tests/auth-ops.test.ts b/packages/cli/tests/auth-ops.test.ts index 83a5820..52db3a4 100644 --- a/packages/cli/tests/auth-ops.test.ts +++ b/packages/cli/tests/auth-ops.test.ts @@ -13,6 +13,72 @@ function encodeJwt(claims: Record): string { } describe("readAuthState", () => { + it("resolves the current OAuth principal from /v1/me when available", async () => { + const getTokens = vi.fn().mockResolvedValue({ + workspaceId: "cmmxlp7ae1251zyfs8mdpnavm", + accessToken: encodeJwt({ sub: "user:usr_123" }), + refreshToken: "refresh-token", + }); + const requireComputeAuth = vi.fn().mockResolvedValue({ + GET: vi.fn().mockImplementation((pathName: string) => { + if (pathName === "/v1/me") { + return { + data: { + data: { + user: { + id: "usr_123", + email: "luan@example.com", + name: "Luan", + }, + workspace: { + id: "wksp_cmmxlp7ae1251zyfs8mdpnavm", + name: "Sandpit", + }, + credential: { + type: "oauth", + id: null, + name: null, + }, + }, + }, + }; + } + + throw new Error(`Unexpected path ${pathName}`); + }), + }); + + vi.doMock("../src/adapters/token-storage", () => ({ + FileTokenStorage: vi.fn().mockImplementation(() => ({ + getTokens, + })), + })); + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + + const { readAuthState } = await import("../src/lib/auth/auth-ops"); + + await expect(readAuthState({} as NodeJS.ProcessEnv)).resolves.toEqual({ + authenticated: true, + provider: null, + user: { + id: "usr_123", + email: "luan@example.com", + name: "Luan", + }, + workspace: { + id: "wksp_cmmxlp7ae1251zyfs8mdpnavm", + name: "Sandpit", + }, + credential: { + type: "oauth", + id: null, + name: null, + }, + }); + }); + it("normalizes the workspace id to the canonical API id and returns the user email", async () => { const getTokens = vi.fn().mockResolvedValue({ workspaceId: "cmmxlp7ae1251zyfs8mdpnavm", @@ -58,6 +124,7 @@ describe("readAuthState", () => { id: "wksp_cmmxlp7ae1251zyfs8mdpnavm", name: "Sandpit", }, + credential: null, }); }); @@ -139,6 +206,25 @@ describe("readAuthState", () => { const getTokens = vi.fn(); const requireComputeAuth = vi.fn().mockResolvedValue({ GET: vi.fn().mockImplementation((pathName: string, request?: { params?: { path?: { id?: string } } }) => { + if (pathName === "/v1/me") { + return { + data: { + data: { + user: null, + workspace: { + id: "wksp_clitq5hfg0000qv0gtg9nv9fy", + name: "Prisma Platform", + }, + credential: { + type: "service_token", + id: "itgr_ci", + name: "ci-deploys-prod", + }, + }, + }, + }; + } + if (pathName === "/v1/workspaces/{id}" && request?.params?.path?.id === "clitq5hfg0000qv0gtg9nv9fy") { return { data: { @@ -171,11 +257,16 @@ describe("readAuthState", () => { ).resolves.toEqual({ authenticated: true, provider: null, - user: { email: "service@example.com" }, + user: null, workspace: { id: "wksp_clitq5hfg0000qv0gtg9nv9fy", name: "Prisma Platform", }, + credential: { + type: "service_token", + id: "itgr_ci", + name: "ci-deploys-prod", + }, }); expect(getTokens).not.toHaveBeenCalled(); @@ -246,6 +337,7 @@ describe("readAuthState", () => { provider: null, user: null, workspace: null, + credential: null, }); }); @@ -282,6 +374,7 @@ describe("readAuthState", () => { id: "clitq5hfg0000qv0gtg9nv9fy", name: "clitq5hfg0000qv0gtg9nv9fy", }, + credential: null, }); }); @@ -310,6 +403,7 @@ describe("readAuthState", () => { id: "clitq5hfg0000qv0gtg9nv9fy", name: "clitq5hfg0000qv0gtg9nv9fy", }, + credential: null, }); }); @@ -332,6 +426,7 @@ describe("readAuthState", () => { provider: null, user: null, workspace: null, + credential: null, }); expect(getTokens).not.toHaveBeenCalled(); }); diff --git a/packages/cli/tests/auth-real-mode.test.ts b/packages/cli/tests/auth-real-mode.test.ts index e651c32..e48359f 100644 --- a/packages/cli/tests/auth-real-mode.test.ts +++ b/packages/cli/tests/auth-real-mode.test.ts @@ -120,6 +120,66 @@ describe("real auth mode", () => { ); }); + it("returns service-token identity in real auth JSON output", async () => { + const readAuthState = vi.fn().mockResolvedValue({ + authenticated: true, + provider: null, + user: null, + workspace: { + id: "wksp_real", + name: "Real Workspace", + }, + credential: { + type: "service_token", + id: "itgr_ci", + name: "ci-deploys-prod", + }, + }); + + vi.doMock("../src/lib/auth/auth-ops", () => ({ + performLogin: vi.fn(), + readAuthState, + performLogout: vi.fn(), + })); + + const { createTempCwd, executeCli } = await import("./helpers"); + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + + const result = await executeCli({ + argv: ["auth", "whoami", "--json"], + cwd, + stateDir, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toEqual({ + ok: true, + command: "auth.whoami", + result: { + authenticated: true, + provider: null, + user: null, + workspace: { + id: "wksp_real", + name: "Real Workspace", + }, + credential: { + type: "service_token", + id: "itgr_ci", + name: "ci-deploys-prod", + }, + }, + warnings: [], + nextSteps: [], + }); + }); + it("omits empty provider and workspace rows in auth output", async () => { const { createTempCwd, createTestCommandContext } = await import("./helpers"); const cwd = await createTempCwd(); @@ -141,6 +201,7 @@ describe("real auth mode", () => { email: "real@example.com", }, workspace: null, + credential: null, }, ).join(""); @@ -173,6 +234,7 @@ describe("real auth mode", () => { id: "ws_real", name: "Real Workspace", }, + credential: null, }, ).join(""); @@ -182,4 +244,41 @@ describe("real auth mode", () => { expect(plain).not.toContain("user:"); expect(plain).not.toContain("<>"); }); + + it("shows service-token identity when no human user is present", async () => { + const { createTempCwd, createTestCommandContext } = await import("./helpers"); + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + fixturePath, + }); + + const output = renderAuthSuccess( + context, + getCommandDescriptor("auth.whoami"), + "auth.whoami", + { + authenticated: true, + provider: null, + user: null, + workspace: { + id: "wksp_real", + name: "Real Workspace", + }, + credential: { + type: "service_token", + id: "itgr_ci", + name: "ci-deploys-prod", + }, + }, + ).join(""); + + const plain = stripAnsi(output); + + expect(plain).toContain("status: signed in"); + expect(plain).toContain("user: "); + expect(plain).toContain("workspace: Real Workspace"); + }); }); diff --git a/packages/cli/tests/auth-usecases.test.ts b/packages/cli/tests/auth-usecases.test.ts index a7f30d9..6577da7 100644 --- a/packages/cli/tests/auth-usecases.test.ts +++ b/packages/cli/tests/auth-usecases.test.ts @@ -13,6 +13,7 @@ describe("auth use cases", () => { provider: null, user: null, workspace: null, + credential: null, }); }); @@ -30,12 +31,19 @@ describe("auth use cases", () => { authenticated: true, provider: "github", user: { + id: "usr_456", email: "bob@example.com", + name: "Bob Example", }, workspace: { id: "ws_123", name: "Acme Inc", }, + credential: { + type: "oauth", + id: null, + name: null, + }, }); expect(readState().authSession).toEqual({ @@ -60,6 +68,7 @@ describe("auth use cases", () => { provider: null, user: null, workspace: null, + credential: null, }); expect(readState().authSession).toBeNull(); diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index f6dcb60..563719f 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -70,12 +70,19 @@ describe("auth commands", () => { authenticated: true, provider: "github", user: { + id: "usr_456", email: "bob@example.com", + name: "Bob Example", }, workspace: { id: "ws_123", name: "Acme Inc", }, + credential: { + type: "oauth", + id: null, + name: null, + }, }, warnings: [], nextSteps: [], diff --git a/packages/cli/tests/project-usecases.test.ts b/packages/cli/tests/project-usecases.test.ts index dd5d306..9cb09c8 100644 --- a/packages/cli/tests/project-usecases.test.ts +++ b/packages/cli/tests/project-usecases.test.ts @@ -19,6 +19,7 @@ describe("project use cases", () => { id: "ws_123", name: "Acme Inc", }, + credential: null, }), ).resolves.toEqual({ workspace: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc2e2db..067ebeb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,13 +22,13 @@ importers: version: 1.2.0 '@prisma/compute-sdk': specifier: ^0.19.0 - version: 0.19.0(@prisma/management-api-sdk@1.33.1) + version: 0.19.0(@prisma/management-api-sdk@1.34.0) '@prisma/credentials-store': specifier: ^7.7.0 version: 7.7.0 '@prisma/management-api-sdk': - specifier: ^1.33.1 - version: 1.33.1 + specifier: ^1.34.0 + version: 1.34.0 c12: specifier: 4.0.0-beta.4 version: 4.0.0-beta.4(jiti@2.6.1)(magicast@0.3.5) @@ -295,8 +295,8 @@ packages: '@prisma/credentials-store@7.7.0': resolution: {integrity: sha512-SVaMCL1Q8rFPKQB5W9B7HDuRdD/KyBfKjCgZfnlN+sqrAXIrUGU/m/whcRgkuvygB5GFPAeeZ/4QgvvH0vPSWg==} - '@prisma/management-api-sdk@1.33.1': - resolution: {integrity: sha512-AQU0x6ceI5w32hAZc034H3IcSZQcd5K40tegiDYh7uNT0wlSVYMd4eRhXHdImz2aZafGzZqAQH5cmH23eCr+sA==} + '@prisma/management-api-sdk@1.34.0': + resolution: {integrity: sha512-l0UE58T/6rS9/tIe7Qv/ffQr3XUeUMGZwPhVig8VIoMKvidhtg8/UO84emUqAzQBQDG52GQP+VRtv3xFchrnjw==} '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} @@ -1319,9 +1319,9 @@ snapshots: '@oxc-project/types@0.124.0': {} - '@prisma/compute-sdk@0.19.0(@prisma/management-api-sdk@1.33.1)': + '@prisma/compute-sdk@0.19.0(@prisma/management-api-sdk@1.34.0)': dependencies: - '@prisma/management-api-sdk': 1.33.1 + '@prisma/management-api-sdk': 1.34.0 better-result: 2.8.2 tar-stream: 3.1.8 tiny-invariant: 1.3.3 @@ -1337,7 +1337,7 @@ snapshots: dependencies: xdg-app-paths: 8.3.0 - '@prisma/management-api-sdk@1.33.1': + '@prisma/management-api-sdk@1.34.0': dependencies: openapi-fetch: 0.14.0