From f1c08ccc2d5cd5a9532ba2f651323f608666cd34 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Wed, 19 Aug 2026 14:09:01 +0530 Subject: [PATCH 1/2] fix(auth): identify workspace sessions by account Signed-off-by: Aman Varshney --- docs/product/output-conventions.md | 55 +++++ packages/cli-engine/src/credential-manager.ts | 15 +- .../src/environment-credential-manager.ts | 4 + .../src/in-memory-credential-manager.ts | 5 + .../tests/credential-manager.test.ts | 10 + packages/cli-engine/tests/engine.type-test.ts | 1 + .../cli-engine/tests/management-api.test.ts | 2 + packages/cli/src/auth/credential-manager.ts | 163 ++++++++++++++- packages/cli/src/auth/session-metadata.ts | 45 +++++ packages/cli/src/auth/state-file.ts | 37 ++++ packages/cli/src/auth/workspace-name.ts | 21 -- packages/cli/src/commands/auth/login.ts | 11 +- packages/cli/src/commands/auth/session-ref.ts | 48 ++++- .../cli/src/commands/auth/workspace-list.ts | 24 ++- .../cli/src/commands/auth/workspace-logout.ts | 19 +- .../cli/src/commands/auth/workspace-use.ts | 17 +- packages/cli/src/runtime.ts | 6 +- packages/cli/tests/auth.test.ts | 121 ++++++++++- packages/cli/tests/credential-manager.test.ts | 190 +++++++++++++++++- packages/cli/tests/golden-rendering.test.ts | 29 ++- packages/cli/tests/session-metadata.test.ts | 45 +++++ 21 files changed, 800 insertions(+), 68 deletions(-) create mode 100644 packages/cli/src/auth/session-metadata.ts delete mode 100644 packages/cli/src/auth/workspace-name.ts create mode 100644 packages/cli/tests/session-metadata.test.ts diff --git a/docs/product/output-conventions.md b/docs/product/output-conventions.md index 0ca9d38d..a2c9d403 100644 --- a/docs/product/output-conventions.md +++ b/docs/product/output-conventions.md @@ -155,6 +155,61 @@ Current MVP commands map to patterns like this: No current MVP command uses `verify` or `inspect`, but new commands must still choose one existing pattern rather than inventing a new one casually. +### Workspace session identity + +An OAuth login authorizes one workspace and stores one local workspace session. +Running `auth login` again may add another session, including a session owned by +a different Prisma user. Therefore, `auth workspace list` describes the +workspace sessions authorized on this machine; it must not present them as the +complete list of workspaces visible in Console for the currently selected user. + +At login, the CLI resolves the authorizing user through `/v1/me` and persists +only its safe id, email, and name alongside the session. This lookup is +best-effort and never prevents login. Existing state remains compatible; when +stored metadata is unavailable, the CLI falls back to identity claims in the +access token. Session-list and session-selection commands also attempt this +enrichment for older records and cache successful results. This is an explicit +best-effort operation; the credential manager's ordinary `sessions()` read +remains local-only. + +Human workspace-session output shows the user email next to every workspace +when one is known. Selection prompts use the same identity so a user can +distinguish same-named workspaces and sessions belonging to different +accounts. When no email is known, output falls back to the user's name and then +id. Tables render the standard unknown-value marker when no user identity is +available, while selection prompts omit an identity they do not know. + +Structured workspace-session output includes a nullable `user` object on every +item. Its `context.scope` is `"local-sessions"`, making it explicit that the +collection is not a complete remote membership list: + +```json +{ + "context": { + "scope": "local-sessions" + }, + "items": [ + { + "workspaceId": "workspace_123", + "workspaceName": "Acme Inc", + "user": { + "id": "usr_123", + "email": "developer@example.com", + "name": null + }, + "current": true, + "expiresAt": "2026-08-19T09:10:49.000Z" + } + ] +} +``` + +The `user` object is `null` when neither stored metadata nor token claims carry +a user identity. Individual user fields use `null` when unavailable. Token +material never reaches either output mode. `auth workspace list` always offers +`auth login` as the structured next action: it authorizes the first workspace +when the list is empty and another workspace when sessions already exist. + ### One-Time Secret Output Commands that create one-time-view secrets may write the raw secret value to diff --git a/packages/cli-engine/src/credential-manager.ts b/packages/cli-engine/src/credential-manager.ts index 1405b200..665469c3 100644 --- a/packages/cli-engine/src/credential-manager.ts +++ b/packages/cli-engine/src/credential-manager.ts @@ -26,6 +26,10 @@ export interface Credential { export interface Session { readonly workspaceId: string; readonly workspaceName: string | undefined; + /** Who authorized this workspace session. A persistent manager may enrich + * this from the account API and fall back to credential claims. It contains + * no token material and may be absent for workspace-only credentials. */ + readonly identity: CredentialIdentity | undefined; /** The stored ACCESS TOKEN's expiry, which rotation changes — not a * deadline on the logged-in-ness. */ readonly expiresAt: Date | undefined; @@ -105,13 +109,18 @@ export interface CredentialManager { /** The stored sessions and the selection, read fresh. Local-only. */ sessions(): Promise; + /** Best-effort remote enrichment for session display metadata. Persistent + * managers may cache safe account details; failure still returns the local + * sessions. Commands opt into this explicitly so normal reads stay local. */ + enrichSessions(): Promise; + /** * Login's write. The caller names the workspace that identifies the * session; for workspace-bound credentials the manager verifies the * workspace_id claim matches and refuses on mismatch. Upserts by - * workspaceId and selects it. The workspace name is fetched - * best-effort after the write — failure leaves it undefined, never - * fails login. + * workspaceId and selects it. The workspace name and safe user identity are + * fetched best-effort after the write — failure leaves either undefined and + * never fails login. */ createSession(credential: Credential, workspaceId: string): Promise; diff --git a/packages/cli-engine/src/environment-credential-manager.ts b/packages/cli-engine/src/environment-credential-manager.ts index dedd1db2..23653203 100644 --- a/packages/cli-engine/src/environment-credential-manager.ts +++ b/packages/cli-engine/src/environment-credential-manager.ts @@ -76,6 +76,10 @@ export class EnvironmentCredentialManager implements CredentialManager { return { sessions: [], selectedWorkspaceId: undefined }; } + async enrichSessions(): Promise { + return this.sessions(); + } + async createSession( _credential: Credential, _workspaceId: string, diff --git a/packages/cli-engine/src/in-memory-credential-manager.ts b/packages/cli-engine/src/in-memory-credential-manager.ts index 81b973c0..9b26b34f 100644 --- a/packages/cli-engine/src/in-memory-credential-manager.ts +++ b/packages/cli-engine/src/in-memory-credential-manager.ts @@ -85,6 +85,7 @@ function asSession(record: SessionRecord): Session { return { workspaceId: record.workspaceId, workspaceName: record.workspaceName, + identity: claimedIdentity(record.credential.token), expiresAt: record.credential.expiresAt, }; } @@ -234,6 +235,10 @@ export class InMemoryCredentialManager implements CredentialManager { }; } + async enrichSessions(): Promise { + return this.sessions(); + } + async createSession( credential: Credential, workspaceId: string, diff --git a/packages/cli-engine/tests/credential-manager.test.ts b/packages/cli-engine/tests/credential-manager.test.ts index d7050a88..861b07b8 100644 --- a/packages/cli-engine/tests/credential-manager.test.ts +++ b/packages/cli-engine/tests/credential-manager.test.ts @@ -442,6 +442,11 @@ describe("session mutations and state read-back", () => { expect(selected).toEqual({ workspaceId: "workspace-2", workspaceName: undefined, + identity: { + userId: "user-1", + email: undefined, + name: undefined, + }, expiresAt: undefined, }); expect(manager.state().selectedWorkspaceId).toBe("workspace-2"); @@ -507,6 +512,11 @@ describe("session mutations and state read-back", () => { { workspaceId: "workspace-1", workspaceName: undefined, + identity: { + userId: "user-1", + email: undefined, + name: undefined, + }, expiresAt: undefined, }, ], diff --git a/packages/cli-engine/tests/engine.type-test.ts b/packages/cli-engine/tests/engine.type-test.ts index 86d31a60..c108fe1f 100644 --- a/packages/cli-engine/tests/engine.type-test.ts +++ b/packages/cli-engine/tests/engine.type-test.ts @@ -571,6 +571,7 @@ export const unmanagedIsUndeclared: false = unmanagedCommand.managesCredentials; export const sessionHasNoTokenMaterial: | "workspaceId" | "workspaceName" + | "identity" | "expiresAt" = undefined as unknown as keyof Session; export const activeCredentialHasNoTokenMaterial: diff --git a/packages/cli-engine/tests/management-api.test.ts b/packages/cli-engine/tests/management-api.test.ts index 224e89cf..16773dc1 100644 --- a/packages/cli-engine/tests/management-api.test.ts +++ b/packages/cli-engine/tests/management-api.test.ts @@ -51,6 +51,7 @@ function fakeCredentialManager( return { activeCredential: unusedManagerMethod("activeCredential"), sessions: unusedManagerMethod("sessions"), + enrichSessions: unusedManagerMethod("enrichSessions"), createSession: unusedManagerMethod("createSession"), selectSession: unusedManagerMethod("selectSession"), endSession: unusedManagerMethod("endSession"), @@ -73,6 +74,7 @@ const storedSessions = (...workspaceIds: readonly string[]) => ({ sessions: workspaceIds.map((workspaceId) => ({ workspaceId, workspaceName: undefined, + identity: undefined, expiresAt: undefined, })), selectedWorkspaceId: workspaceIds[0], diff --git a/packages/cli/src/auth/credential-manager.ts b/packages/cli/src/auth/credential-manager.ts index 19469272..e151538e 100644 --- a/packages/cli/src/auth/credential-manager.ts +++ b/packages/cli/src/auth/credential-manager.ts @@ -4,6 +4,7 @@ import type { ActiveAccessTokenOptions, ActiveCredential, Credential, + CredentialIdentity, CredentialManager, CredentialRefresher, Session, @@ -28,6 +29,7 @@ import { readCredentialState, resolveStateFilePath, type StoredSession, + type StoredSessionUser, withRefreshFileLock, withStateLock, writeCredentialState, @@ -50,9 +52,17 @@ export type FetchWorkspaceName = ( workspaceId: string, ) => Promise; +/** Looks up safe account metadata for the credential that was just minted. + * Best-effort: a failed lookup never prevents the session from being saved. */ +export type FetchSessionIdentity = ( + credential: Credential, + workspaceId: string, +) => Promise; + export interface FileCredentialManagerOptions { readonly env: Readonly>; readonly fetchWorkspaceName?: FetchWorkspaceName; + readonly fetchSessionIdentity?: FetchSessionIdentity; readonly refreshCredential?: CredentialRefresher; readonly debugWrite?: (text: string) => void; } @@ -115,6 +125,7 @@ export class FileCredentialManager implements CredentialManager { readonly #filePath: string; readonly #debug: DebugLog; readonly #fetchWorkspaceName: FetchWorkspaceName | undefined; + readonly #fetchSessionIdentity: FetchSessionIdentity | undefined; readonly #refreshCredential: CredentialRefresher | undefined; #actingAs: ActingAs = { kind: "unresolved" }; /** Built for the credential the process acts as. Every mutation that @@ -129,6 +140,7 @@ export class FileCredentialManager implements CredentialManager { this.#filePath = resolveStateFilePath(options.env).filePath; this.#debug = makeDebugLog(options.env, options.debugWrite); this.#fetchWorkspaceName = options.fetchWorkspaceName; + this.#fetchSessionIdentity = options.fetchSessionIdentity; this.#refreshCredential = options.refreshCredential; this.#debug(`state file ${this.#filePath}`); } @@ -161,10 +173,57 @@ export class FileCredentialManager implements CredentialManager { async sessions(): Promise { const state = await readCredentialState(this.#filePath); - return { - sessions: state.sessions.map((record) => toSession(record)), - selectedWorkspaceId: resolvedMarker(state) ?? undefined, - }; + return storedSessions(state); + } + + async enrichSessions(): Promise { + if (this.#fetchSessionIdentity === undefined) return this.sessions(); + const state = await readCredentialState(this.#filePath); + const candidates = state.sessions.filter( + (session) => session.user === undefined, + ); + if (candidates.length === 0) return storedSessions(state); + + const fetched = await Promise.all( + candidates.map(async (session) => ({ + workspaceId: session.workspaceId, + token: session.token, + identity: await this.#lookUpSessionIdentity( + storedSessionCredential(session), + session.workspaceId, + ), + })), + ); + const byWorkspaceId = new Map( + fetched + .filter( + ( + result, + ): result is typeof result & { identity: CredentialIdentity } => + result.identity !== undefined, + ) + .map((result) => [result.workspaceId, result] as const), + ); + if (byWorkspaceId.size === 0) return this.sessions(); + + return this.#mutate((current) => { + let changed = false; + const sessions = current.sessions.map((session) => { + const fetchedSession = byWorkspaceId.get(session.workspaceId); + if ( + session.user !== undefined || + fetchedSession === undefined || + fetchedSession.token !== session.token + ) { + return session; + } + changed = true; + return { ...session, user: storedUser(fetchedSession.identity) }; + }); + if (!changed) return { result: storedSessions(current) }; + const next = { ...current, sessions }; + return { state: next, result: storedSessions(next) }; + }); } async createSession( @@ -207,22 +266,33 @@ export class FileCredentialManager implements CredentialManager { this.#actAs({ kind: "session", workspaceId }); } - const name = await this.#lookUpWorkspaceName(credential, workspaceId); - if (name === undefined) return created; + const [name, identity] = await Promise.all([ + this.#lookUpWorkspaceName(credential, workspaceId), + this.#lookUpSessionIdentity(credential, workspaceId), + ]); + if (name === undefined && identity === undefined) return created; return this.#mutate((state) => { const record = state.sessions.find( (session) => session.workspaceId === workspaceId, ); - if (record === undefined) return { result: created }; - const named: StoredSession = { ...record, name }; + // Lookups happen outside the lock. Do not attach their result to a + // credential that another process saved for this workspace meanwhile. + if (record === undefined || record.token !== credential.token) { + return { result: created }; + } + const enriched: StoredSession = { + ...record, + ...(name === undefined ? {} : { name }), + ...(identity === undefined ? {} : { user: storedUser(identity) }), + }; const next: CredentialState = { ...state, sessions: state.sessions.map((session) => - session.workspaceId === workspaceId ? named : session, + session.workspaceId === workspaceId ? enriched : session, ), }; - return { state: next, result: toSession(named) }; + return { state: next, result: toSession(enriched) }; }); } @@ -362,6 +432,7 @@ export class FileCredentialManager implements CredentialManager { const rotated: StoredSession = { workspaceId: record.workspaceId, ...(record.name === undefined ? {} : { name: record.name }), + ...storedUserSlice(record.user), token: tokens.accessToken, ...(tokens.refreshToken === undefined ? {} @@ -522,6 +593,20 @@ export class FileCredentialManager implements CredentialManager { } } + async #lookUpSessionIdentity( + credential: Credential, + workspaceId: string, + ): Promise { + if (this.#fetchSessionIdentity === undefined) return undefined; + try { + return normalizedIdentity( + await this.#fetchSessionIdentity(credential, workspaceId), + ); + } catch { + return undefined; + } + } + /** One mutation: the short lock, a fresh read, one slice, one atomic * write. A slice that returns no state writes nothing. */ async #mutate( @@ -578,6 +663,15 @@ function expiresAtSlice( return expiresAt === undefined ? {} : { expiresAt: expiresAt.toISOString() }; } +function storedSessionCredential(record: StoredSession): Credential { + return { + token: record.token, + refreshToken: record.refreshToken, + expiresAt: + record.expiresAt === undefined ? undefined : new Date(record.expiresAt), + }; +} + /** The selection the manager will admit to: one that names a stored * session, or none. A dangling selection never escapes. */ function resolvedMarker(state: CredentialState): string | null { @@ -591,10 +685,18 @@ function resolvedMarker(state: CredentialState): string | null { return null; } +function storedSessions(state: CredentialState): StoredSessions { + return { + sessions: state.sessions.map((record) => toSession(record)), + selectedWorkspaceId: resolvedMarker(state) ?? undefined, + }; +} + function toSession(record: StoredSession): Session { return { workspaceId: record.workspaceId, workspaceName: record.name, + identity: storedIdentity(record), expiresAt: record.expiresAt === undefined ? undefined : new Date(record.expiresAt), }; @@ -606,11 +708,50 @@ function storedCredential(record: StoredSession): ActiveCredential { workspaceName: record.name, expiresAt: record.expiresAt === undefined ? undefined : new Date(record.expiresAt), - identity: claimedIdentity(record.token), + identity: storedIdentity(record), origin: { source: "stored" }, }; } +function storedIdentity(record: StoredSession): CredentialIdentity | undefined { + const user = record.user; + return user === undefined + ? claimedIdentity(record.token) + : { userId: user.id, email: user.email, name: user.name }; +} + +function storedUser(identity: CredentialIdentity): StoredSessionUser { + return { + ...(identity.userId === undefined ? {} : { id: identity.userId }), + ...(identity.email === undefined ? {} : { email: identity.email }), + ...(identity.name === undefined ? {} : { name: identity.name }), + }; +} + +function storedUserSlice(user: StoredSessionUser | undefined): { + user?: StoredSessionUser; +} { + return user === undefined ? {} : { user }; +} + +function normalizedIdentity( + identity: CredentialIdentity | undefined, +): CredentialIdentity | undefined { + if (identity === undefined) return undefined; + const userId = normalizedString(identity.userId); + const email = normalizedString(identity.email); + const name = normalizedString(identity.name); + if (userId === undefined && email === undefined && name === undefined) { + return undefined; + } + return { userId, email, name }; +} + +function normalizedString(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + /** An environment token whose claims name no workspace reports no * workspace id — never the empty string. */ function environmentCredential(token: string): ActiveCredential { diff --git a/packages/cli/src/auth/session-metadata.ts b/packages/cli/src/auth/session-metadata.ts new file mode 100644 index 00000000..652f565a --- /dev/null +++ b/packages/cli/src/auth/session-metadata.ts @@ -0,0 +1,45 @@ +import { createManagementApiClient } from "@prisma/management-api-sdk"; +import type { + FetchSessionIdentity, + FetchWorkspaceName, +} from "./credential-manager"; + +const IDENTITY_LOOKUP_TIMEOUT_MS = 3_000; + +function clientFor(apiBaseUrl: string, token: string) { + return createManagementApiClient({ baseUrl: apiBaseUrl, token }); +} + +/** Resolve the human workspace name with the workspace-bound credential that + * was just minted. The credential manager treats this as best-effort. */ +export function fetchWorkspaceName(apiBaseUrl: string): FetchWorkspaceName { + return async (credential, workspaceId) => { + const { data } = await clientFor(apiBaseUrl, credential.token).GET( + "/v1/workspaces/{id}", + { params: { path: { id: workspaceId } } }, + ); + const name = data?.data?.name; + return typeof name === "string" && name.trim().length > 0 + ? name.trim() + : undefined; + }; +} + +/** Resolve safe account metadata once at login. OAuth access tokens do not + * necessarily carry an email, so claims alone cannot distinguish sessions + * authorized by different Prisma accounts. */ +export function fetchSessionIdentity(apiBaseUrl: string): FetchSessionIdentity { + return async (credential) => { + const { data } = await clientFor(apiBaseUrl, credential.token).GET( + "/v1/me", + { signal: AbortSignal.timeout(IDENTITY_LOOKUP_TIMEOUT_MS) }, + ); + const user = data?.data?.user; + if (!user) return undefined; + return { + userId: user.id ?? undefined, + email: user.email ?? undefined, + name: user.name ?? undefined, + }; + }; +} diff --git a/packages/cli/src/auth/state-file.ts b/packages/cli/src/auth/state-file.ts index a22f4908..ea27bdb7 100644 --- a/packages/cli/src/auth/state-file.ts +++ b/packages/cli/src/auth/state-file.ts @@ -45,11 +45,20 @@ const REFRESH_LOCK_TIMINGS: LockTimings = { export interface StoredSession { readonly workspaceId: string; readonly name?: string; + /** Safe account metadata captured during login. Token material remains the + * source of authentication; this is only for identifying local sessions. */ + readonly user?: StoredSessionUser; readonly token: string; readonly refreshToken?: string; readonly expiresAt?: string; } +export interface StoredSessionUser { + readonly id?: string; + readonly email?: string; + readonly name?: string; +} + export interface CredentialState { readonly version: number; readonly sessions: readonly StoredSession[]; @@ -175,11 +184,13 @@ function isStoredSession(value: unknown): value is StoredSession { } function normalizeSession(session: StoredSession): StoredSession { + const user = normalizeStoredSessionUser(session.user); return { workspaceId: session.workspaceId, ...(typeof session.name === "string" && session.name.length > 0 ? { name: session.name } : {}), + ...(user === undefined ? {} : { user }), token: session.token, ...(typeof session.refreshToken === "string" && session.refreshToken.length > 0 @@ -191,6 +202,32 @@ function normalizeSession(session: StoredSession): StoredSession { }; } +function normalizeStoredSessionUser( + value: unknown, +): StoredSessionUser | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + const candidate = value as Record; + const id = normalizedString(candidate.id); + const email = normalizedString(candidate.email); + const name = normalizedString(candidate.name); + if (id === undefined && email === undefined && name === undefined) { + return undefined; + } + return { + ...(id === undefined ? {} : { id }), + ...(email === undefined ? {} : { email }), + ...(name === undefined ? {} : { name }), + }; +} + +function normalizedString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length === 0 ? undefined : trimmed; +} + /** Temp file in the same directory, fsync, rename, mode 0600 — a reader * only ever sees a complete state. The written file also carries the * legacy `tokens` mirror and the auth.context.json pointer stays in diff --git a/packages/cli/src/auth/workspace-name.ts b/packages/cli/src/auth/workspace-name.ts deleted file mode 100644 index 5e0aff0b..00000000 --- a/packages/cli/src/auth/workspace-name.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { createManagementApiClient } from "@prisma/management-api-sdk"; -import type { FetchWorkspaceName } from "./credential-manager"; - -/** The manager's injected name lookup: a static-token client over the - * credential just minted. The manager constructs no API client and - * treats any failure here as "no name". */ -export function fetchWorkspaceName(apiBaseUrl: string): FetchWorkspaceName { - return async (credential, workspaceId) => { - const client = createManagementApiClient({ - baseUrl: apiBaseUrl, - token: credential.token, - }); - const { data } = await client.GET("/v1/workspaces/{id}", { - params: { path: { id: workspaceId } }, - }); - const name = data?.data?.name; - return typeof name === "string" && name.trim().length > 0 - ? name.trim() - : undefined; - }; -} diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index dccbbaee..b26ead7f 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -14,13 +14,19 @@ import { environmentCredentialInForce } from "../../auth/service-token"; import { CLI_NAME } from "../../cli-name"; import { resolveAgentSetupTipCommand } from "./agent-setup-tip"; import { ENVIRONMENT_CREDENTIAL_NOTICE } from "./credential-card"; -import { sessionLabel } from "./session-ref"; +import { + type SessionUser, + sessionLabel, + sessionUser, + sessionUserLabel, +} from "./session-ref"; const TITLE = "Starting an authenticated CLI session."; const LOGIN_STEP = "Sign in via your browser"; export interface LoginResult { readonly workspace: { readonly id: string; readonly name: string | null }; + readonly user: SessionUser | null; readonly environmentCredentialInForce: boolean; } @@ -77,8 +83,10 @@ function presentationsFor( }, result: LoginResult, ): Presentations { + const user = sessionUserLabel(spec.session); const rows = [ { label: "status", value: "signed in" }, + ...(user === undefined ? [] : [{ label: "user", value: user }]), { label: "workspace", value: sessionLabel(spec.session) }, ]; return { @@ -151,6 +159,7 @@ export const authLoginCommand = defineCommand({ id: session.workspaceId, name: session.workspaceName ?? null, }, + user: sessionUser(session), environmentCredentialInForce: environmentSession, }; return ok( diff --git a/packages/cli/src/commands/auth/session-ref.ts b/packages/cli/src/commands/auth/session-ref.ts index faf522c2..ef737e78 100644 --- a/packages/cli/src/commands/auth/session-ref.ts +++ b/packages/cli/src/commands/auth/session-ref.ts @@ -15,6 +15,37 @@ export type SessionRefResolution = | { readonly kind: "no-match" } | { readonly kind: "ambiguous"; readonly matches: readonly Session[] }; +export interface SessionUser { + readonly id: string | null; + readonly email: string | null; + readonly name: string | null; +} + +/** The safe identity fields a command may expose for a stored session. */ +export function sessionUser(session: Session): SessionUser | null { + const identity = session.identity; + if (identity === undefined) return null; + return { + id: identity.userId ?? null, + email: identity.email ?? null, + name: identity.name ?? null, + }; +} + +/** The shortest useful human identity for a workspace session. */ +export function sessionUserLabel(session: Session): string | undefined { + const identity = session.identity; + return identity?.email ?? identity?.name ?? identity?.userId; +} + +/** Workspace first, account second: suitable for interactive choices. */ +export function sessionChoiceLabel(session: Session): string { + const user = sessionUserLabel(session); + return user === undefined + ? sessionLabel(session) + : `${sessionLabel(session)} — ${user}`; +} + /** Exact workspace id first, then case-insensitive workspace name. */ export function resolveSessionRef( sessions: readonly Session[], @@ -47,8 +78,21 @@ export function ambiguousSessionRefError( "AUTH.WORKSPACE_AMBIGUOUS", `More than one workspace session is named '${ref}'.`, { - why: `Matching workspaces: ${matches.map((match) => match.workspaceId).join(", ")}.`, - meta: { workspaceIds: matches.map((match) => match.workspaceId) }, + why: `Matching sessions: ${matches + .map((match) => { + const user = sessionUserLabel(match); + return user === undefined + ? match.workspaceId + : `${match.workspaceId} (${user})`; + }) + .join(", ")}.`, + meta: { + workspaceIds: matches.map((match) => match.workspaceId), + sessions: matches.map((match) => ({ + workspaceId: match.workspaceId, + user: sessionUser(match), + })), + }, nextActions: [ { kind: "run-command", diff --git a/packages/cli/src/commands/auth/workspace-list.ts b/packages/cli/src/commands/auth/workspace-list.ts index b1634242..f1f13b00 100644 --- a/packages/cli/src/commands/auth/workspace-list.ts +++ b/packages/cli/src/commands/auth/workspace-list.ts @@ -8,11 +8,17 @@ import { type NextAction, ok } from "@prisma/cli-engine/protocol"; import { environmentCredentialInForce } from "../../auth/service-token"; import { CLI_NAME } from "../../cli-name"; import { ENVIRONMENT_CREDENTIAL_NOTICE } from "./credential-card"; -import { sessionLabel } from "./session-ref"; +import { sessionLabel, sessionUser, sessionUserLabel } from "./session-ref"; const LOGIN_NEXT_ACTION: NextAction = { kind: "run-command", - label: "Sign in", + label: "Authorize a workspace", + command: `${CLI_NAME} auth login`, +}; + +const AUTHORIZE_ANOTHER_NEXT_ACTION: NextAction = { + kind: "run-command", + label: "Authorize another workspace", command: `${CLI_NAME} auth login`, }; @@ -25,12 +31,14 @@ export interface WorkspaceListResult { export function serializeWorkspaceList(result: WorkspaceListResult) { return { context: { + scope: "local-sessions" as const, environmentCredentialInForce: result.environmentCredentialInForce, currentWorkspaceId: result.selectedWorkspaceId ?? null, }, items: result.sessions.map((session) => ({ workspaceId: session.workspaceId, workspaceName: session.workspaceName ?? null, + user: sessionUser(session), current: session.workspaceId === result.selectedWorkspaceId, expiresAt: session.expiresAt?.toISOString() ?? null, })), @@ -39,9 +47,10 @@ export function serializeWorkspaceList(result: WorkspaceListResult) { } function listPresentations(result: WorkspaceListResult): Presentations { - const columns = ["name", "id", "status"]; + const columns = ["workspace", "user", "id", "status"]; const rows = result.sessions.map((session) => [ sessionLabel(session), + sessionUserLabel(session) ?? "", session.workspaceId, session.workspaceId === result.selectedWorkspaceId ? "current" : "", ]); @@ -73,18 +82,21 @@ function listPresentations(result: WorkspaceListResult): Presentations { ], stdout: () => rows.map((row) => row.join(" ").trimEnd()), json: () => serializeWorkspaceList(result), - next: () => (result.sessions.length === 0 ? [LOGIN_NEXT_ACTION] : []), + next: () => + result.sessions.length === 0 + ? [LOGIN_NEXT_ACTION] + : [AUTHORIZE_ANOTHER_NEXT_ACTION], }; } export const authWorkspaceListCommand = defineCommand({ managesCredentials: true, help: { - summary: "List your workspace sessions", + summary: "List workspace sessions authorized on this machine", examples: ["auth workspace list", "auth workspace list --json"], }, handler: async (_args, ctx) => { - const stored = await ctx.credentialManager.sessions(); + const stored = await ctx.credentialManager.enrichSessions(); const result: WorkspaceListResult = { sessions: stored.sessions, selectedWorkspaceId: stored.selectedWorkspaceId, diff --git a/packages/cli/src/commands/auth/workspace-logout.ts b/packages/cli/src/commands/auth/workspace-logout.ts index 8ab41e81..495303fa 100644 --- a/packages/cli/src/commands/auth/workspace-logout.ts +++ b/packages/cli/src/commands/auth/workspace-logout.ts @@ -8,22 +8,33 @@ import { ok } from "@prisma/cli-engine/protocol"; import { environmentCredentialInForce } from "../../auth/service-token"; import { CLI_NAME } from "../../cli-name"; import { ENVIRONMENT_CREDENTIAL_NOTICE } from "./credential-card"; -import { requireSession, sessionLabel } from "./session-ref"; +import { + requireSession, + type SessionUser, + sessionLabel, + sessionUser, + sessionUserLabel, +} from "./session-ref"; export interface WorkspaceLogoutResult { readonly workspace: { readonly id: string; readonly name: string | null }; + readonly user: SessionUser | null; readonly wasSelected: boolean; } function logoutPresentations( spec: { readonly label: string; + readonly user: string | undefined; readonly wasSelected: boolean; readonly environmentCredentialInForce: boolean; }, result: WorkspaceLogoutResult, ): Presentations { - const rows = [{ label: "workspace", value: spec.label }]; + const rows = [ + { label: "workspace", value: spec.label }, + ...(spec.user === undefined ? [] : [{ label: "user", value: spec.user }]), + ]; return { json: () => result, human: () => [ @@ -85,7 +96,7 @@ export const authWorkspaceLogoutCommand = defineCommand({ examples: ["auth workspace logout my-workspace"], }, handler: async (args, ctx) => { - const stored = await ctx.credentialManager.sessions(); + const stored = await ctx.credentialManager.enrichSessions(); const session = requireSession(stored.sessions, args.positionals.workspace); const wasSelected = session.workspaceId === stored.selectedWorkspaceId; await ctx.credentialManager.endSession(session.workspaceId); @@ -94,6 +105,7 @@ export const authWorkspaceLogoutCommand = defineCommand({ id: session.workspaceId, name: session.workspaceName ?? null, }, + user: sessionUser(session), wasSelected, }; return ok( @@ -102,6 +114,7 @@ export const authWorkspaceLogoutCommand = defineCommand({ logoutPresentations( { label: sessionLabel(session), + user: sessionUserLabel(session), wasSelected, environmentCredentialInForce: environmentCredentialInForce(ctx.env), }, diff --git a/packages/cli/src/commands/auth/workspace-use.ts b/packages/cli/src/commands/auth/workspace-use.ts index a8a1a03c..d42ce4ac 100644 --- a/packages/cli/src/commands/auth/workspace-use.ts +++ b/packages/cli/src/commands/auth/workspace-use.ts @@ -11,10 +11,18 @@ import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import { environmentCredentialInForce } from "../../auth/service-token"; import { CLI_NAME } from "../../cli-name"; import { ENVIRONMENT_CREDENTIAL_NOTICE } from "./credential-card"; -import { requireSession, sessionLabel } from "./session-ref"; +import { + requireSession, + type SessionUser, + sessionChoiceLabel, + sessionLabel, + sessionUser, + sessionUserLabel, +} from "./session-ref"; export interface WorkspaceUseResult { readonly workspace: { readonly id: string; readonly name: string | null }; + readonly user: SessionUser | null; readonly previousWorkspaceId: string | null; } @@ -42,11 +50,13 @@ function usePresentations( }, result: WorkspaceUseResult, ): Presentations { + const user = sessionUserLabel(spec.session); const rows = [ ...(spec.previous === undefined ? [] : [{ label: "previous", value: sessionLabel(spec.previous) }]), { label: "workspace", value: sessionLabel(spec.session) }, + ...(user === undefined ? [] : [{ label: "user", value: user }]), ]; return { json: () => result, @@ -103,7 +113,7 @@ export const authWorkspaceUseCommand = defineCommand({ examples: ["auth workspace use", "auth workspace use my-workspace"], }, handler: async (args, ctx) => { - const stored = await ctx.credentialManager.sessions(); + const stored = await ctx.credentialManager.enrichSessions(); if (stored.sessions.length === 0) { throw noWorkspaceSessionsError(); } @@ -123,6 +133,7 @@ export const authWorkspaceUseCommand = defineCommand({ id: session.workspaceId, name: session.workspaceName ?? null, }, + user: sessionUser(session), previousWorkspaceId: previous?.workspaceId ?? null, }; return ok( @@ -155,7 +166,7 @@ async function promptForSession( "Select a workspace", stored.sessions.map((session) => ({ value: session.workspaceId, - label: `${sessionLabel(session)} (${session.workspaceId})${ + label: `${sessionChoiceLabel(session)} (${session.workspaceId})${ session.workspaceId === stored.selectedWorkspaceId ? " current" : "" }`, })), diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts index 439426dd..a1001a48 100644 --- a/packages/cli/src/runtime.ts +++ b/packages/cli/src/runtime.ts @@ -16,12 +16,15 @@ import { } from "./auth/client"; import { FileCredentialManager } from "./auth/credential-manager"; import { makeCredentialRefresher } from "./auth/refresh"; +import { + fetchSessionIdentity, + fetchWorkspaceName, +} from "./auth/session-metadata"; import { DEPRECATED_STATE_FILE_ENV_VAR, resolveStateFilePath, STATE_FILE_ENV_VAR, } from "./auth/state-file"; -import { fetchWorkspaceName } from "./auth/workspace-name"; import { runPackageManager } from "./package-manager-runner"; import { makeSpawnChild } from "./spawn"; @@ -137,6 +140,7 @@ export async function assembleRuntime(proc: HostProcess): Promise { credentialManager: new FileCredentialManager({ env: proc.env, fetchWorkspaceName: fetchWorkspaceName(apiBaseUrl), + fetchSessionIdentity: fetchSessionIdentity(apiBaseUrl), refreshCredential: makeCredentialRefresher(authBaseUrl), }), managementApiClientConfig: { diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index 069c2c2d..45a22250 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -56,11 +56,17 @@ function tokenFor( return mintTestJwt({ workspace_id: workspaceId, ...claims }); } -function credentialFor(workspaceId: string) { +function credentialFor( + workspaceId: string, + user: { readonly id: string; readonly email: string } = { + id: "usr_456", + email: "bob@example.com", + }, +) { return { token: tokenFor(workspaceId, { - sub: "usr_456", - email: "bob@example.com", + sub: user.id, + email: user.email, }), refreshToken: `refresh_${workspaceId}`, expiresAt: undefined, @@ -70,11 +76,12 @@ function credentialFor(workspaceId: string) { function record( workspaceId: string, workspaceName: string | undefined, + user?: { readonly id: string; readonly email: string }, ): SessionRecord { return { workspaceId, workspaceName, - credential: credentialFor(workspaceId), + credential: credentialFor(workspaceId, user), }; } @@ -172,6 +179,11 @@ describe("auth login", () => { expect(result.exitCode).toBe(0); expect(resultOf(result)).toEqual({ workspace: { id: "ws_1", name: null }, + user: { + id: "usr_456", + email: "bob@example.com", + name: null, + }, environmentCredentialInForce: false, }); const state = cli.credentialManager?.state(); @@ -202,6 +214,7 @@ describe("auth login", () => { }); expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("user: bob@example.com"); expect(result.stderr).toContain( "PRISMA_SERVICE_TOKEN supplies the credential in force", ); @@ -443,7 +456,10 @@ describe("auth workspace list", () => { }); expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("Acme Inc ws_1\nws_2 ws_2 current\n"); + expect(result.stdout).toBe( + "Acme Inc bob@example.com ws_1\n" + + "ws_2 bob@example.com ws_2 current\n", + ); }); it("serializes the sessions and the current marker for json", async () => { @@ -456,6 +472,7 @@ describe("auth workspace list", () => { expect(resultOf(result)).toEqual({ context: { + scope: "local-sessions", environmentCredentialInForce: false, currentWorkspaceId: "ws_1", }, @@ -463,6 +480,11 @@ describe("auth workspace list", () => { { workspaceId: "ws_1", workspaceName: "Acme Inc", + user: { + id: "usr_456", + email: "bob@example.com", + name: null, + }, current: true, expiresAt: null, }, @@ -471,6 +493,30 @@ describe("auth workspace list", () => { }); }); + it("uses null when a legacy session token carries no user identity", async () => { + const cli = makeCli({ + sessions: [ + { + workspaceId: "ws_legacy", + workspaceName: "Legacy workspace", + credential: { + token: tokenFor("ws_legacy"), + refreshToken: "refresh_legacy", + expiresAt: undefined, + }, + }, + ], + selectedWorkspaceId: "ws_legacy", + }); + + const result = await cli.run(["auth", "workspace", "list", "--json"]); + + expect(resultOf(result)).toMatchObject({ + items: [{ workspaceId: "ws_legacy", user: null }], + }); + expect(result.stdout).not.toContain("undefined"); + }); + it("states that the environment credential is in force", async () => { const cli = makeCli({ sessions: [record("ws_1", "Acme Inc")], @@ -488,14 +534,55 @@ describe("auth workspace list", () => { }); }); - it("offers sign-in when there are no sessions", async () => { + it("offers workspace authorization when there are no sessions", async () => { const result = await makeCli().run(["auth", "workspace", "list", "--json"]); expect(result.exitCode).toBe(0); expect(result.presented?.presentation.next).toEqual([ { kind: "run-command", - label: "Sign in", + label: "Authorize a workspace", + command: "prisma-cli auth login", + }, + ]); + }); + + it("distinguishes sessions from different users and offers another authorization", async () => { + const cli = makeCli({ + sessions: [ + record("ws_personal", "Personal workspace", { + id: "usr_personal", + email: "personal@example.com", + }), + record("ws_work", "Prisma DevRel", { + id: "usr_work", + email: "developer@prisma.io", + }), + ], + selectedWorkspaceId: "ws_work", + }); + + const result = await cli.run(["auth", "workspace", "list", "--json"]); + + expect(resultOf(result)).toMatchObject({ + context: { scope: "local-sessions", currentWorkspaceId: "ws_work" }, + items: [ + { + workspaceId: "ws_personal", + user: { id: "usr_personal", email: "personal@example.com" }, + current: false, + }, + { + workspaceId: "ws_work", + user: { id: "usr_work", email: "developer@prisma.io" }, + current: true, + }, + ], + }); + expect(result.presented?.presentation.next).toEqual([ + { + kind: "run-command", + label: "Authorize another workspace", command: "prisma-cli auth login", }, ]); @@ -519,6 +606,11 @@ describe("auth workspace use", () => { expect(result.exitCode).toBe(0); expect(resultOf(result)).toEqual({ workspace: { id: "ws_2", name: "Globex" }, + user: { + id: "usr_456", + email: "bob@example.com", + name: null, + }, previousWorkspaceId: "ws_1", }); expect(cli.credentialManager?.state().selectedWorkspaceId).toBe("ws_2"); @@ -639,6 +731,11 @@ describe("auth workspace logout", () => { expect(result.exitCode).toBe(0); expect(resultOf(result)).toEqual({ workspace: { id: "ws_1", name: "Acme Inc" }, + user: { + id: "usr_456", + email: "bob@example.com", + name: null, + }, wasSelected: false, }); expect( @@ -729,6 +826,11 @@ describe("auth workspace logout", () => { expect(result.exitCode).toBe(0); expect(resultOf(result)).toEqual({ workspace: { id: "ws_1", name: "Acme Inc" }, + user: { + id: "usr_456", + email: "bob@example.com", + name: null, + }, wasSelected: true, }); expect(manager.state().sessions).toEqual([]); @@ -924,6 +1026,11 @@ describe("the shapes the commands hand back", () => { const session: Session = { workspaceId: "ws_1", workspaceName: "Acme Inc", + identity: { + userId: "usr_456", + email: "bob@example.com", + name: undefined, + }, expiresAt: undefined, }; const active: ActiveCredential = { diff --git a/packages/cli/tests/credential-manager.test.ts b/packages/cli/tests/credential-manager.test.ts index ed15458c..b7fe2c3d 100644 --- a/packages/cli/tests/credential-manager.test.ts +++ b/packages/cli/tests/credential-manager.test.ts @@ -14,7 +14,11 @@ import fsPromises, { } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { CredentialRefresher, TokenStorage } from "@prisma/cli-engine"; +import type { + CredentialIdentity, + CredentialRefresher, + TokenStorage, +} from "@prisma/cli-engine"; import { mintTestJwt } from "@prisma/cli-engine/testing"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -90,6 +94,10 @@ function makeManager( credential: { token: string }, workspaceId: string, ) => Promise; + fetchSessionIdentity?: ( + credential: { token: string }, + workspaceId: string, + ) => Promise; refreshCredential?: CredentialRefresher; debugWrite?: (text: string) => void; } = {}, @@ -97,6 +105,7 @@ function makeManager( return new FileCredentialManager({ env: { PRISMA_AUTH_FILE: stateFilePath, ...options.env }, fetchWorkspaceName: options.fetchWorkspaceName, + fetchSessionIdentity: options.fetchSessionIdentity, refreshCredential: options.refreshCredential, debugWrite: options.debugWrite, }); @@ -795,6 +804,67 @@ describe("the environment credential", () => { }); describe("createSession", () => { + it("persists and exposes the authorizing account without exposing token material", async () => { + const manager = makeManager({ + fetchSessionIdentity: async () => ({ + userId: "usr_work", + email: "developer@prisma.io", + name: "Prisma Developer", + }), + }); + const credential = { + // Real OAuth tokens identify the user but do not necessarily carry the + // email needed to distinguish accounts in workspace-session output. + token: mintToken(WORKSPACE_A, { sub: "user:opaque-subject" }), + refreshToken: "refresh-work", + expiresAt: undefined, + }; + + const created = await manager.createSession(credential, WORKSPACE_A); + const listed = (await manager.sessions()).sessions[0]; + + expect(created.identity).toEqual({ + userId: "usr_work", + email: "developer@prisma.io", + name: "Prisma Developer", + }); + expect(listed?.identity).toEqual(created.identity); + expect( + (await readCredentialState(stateFilePath)).sessions[0]?.user, + ).toEqual({ + id: "usr_work", + email: "developer@prisma.io", + name: "Prisma Developer", + }); + expect(Object.keys(created)).not.toContain("token"); + expect(Object.keys(listed ?? {})).not.toContain("token"); + }); + + it("falls back to credential claims when account enrichment fails", async () => { + const manager = makeManager({ + fetchSessionIdentity: async () => { + throw new Error("offline"); + }, + }); + const session = await manager.createSession( + { + token: mintToken(WORKSPACE_A, { sub: "user:claimed" }), + refreshToken: "refresh-work", + expiresAt: undefined, + }, + WORKSPACE_A, + ); + + expect(session.identity).toEqual({ + userId: "user:claimed", + email: undefined, + name: undefined, + }); + expect( + (await readCredentialState(stateFilePath)).sessions[0]?.user, + ).toBeUndefined(); + }); + it("refuses a credential whose workspace_id claim names another workspace", async () => { const manager = makeManager(); await expect( @@ -873,6 +943,54 @@ describe("createSession", () => { }); }); + it("does not attach stale account metadata to a concurrently replaced session", async () => { + let releaseFetch: () => void = () => {}; + let markFetchStarted: () => void = () => {}; + const fetchStarted = new Promise((resolve) => { + markFetchStarted = resolve; + }); + const first = makeManager({ + fetchSessionIdentity: async () => { + markFetchStarted(); + await new Promise((resolve) => { + releaseFetch = resolve; + }); + return { + userId: "usr_first", + email: "first@example.com", + name: undefined, + }; + }, + }); + const firstLogin = first.createSession( + { + token: mintToken(WORKSPACE_A, { sub: "user:first" }), + refreshToken: "refresh-first", + expiresAt: undefined, + }, + WORKSPACE_A, + ); + + await fetchStarted; + await makeManager().createSession( + { + token: mintToken(WORKSPACE_A, { sub: "user:second" }), + refreshToken: "refresh-second", + expiresAt: undefined, + }, + WORKSPACE_A, + ); + releaseFetch(); + await firstLogin; + + const state = await readCredentialState(stateFilePath); + expect(state.sessions[0]).toMatchObject({ refreshToken: "refresh-second" }); + expect(state.sessions[0]?.user).toBeUndefined(); + expect((await makeManager().sessions()).sessions[0]?.identity?.userId).toBe( + "user:second", + ); + }); + it("upserts by workspace id, keeping the stored name and moving the marker", async () => { const manager = makeManager({ fetchWorkspaceName: async () => "Workspace A", @@ -895,10 +1013,75 @@ describe("createSession", () => { }); }); +describe("enrichSessions", () => { + it("backfills safe account metadata for an existing session", async () => { + await makeManager().createSession( + { + token: mintToken(WORKSPACE_A, { sub: "user:legacy" }), + refreshToken: "refresh-legacy", + expiresAt: undefined, + }, + WORKSPACE_A, + ); + const fetchSessionIdentity = vi.fn(async () => ({ + userId: "usr_work", + email: "developer@prisma.io", + name: "Prisma Developer", + })); + const manager = makeManager({ fetchSessionIdentity }); + + const first = await manager.enrichSessions(); + const second = await manager.enrichSessions(); + + expect(first.sessions[0]?.identity).toEqual({ + userId: "usr_work", + email: "developer@prisma.io", + name: "Prisma Developer", + }); + expect(second).toEqual(first); + expect(fetchSessionIdentity).toHaveBeenCalledTimes(1); + expect( + (await readCredentialState(stateFilePath)).sessions[0]?.user, + ).toEqual({ + id: "usr_work", + email: "developer@prisma.io", + name: "Prisma Developer", + }); + }); + + it("returns local sessions when metadata enrichment fails", async () => { + await makeManager().createSession( + { + token: mintToken(WORKSPACE_A, { sub: "user:legacy" }), + refreshToken: "refresh-legacy", + expiresAt: undefined, + }, + WORKSPACE_A, + ); + const manager = makeManager({ + fetchSessionIdentity: async () => { + throw new Error("offline"); + }, + }); + + const stored = await manager.enrichSessions(); + + expect(stored.sessions[0]?.identity?.userId).toBe("user:legacy"); + expect( + (await readCredentialState(stateFilePath)).sessions[0]?.user, + ).toBeUndefined(); + }); +}); + describe("the file-backed TokenStorage", () => { it("writes only the token fields on rotation and re-derives the expiry", async () => { const manager = makeManager({ fetchWorkspaceName: async () => "Workspace A", + fetchSessionIdentity: async () => ({ + userId: "usr_work", + email: "developer@prisma.io", + name: "Prisma Developer", + }), }); await manager.createSession(credentialFor(WORKSPACE_A), WORKSPACE_A); await makeManager().createSession(credentialFor(WORKSPACE_B), WORKSPACE_B); @@ -916,6 +1099,11 @@ describe("the file-backed TokenStorage", () => { ); expect(record).toMatchObject({ name: "Workspace A", + user: { + id: "usr_work", + email: "developer@prisma.io", + name: "Prisma Developer", + }, token: rotated, refreshToken: "refresh-2", expiresAt: new Date(2_000_000_000 * 1000).toISOString(), diff --git a/packages/cli/tests/golden-rendering.test.ts b/packages/cli/tests/golden-rendering.test.ts index 54efae09..41242ddf 100644 --- a/packages/cli/tests/golden-rendering.test.ts +++ b/packages/cli/tests/golden-rendering.test.ts @@ -30,7 +30,11 @@ function record(workspaceId: string, workspaceName: string): SessionRecord { workspaceId, workspaceName, credential: { - token: mintTestJwt({ workspace_id: workspaceId }), + token: mintTestJwt({ + workspace_id: workspaceId, + sub: `usr_${workspaceId}`, + email: `${workspaceId}@example.com`, + }), refreshToken: `refresh_${workspaceId}`, expiresAt: undefined, }, @@ -112,11 +116,16 @@ describe("golden rendering", () => { expect(result.stderr).toBe( "ℹ Listing your workspace sessions on this machine.\n" + "\n" + - "Name Id Status\n" + - "Acme Inc ws_1 current\n" + - "Globex ws_2 \u2014\n", + "Workspace User Id Status\n" + + "Acme Inc ws_1@example.com ws_1 current\n" + + "Globex ws_2@example.com ws_2 \u2014\n" + + "\n" + + "→ Authorize another workspace: prisma-cli auth login\n", + ); + expect(result.stdout).toBe( + "Acme Inc ws_1@example.com ws_1 current\n" + + "Globex ws_2@example.com ws_2\n", ); - expect(result.stdout).toBe("Acme Inc ws_1 current\nGlobex ws_2\n"); }); /** @@ -163,7 +172,7 @@ describe("golden rendering", () => { expect(result.exitCode).toBe(2); expect(result.stderr).toBe( "✘ [AUTH.WORKSPACE_AMBIGUOUS] More than one workspace session is named 'Acme Inc'.\n" + - " why: Matching workspaces: ws_1, ws_9.\n" + + " why: Matching sessions: ws_1 (ws_1@example.com), ws_9 (ws_9@example.com).\n" + "→ List your workspace sessions and pass a workspace id: prisma-cli auth workspace list\n", ); expect(result.stdout).toBe(""); @@ -207,9 +216,11 @@ describe("golden rendering", () => { expect(result.stderr).toBe( "\u001b[34m\u2139\u001b[39m Listing your workspace sessions on this machine.\n" + "\n" + - "\u001b[36mName \u001b[39m \u001b[36mId \u001b[39m \u001b[36mStatus\u001b[39m\n" + - "Acme Inc ws_1 current\n" + - "Globex ws_2 \u2014\n", + "\u001b[36mWorkspace\u001b[39m \u001b[36mUser \u001b[39m \u001b[36mId \u001b[39m \u001b[36mStatus\u001b[39m\n" + + "Acme Inc ws_1@example.com ws_1 current\n" + + "Globex ws_2@example.com ws_2 \u2014\n" + + "\n" + + "\u001b[36m→\u001b[39m Authorize another workspace: \u001b[36mprisma-cli auth login\u001b[39m\n", ); }); }); diff --git a/packages/cli/tests/session-metadata.test.ts b/packages/cli/tests/session-metadata.test.ts new file mode 100644 index 00000000..1037808a --- /dev/null +++ b/packages/cli/tests/session-metadata.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { + fetchSessionIdentity, + fetchWorkspaceName, +} from "../src/auth/session-metadata"; +import { + FAKE_WORKSPACE_ID, + type FakeManagementApi, + startFakeManagementApi, +} from "./helpers/fake-management-api"; + +const CREDENTIAL = { + token: "test-access-token", + refreshToken: undefined, + expiresAt: undefined, +}; + +let api: FakeManagementApi | undefined; + +afterEach(async () => { + await api?.close(); + api = undefined; +}); + +describe("login session metadata", () => { + it("resolves the workspace name and authorizing account through the API", async () => { + api = await startFakeManagementApi(); + + const [workspaceName, identity] = await Promise.all([ + fetchWorkspaceName(api.baseUrl)(CREDENTIAL, FAKE_WORKSPACE_ID), + fetchSessionIdentity(api.baseUrl)(CREDENTIAL, FAKE_WORKSPACE_ID), + ]); + + expect(workspaceName).toBe("Acme Inc"); + expect(identity).toEqual({ + userId: "usr_456", + email: "dev@example.com", + name: "Dev", + }); + expect([...api.requests].sort()).toEqual( + [`GET /v1/me`, `GET /v1/workspaces/${FAKE_WORKSPACE_ID}`].sort(), + ); + }); +}); From e627c95b6b6262c2f431c7c182b298026057519c Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Wed, 19 Aug 2026 14:15:43 +0530 Subject: [PATCH 2/2] refactor(auth): keep account metadata CLI-local Signed-off-by: Aman Varshney --- packages/cli-engine/src/credential-manager.ts | 15 +---- .../src/environment-credential-manager.ts | 4 -- .../src/in-memory-credential-manager.ts | 5 -- .../tests/credential-manager.test.ts | 10 ---- packages/cli-engine/tests/engine.type-test.ts | 1 - .../cli-engine/tests/management-api.test.ts | 2 - packages/cli/src/auth/credential-manager.ts | 60 ++++++++++++++++--- packages/cli/src/commands/auth/session-ref.ts | 5 +- .../cli/src/commands/auth/workspace-list.ts | 3 +- .../cli/src/commands/auth/workspace-logout.ts | 3 +- .../cli/src/commands/auth/workspace-use.ts | 3 +- packages/cli/tests/auth.test.ts | 15 +++-- packages/cli/tests/golden-rendering.test.ts | 7 ++- .../account-aware-credential-manager.ts | 59 ++++++++++++++++++ 14 files changed, 140 insertions(+), 52 deletions(-) create mode 100644 packages/cli/tests/helpers/account-aware-credential-manager.ts diff --git a/packages/cli-engine/src/credential-manager.ts b/packages/cli-engine/src/credential-manager.ts index 665469c3..1405b200 100644 --- a/packages/cli-engine/src/credential-manager.ts +++ b/packages/cli-engine/src/credential-manager.ts @@ -26,10 +26,6 @@ export interface Credential { export interface Session { readonly workspaceId: string; readonly workspaceName: string | undefined; - /** Who authorized this workspace session. A persistent manager may enrich - * this from the account API and fall back to credential claims. It contains - * no token material and may be absent for workspace-only credentials. */ - readonly identity: CredentialIdentity | undefined; /** The stored ACCESS TOKEN's expiry, which rotation changes — not a * deadline on the logged-in-ness. */ readonly expiresAt: Date | undefined; @@ -109,18 +105,13 @@ export interface CredentialManager { /** The stored sessions and the selection, read fresh. Local-only. */ sessions(): Promise; - /** Best-effort remote enrichment for session display metadata. Persistent - * managers may cache safe account details; failure still returns the local - * sessions. Commands opt into this explicitly so normal reads stay local. */ - enrichSessions(): Promise; - /** * Login's write. The caller names the workspace that identifies the * session; for workspace-bound credentials the manager verifies the * workspace_id claim matches and refuses on mismatch. Upserts by - * workspaceId and selects it. The workspace name and safe user identity are - * fetched best-effort after the write — failure leaves either undefined and - * never fails login. + * workspaceId and selects it. The workspace name is fetched + * best-effort after the write — failure leaves it undefined, never + * fails login. */ createSession(credential: Credential, workspaceId: string): Promise; diff --git a/packages/cli-engine/src/environment-credential-manager.ts b/packages/cli-engine/src/environment-credential-manager.ts index 23653203..dedd1db2 100644 --- a/packages/cli-engine/src/environment-credential-manager.ts +++ b/packages/cli-engine/src/environment-credential-manager.ts @@ -76,10 +76,6 @@ export class EnvironmentCredentialManager implements CredentialManager { return { sessions: [], selectedWorkspaceId: undefined }; } - async enrichSessions(): Promise { - return this.sessions(); - } - async createSession( _credential: Credential, _workspaceId: string, diff --git a/packages/cli-engine/src/in-memory-credential-manager.ts b/packages/cli-engine/src/in-memory-credential-manager.ts index 9b26b34f..81b973c0 100644 --- a/packages/cli-engine/src/in-memory-credential-manager.ts +++ b/packages/cli-engine/src/in-memory-credential-manager.ts @@ -85,7 +85,6 @@ function asSession(record: SessionRecord): Session { return { workspaceId: record.workspaceId, workspaceName: record.workspaceName, - identity: claimedIdentity(record.credential.token), expiresAt: record.credential.expiresAt, }; } @@ -235,10 +234,6 @@ export class InMemoryCredentialManager implements CredentialManager { }; } - async enrichSessions(): Promise { - return this.sessions(); - } - async createSession( credential: Credential, workspaceId: string, diff --git a/packages/cli-engine/tests/credential-manager.test.ts b/packages/cli-engine/tests/credential-manager.test.ts index 861b07b8..d7050a88 100644 --- a/packages/cli-engine/tests/credential-manager.test.ts +++ b/packages/cli-engine/tests/credential-manager.test.ts @@ -442,11 +442,6 @@ describe("session mutations and state read-back", () => { expect(selected).toEqual({ workspaceId: "workspace-2", workspaceName: undefined, - identity: { - userId: "user-1", - email: undefined, - name: undefined, - }, expiresAt: undefined, }); expect(manager.state().selectedWorkspaceId).toBe("workspace-2"); @@ -512,11 +507,6 @@ describe("session mutations and state read-back", () => { { workspaceId: "workspace-1", workspaceName: undefined, - identity: { - userId: "user-1", - email: undefined, - name: undefined, - }, expiresAt: undefined, }, ], diff --git a/packages/cli-engine/tests/engine.type-test.ts b/packages/cli-engine/tests/engine.type-test.ts index c108fe1f..86d31a60 100644 --- a/packages/cli-engine/tests/engine.type-test.ts +++ b/packages/cli-engine/tests/engine.type-test.ts @@ -571,7 +571,6 @@ export const unmanagedIsUndeclared: false = unmanagedCommand.managesCredentials; export const sessionHasNoTokenMaterial: | "workspaceId" | "workspaceName" - | "identity" | "expiresAt" = undefined as unknown as keyof Session; export const activeCredentialHasNoTokenMaterial: diff --git a/packages/cli-engine/tests/management-api.test.ts b/packages/cli-engine/tests/management-api.test.ts index 16773dc1..224e89cf 100644 --- a/packages/cli-engine/tests/management-api.test.ts +++ b/packages/cli-engine/tests/management-api.test.ts @@ -51,7 +51,6 @@ function fakeCredentialManager( return { activeCredential: unusedManagerMethod("activeCredential"), sessions: unusedManagerMethod("sessions"), - enrichSessions: unusedManagerMethod("enrichSessions"), createSession: unusedManagerMethod("createSession"), selectSession: unusedManagerMethod("selectSession"), endSession: unusedManagerMethod("endSession"), @@ -74,7 +73,6 @@ const storedSessions = (...workspaceIds: readonly string[]) => ({ sessions: workspaceIds.map((workspaceId) => ({ workspaceId, workspaceName: undefined, - identity: undefined, expiresAt: undefined, })), selectedWorkspaceId: workspaceIds[0], diff --git a/packages/cli/src/auth/credential-manager.ts b/packages/cli/src/auth/credential-manager.ts index e151538e..d8f60ba7 100644 --- a/packages/cli/src/auth/credential-manager.ts +++ b/packages/cli/src/auth/credential-manager.ts @@ -8,7 +8,6 @@ import type { CredentialManager, CredentialRefresher, Session, - StoredSessions, TokenStorage, } from "@prisma/cli-engine"; import { @@ -59,6 +58,53 @@ export type FetchSessionIdentity = ( workspaceId: string, ) => Promise; +export type AccountSession = Session & { + readonly identity: CredentialIdentity | undefined; +}; + +export interface AccountStoredSessions { + readonly sessions: readonly AccountSession[]; + readonly selectedWorkspaceId: string | undefined; +} + +interface AccountAwareCredentialManager extends CredentialManager { + enrichSessions(): Promise; +} + +/** Session display metadata is a CLI concern, not part of the shared engine + * contract. FileCredentialManager provides it; other managers degrade to the + * standard local session shape without inventing an account identity. */ +export async function sessionsForDisplay( + manager: CredentialManager, +): Promise { + if (isAccountAwareCredentialManager(manager)) { + return manager.enrichSessions(); + } + const stored = await manager.sessions(); + return { + sessions: stored.sessions.map(asAccountSession), + selectedWorkspaceId: stored.selectedWorkspaceId, + }; +} + +export function sessionIdentity( + session: Session, +): CredentialIdentity | undefined { + return normalizedIdentity( + Reflect.get(session, "identity") as CredentialIdentity | undefined, + ); +} + +function isAccountAwareCredentialManager( + manager: CredentialManager, +): manager is AccountAwareCredentialManager { + return typeof Reflect.get(manager, "enrichSessions") === "function"; +} + +function asAccountSession(session: Session): AccountSession { + return { ...session, identity: sessionIdentity(session) }; +} + export interface FileCredentialManagerOptions { readonly env: Readonly>; readonly fetchWorkspaceName?: FetchWorkspaceName; @@ -171,12 +217,12 @@ export class FileCredentialManager implements CredentialManager { return storedCredential(record); } - async sessions(): Promise { + async sessions(): Promise { const state = await readCredentialState(this.#filePath); return storedSessions(state); } - async enrichSessions(): Promise { + async enrichSessions(): Promise { if (this.#fetchSessionIdentity === undefined) return this.sessions(); const state = await readCredentialState(this.#filePath); const candidates = state.sessions.filter( @@ -229,7 +275,7 @@ export class FileCredentialManager implements CredentialManager { async createSession( credential: Credential, workspaceId: string, - ): Promise { + ): Promise { const environmentInForce = this.#environmentToken() !== undefined; const claimed = credentialWorkspaceId(credential.token); if (claimed !== undefined && claimed !== workspaceId) { @@ -296,7 +342,7 @@ export class FileCredentialManager implements CredentialManager { }); } - async selectSession(workspaceId: string): Promise { + async selectSession(workspaceId: string): Promise { const environmentInForce = this.#environmentToken() !== undefined; const selected = await this.#mutate((state) => { @@ -685,14 +731,14 @@ function resolvedMarker(state: CredentialState): string | null { return null; } -function storedSessions(state: CredentialState): StoredSessions { +function storedSessions(state: CredentialState): AccountStoredSessions { return { sessions: state.sessions.map((record) => toSession(record)), selectedWorkspaceId: resolvedMarker(state) ?? undefined, }; } -function toSession(record: StoredSession): Session { +function toSession(record: StoredSession): AccountSession { return { workspaceId: record.workspaceId, workspaceName: record.name, diff --git a/packages/cli/src/commands/auth/session-ref.ts b/packages/cli/src/commands/auth/session-ref.ts index ef737e78..7d3ce478 100644 --- a/packages/cli/src/commands/auth/session-ref.ts +++ b/packages/cli/src/commands/auth/session-ref.ts @@ -8,6 +8,7 @@ */ import { noSessionForWorkspaceError, type Session } from "@prisma/cli-engine"; import { CliStructuredError } from "@prisma/cli-engine/protocol"; +import { sessionIdentity } from "../../auth/credential-manager"; import { CLI_NAME } from "../../cli-name"; export type SessionRefResolution = @@ -23,7 +24,7 @@ export interface SessionUser { /** The safe identity fields a command may expose for a stored session. */ export function sessionUser(session: Session): SessionUser | null { - const identity = session.identity; + const identity = sessionIdentity(session); if (identity === undefined) return null; return { id: identity.userId ?? null, @@ -34,7 +35,7 @@ export function sessionUser(session: Session): SessionUser | null { /** The shortest useful human identity for a workspace session. */ export function sessionUserLabel(session: Session): string | undefined { - const identity = session.identity; + const identity = sessionIdentity(session); return identity?.email ?? identity?.name ?? identity?.userId; } diff --git a/packages/cli/src/commands/auth/workspace-list.ts b/packages/cli/src/commands/auth/workspace-list.ts index f1f13b00..30a8c07b 100644 --- a/packages/cli/src/commands/auth/workspace-list.ts +++ b/packages/cli/src/commands/auth/workspace-list.ts @@ -5,6 +5,7 @@ import { type Session, } from "@prisma/cli-engine"; import { type NextAction, ok } from "@prisma/cli-engine/protocol"; +import { sessionsForDisplay } from "../../auth/credential-manager"; import { environmentCredentialInForce } from "../../auth/service-token"; import { CLI_NAME } from "../../cli-name"; import { ENVIRONMENT_CREDENTIAL_NOTICE } from "./credential-card"; @@ -96,7 +97,7 @@ export const authWorkspaceListCommand = defineCommand({ examples: ["auth workspace list", "auth workspace list --json"], }, handler: async (_args, ctx) => { - const stored = await ctx.credentialManager.enrichSessions(); + const stored = await sessionsForDisplay(ctx.credentialManager); const result: WorkspaceListResult = { sessions: stored.sessions, selectedWorkspaceId: stored.selectedWorkspaceId, diff --git a/packages/cli/src/commands/auth/workspace-logout.ts b/packages/cli/src/commands/auth/workspace-logout.ts index 495303fa..153d90c6 100644 --- a/packages/cli/src/commands/auth/workspace-logout.ts +++ b/packages/cli/src/commands/auth/workspace-logout.ts @@ -5,6 +5,7 @@ import { positional, } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; +import { sessionsForDisplay } from "../../auth/credential-manager"; import { environmentCredentialInForce } from "../../auth/service-token"; import { CLI_NAME } from "../../cli-name"; import { ENVIRONMENT_CREDENTIAL_NOTICE } from "./credential-card"; @@ -96,7 +97,7 @@ export const authWorkspaceLogoutCommand = defineCommand({ examples: ["auth workspace logout my-workspace"], }, handler: async (args, ctx) => { - const stored = await ctx.credentialManager.enrichSessions(); + const stored = await sessionsForDisplay(ctx.credentialManager); const session = requireSession(stored.sessions, args.positionals.workspace); const wasSelected = session.workspaceId === stored.selectedWorkspaceId; await ctx.credentialManager.endSession(session.workspaceId); diff --git a/packages/cli/src/commands/auth/workspace-use.ts b/packages/cli/src/commands/auth/workspace-use.ts index d42ce4ac..4b0a0780 100644 --- a/packages/cli/src/commands/auth/workspace-use.ts +++ b/packages/cli/src/commands/auth/workspace-use.ts @@ -8,6 +8,7 @@ import { type StoredSessions, } from "@prisma/cli-engine"; import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; +import { sessionsForDisplay } from "../../auth/credential-manager"; import { environmentCredentialInForce } from "../../auth/service-token"; import { CLI_NAME } from "../../cli-name"; import { ENVIRONMENT_CREDENTIAL_NOTICE } from "./credential-card"; @@ -113,7 +114,7 @@ export const authWorkspaceUseCommand = defineCommand({ examples: ["auth workspace use", "auth workspace use my-workspace"], }, handler: async (args, ctx) => { - const stored = await ctx.credentialManager.enrichSessions(); + const stored = await sessionsForDisplay(ctx.credentialManager); if (stored.sessions.length === 0) { throw noWorkspaceSessionsError(); } diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index 45a22250..52184979 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -11,7 +11,6 @@ import { type Credential, defineCommand, type ManagementApiClient, - type Session, } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; import { @@ -20,7 +19,7 @@ import { type SessionRecord, } from "@prisma/cli-engine/testing"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - +import type { AccountSession } from "../src/auth/credential-manager"; import { performLogin, storeLegacyCredential } from "../src/auth/operations"; import { authLoginCommand } from "../src/commands/auth/login"; import { authLogoutCommand } from "../src/commands/auth/logout"; @@ -28,6 +27,7 @@ import { authWhoamiCommand } from "../src/commands/auth/whoami"; import { authWorkspaceListCommand } from "../src/commands/auth/workspace-list"; import { authWorkspaceLogoutCommand } from "../src/commands/auth/workspace-logout"; import { authWorkspaceUseCommand } from "../src/commands/auth/workspace-use"; +import { attachAccountMetadata } from "./helpers/account-aware-credential-manager"; vi.mock("../src/auth/operations", async (importOriginal) => ({ ...(await importOriginal()), @@ -113,10 +113,11 @@ function makeCli(spec?: { readonly client?: ManagementApiClient; readonly openUrl?: (url: string) => void; }) { - return createTestCli({ + const sessions = spec?.sessions ?? []; + const cli = createTestCli({ commands: COMMANDS, groups: GROUPS, - sessions: spec?.sessions ?? [], + sessions, selectedWorkspaceId: spec?.selectedWorkspaceId, environmentCredential: spec?.environmentToken === undefined @@ -126,6 +127,10 @@ function makeCli(spec?: { openUrl: spec?.openUrl, now: () => new Date(0), }); + if (cli.credentialManager !== undefined) { + attachAccountMetadata(cli.credentialManager, sessions); + } + return cli; } type ResultFrame = { @@ -1023,7 +1028,7 @@ describe("the shapes the commands hand back", () => { }); it("exposes no token on the shapes the commands see", () => { - const session: Session = { + const session: AccountSession = { workspaceId: "ws_1", workspaceName: "Acme Inc", identity: { diff --git a/packages/cli/tests/golden-rendering.test.ts b/packages/cli/tests/golden-rendering.test.ts index 41242ddf..a416e562 100644 --- a/packages/cli/tests/golden-rendering.test.ts +++ b/packages/cli/tests/golden-rendering.test.ts @@ -24,6 +24,7 @@ import { authLogoutCommand } from "../src/commands/auth/logout"; import { authWorkspaceListCommand } from "../src/commands/auth/workspace-list"; import { authWorkspaceLogoutCommand } from "../src/commands/auth/workspace-logout"; import { bucketKeyCreateCommand } from "../src/commands/bucket/key-create"; +import { attachAccountMetadata } from "./helpers/account-aware-credential-manager"; function record(workspaceId: string, workspaceName: string): SessionRecord { return { @@ -46,7 +47,7 @@ function makeCli( current?: string, client?: ManagementApiClient, ) { - return createTestCli({ + const cli = createTestCli({ commands: { "auth logout": authLogoutCommand, "auth workspace list": authWorkspaceListCommand, @@ -64,6 +65,10 @@ function makeCli( ...(client === undefined ? {} : { managementApi: { client } }), now: () => new Date(0), }); + if (cli.credentialManager !== undefined) { + attachAccountMetadata(cli.credentialManager, sessions); + } + return cli; } const CREATED_KEY = { diff --git a/packages/cli/tests/helpers/account-aware-credential-manager.ts b/packages/cli/tests/helpers/account-aware-credential-manager.ts new file mode 100644 index 00000000..a5e291c6 --- /dev/null +++ b/packages/cli/tests/helpers/account-aware-credential-manager.ts @@ -0,0 +1,59 @@ +import { + type CredentialManager, + claimedIdentity, + type Session, +} from "@prisma/cli-engine"; +import type { SessionRecord } from "@prisma/cli-engine/testing"; + +import type { AccountStoredSessions } from "../../src/auth/credential-manager"; + +/** The engine test manager deliberately models only the shared session + * contract. CLI auth tests add this package-local display capability to match + * FileCredentialManager without expanding the published engine API. */ +export function attachAccountMetadata( + manager: CredentialManager, + records: readonly SessionRecord[], +): void { + const identities = new Map( + records.map((record) => [ + record.workspaceId, + claimedIdentity(record.credential.token), + ]), + ); + const sessions = manager.sessions.bind(manager); + const createSession = manager.createSession.bind(manager); + const selectSession = manager.selectSession.bind(manager); + + Object.assign(manager, { + enrichSessions: async (): Promise => { + const stored = await sessions(); + return { + sessions: stored.sessions.map((session) => + withIdentity(session, identities.get(session.workspaceId)), + ), + selectedWorkspaceId: stored.selectedWorkspaceId, + }; + }, + createSession: async ( + ...args: Parameters + ) => { + const session = await createSession(...args); + const identity = claimedIdentity(args[0].token); + identities.set(args[1], identity); + return withIdentity(session, identity); + }, + selectSession: async ( + ...args: Parameters + ) => { + const session = await selectSession(...args); + return withIdentity(session, identities.get(session.workspaceId)); + }, + }); +} + +function withIdentity( + session: Session, + identity: ReturnType, +) { + return { ...session, identity }; +}