diff --git a/src/store/in-memory.ts b/src/store/in-memory.ts index cbe7b29..12e04e4 100644 --- a/src/store/in-memory.ts +++ b/src/store/in-memory.ts @@ -15,7 +15,6 @@ import { type StoredAuthCompletion, type StoredCapabilityGrant, type StoredLinkedIdentity, - type RegisterAppInput, type StoredRegisteredApp, type StoredSession, type StoredPrivateCommandReceipt, @@ -47,25 +46,18 @@ export class InMemoryStore implements HeimdallStore { private readonly authAttempts = new Map(); private readonly privateCommandReceipts = new Map(); private readonly authCompletions = new Map(); + private readonly authCompletionsByAttempt = new Map(); private readonly entitlementSnapshots = new Map(); private readonly auditEvents = new Map(); - async registerApp(input: RegisterAppInput): Promise { - const existing = this.registeredApps.get(input.slug); - const record: StoredRegisteredApp = { - slug: input.slug, - displayName: input.displayName, - profileVersion: input.profileVersion, - createdAt: existing?.createdAt ?? input.registeredAt, - updatedAt: input.registeredAt, - identityProviders: [...input.identityProviders], - entitlementSources: [...input.entitlementSources], - managedConnectionProviders: [...input.managedConnectionProviders], - capabilities: structuredClone(input.capabilities), - redirectUris: [...input.redirectUris], - }; - this.registeredApps.set(record.slug, record); - return structuredClone(record); + /** + * Test/ops seam only — not part of HeimdallStore. There is no production + * writer for `registered_apps` anymore; a real deployment provisions a row + * directly against Postgres. This lets fixtures exercise resolveAppProfile's + * non-built-in branch the same way. + */ + seedRegisteredApp(record: StoredRegisteredApp): void { + this.registeredApps.set(record.slug, structuredClone(record)); } async findRegisteredApp(slug: string): Promise { @@ -359,8 +351,11 @@ export class InMemoryStore implements HeimdallStore { } async createAuthCompletion(input: CreateAuthCompletionInput): Promise { + // The code is always minted here; nothing upstream may choose it (see + // src/app.ts createAuthCompletion call site for why that mattered). const completion: StoredAuthCompletion = { - code: input.code ?? randomUUID(), + code: randomUUID(), + ...(input.attemptId ? { attemptId: input.attemptId } : {}), appSlug: input.appSlug, provider: input.provider, mode: input.mode, @@ -373,6 +368,9 @@ export class InMemoryStore implements HeimdallStore { }; this.authCompletions.set(completion.code, clone(completion)); + if (input.attemptId) { + this.authCompletionsByAttempt.set(`${input.appSlug}:${input.attemptId}`, completion.code); + } return clone(completion); } @@ -390,6 +388,14 @@ export class InMemoryStore implements HeimdallStore { return clone(completion); } + async consumeAuthCompletionByAttempt(appSlug: AppSlug, attemptId: string, at: string): Promise { + const code = this.authCompletionsByAttempt.get(`${appSlug}:${attemptId}`); + if (!code) { + return null; + } + return this.consumeAuthCompletion(appSlug, code, at); + } + async upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise { const key = `${input.accountId}:${input.provider}:${input.scope}`; this.entitlementSnapshots.set(key, clone(input)); diff --git a/src/store/postgres.ts b/src/store/postgres.ts index 7a5b7de..0de5964 100644 --- a/src/store/postgres.ts +++ b/src/store/postgres.ts @@ -18,7 +18,6 @@ import { type StoredAuthCompletion, type StoredCapabilityGrant, type StoredLinkedIdentity, - type RegisterAppInput, type StoredRegisteredApp, type StoredSession, type StoredPrivateCommandReceipt, @@ -90,6 +89,7 @@ interface SessionRow extends QueryResultRow { interface AuthCompletionRow extends QueryResultRow { code: string; + attempt_id: string | null; app_slug: AppSlug; provider: Provider; mode: "sign_in" | "link" | "connect"; @@ -258,6 +258,10 @@ function mapAuthCompletionRow(row: AuthCompletionRow): StoredAuthCompletion { payloadJson: row.payload_json, }; + if (row.attempt_id) { + completion.attemptId = row.attempt_id; + } + if (row.consumed_at) { completion.consumedAt = normalizeTimestamp(row.consumed_at); } @@ -298,44 +302,11 @@ function mapAuthAttemptRow(row: AuthAttemptRow): StoredAuthAttempt { export class PostgresStore implements HeimdallStore { constructor(private readonly pool: Pick) {} - async registerApp(input: RegisterAppInput): Promise { - // Re-registering an app keeps its original created_at: the record is the - // same app being updated, not a new one, and RFC 7592 treats management as - // an update to an existing registration. - const result = await this.pool.query( - ` - INSERT INTO registered_apps ( - slug, display_name, profile_version, created_at, updated_at, - identity_providers, entitlement_sources, managed_connection_providers, - capabilities_json, redirect_uris - ) - VALUES ($1, $2, $3, $4, $4, $5, $6, $7, $8, $9) - ON CONFLICT (slug) - DO UPDATE SET - display_name = EXCLUDED.display_name, - profile_version = EXCLUDED.profile_version, - updated_at = EXCLUDED.updated_at, - identity_providers = EXCLUDED.identity_providers, - entitlement_sources = EXCLUDED.entitlement_sources, - managed_connection_providers = EXCLUDED.managed_connection_providers, - capabilities_json = EXCLUDED.capabilities_json, - redirect_uris = EXCLUDED.redirect_uris - RETURNING * - `, - [ - input.slug, - input.displayName, - input.profileVersion, - input.registeredAt, - JSON.stringify(input.identityProviders), - JSON.stringify(input.entitlementSources), - JSON.stringify(input.managedConnectionProviders), - JSON.stringify(input.capabilities), - JSON.stringify(input.redirectUris), - ], - ); - return mapRegisteredApp(result.rows[0]!); - } + // There is no production writer for registered_apps anymore (the + // caller-identity cut deleted the runtime registration surface that minted + // a client_secret nobody verified). A row can still be provisioned by + // direct SQL against this table; findRegisteredApp/listRegisteredApps below + // keep reading it as a profile source beneath the built-in profiles. async findRegisteredApp(slug: string): Promise { const result = await this.pool.query( @@ -742,18 +713,20 @@ export class PostgresStore implements HeimdallStore { } async createAuthCompletion(input: CreateAuthCompletionInput): Promise { - const code = input.code ?? randomUUID(); + // The code is always minted here; nothing upstream may choose it. + const code = randomUUID(); const result = await this.pool.query( ` INSERT INTO auth_completions ( - code, app_slug, provider, mode, account_id, session_id, + code, attempt_id, app_slug, provider, mode, account_id, session_id, return_to, payload_json, created_at, expires_at ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING * `, [ code, + input.attemptId ?? null, input.appSlug, input.provider, input.mode, @@ -786,6 +759,23 @@ export class PostgresStore implements HeimdallStore { return result.rowCount ? mapAuthCompletionRow(expectRow(result.rows[0], "consumeAuthCompletion")) : null; } + async consumeAuthCompletionByAttempt(appSlug: AppSlug, attemptId: string, at: string): Promise { + const result = await this.pool.query( + ` + UPDATE auth_completions + SET consumed_at = $3 + WHERE attempt_id = $1 + AND app_slug = $2 + AND consumed_at IS NULL + AND expires_at > $3 + RETURNING * + `, + [attemptId, appSlug, at] + ); + + return result.rowCount ? mapAuthCompletionRow(expectRow(result.rows[0], "consumeAuthCompletionByAttempt")) : null; + } + async upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise { await this.pool.query( ` diff --git a/src/store/schema.ts b/src/store/schema.ts index 1a00c2d..7656100 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -109,6 +109,16 @@ CREATE TABLE IF NOT EXISTS auth_completions ( CREATE INDEX IF NOT EXISTS auth_completions_lookup_idx ON auth_completions(app_slug, expires_at, consumed_at); +-- Correlation handle for the browser_completion handoff. Before the +-- caller-identity cut, the completion code itself was the caller-chosen +-- attemptId (an unauthenticated account-takeover primitive); the code is now +-- always minted by the store, and attempt_id lets the private command plane +-- redeem the completion it created without ever needing the code. +ALTER TABLE auth_completions ADD COLUMN IF NOT EXISTS attempt_id TEXT; + +CREATE INDEX IF NOT EXISTS auth_completions_attempt_idx + ON auth_completions(app_slug, attempt_id); + CREATE TABLE IF NOT EXISTS capability_grants ( id TEXT PRIMARY KEY, account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, diff --git a/src/store/types.ts b/src/store/types.ts index 788828a..f61d4d6 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -28,18 +28,6 @@ export interface StoredRegisteredApp { redirectUris: string[]; } -export interface RegisterAppInput { - slug: string; - displayName: string; - profileVersion: string; - registeredAt: string; - identityProviders: Provider[]; - entitlementSources: Provider[]; - managedConnectionProviders: Provider[]; - capabilities: CapabilityDefinition[]; - redirectUris: string[]; -} - export interface StoredAccount { id: string; createdAt: string; @@ -163,6 +151,13 @@ export interface CreateAuditEventInput { export interface StoredAuthCompletion { code: string; + /** + * The browser-carried attempt handle this completion correlates to, if the + * handoff supplied one. Correlation only: it is never the redemption + * secret, and never used to look up a completion by anyone but the + * envelope-authenticated private command plane. + */ + attemptId?: string; appSlug: AppSlug; provider: Provider; mode: OAuthMode; @@ -176,7 +171,11 @@ export interface StoredAuthCompletion { } export interface CreateAuthCompletionInput { - code?: string; + /** + * Correlation handle only (see StoredAuthCompletion.attemptId). The store + * always mints `code` itself; a caller cannot choose it. + */ + attemptId?: string; appSlug: AppSlug; provider: Provider; mode: OAuthMode; @@ -226,7 +225,14 @@ export interface StoredPrivateCommandReceipt { export type CreatePrivateCommandReceiptInput = StoredPrivateCommandReceipt; export interface HeimdallStore { - registerApp(input: RegisterAppInput): Promise; + /** + * There is no runtime writer for this table anymore (the caller-identity + * cut deleted the registration surface that minted a client_secret nobody + * verified). A profile row can still be provisioned directly against the + * store outside the app, which is how the InMemoryStore test double and any + * future ops-side seeding populate it; `resolveAppProfile` keeps consulting + * it below the built-in profiles. + */ findRegisteredApp(slug: string): Promise; listRegisteredApps(): Promise; ensureSchema(): Promise; @@ -262,6 +268,13 @@ export interface HeimdallStore { findPrivateCommandReceipt(appSlug: AppSlug, idempotencyKey: string): Promise; createAuthCompletion(input: CreateAuthCompletionInput): Promise; consumeAuthCompletion(appSlug: AppSlug, code: string, at: string): Promise; + /** + * Consume a completion by its correlated attempt handle instead of its + * code. The private command plane already authenticated `appSlug` by + * opening the envelope; this lets its `completeAuth` redeem the completion + * it created without ever needing to know (or forge) the code. + */ + consumeAuthCompletionByAttempt(appSlug: AppSlug, attemptId: string, at: string): Promise; upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise; createAuditEvent(input: CreateAuditEventInput): Promise; }