Store: mint auth-completion codes server-side; drop the registration writer
The completion code's `code?` input let a caller choose it; app.ts used that to make handoff.attemptId become the code verbatim, which is the account-takeover primitive Soul's probes proved. The store now always mints the code itself. attemptId survives only as a separate correlation field (StoredAuthCompletion.attemptId / CreateAuthCompletionInput.attemptId), and a new consumeAuthCompletionByAttempt(appSlug, attemptId, at) lets a caller that already knows the attempt handle (the private command plane) redeem the completion it created without ever touching the code. Also deletes HeimdallStore.registerApp: the runtime app-registration HTTP surface that minted it is already gone from this branch, and nothing else in the codebase writes to registered_apps (it sits empty on the live host). findRegisteredApp/listRegisteredApps stay: AppSlug is a plain string (contracts.ts), so registered_apps remains the real extension point for a slug Heimdall was never rebuilt to know about; a row is now provisioned by direct SQL, or InMemoryStore.seedRegisteredApp in tests, not by any app-facing writer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
4e8ad3d348
commit
5a4da2ae30
4 changed files with 93 additions and 74 deletions
|
|
@ -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<string, StoredAuthAttempt>();
|
||||
private readonly privateCommandReceipts = new Map<string, StoredPrivateCommandReceipt>();
|
||||
private readonly authCompletions = new Map<string, StoredAuthCompletion>();
|
||||
private readonly authCompletionsByAttempt = new Map<string, string>();
|
||||
private readonly entitlementSnapshots = new Map<string, CreateEntitlementSnapshotInput>();
|
||||
private readonly auditEvents = new Map<string, CreateAuditEventInput>();
|
||||
|
||||
async registerApp(input: RegisterAppInput): Promise<StoredRegisteredApp> {
|
||||
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<StoredRegisteredApp | null> {
|
||||
|
|
@ -359,8 +351,11 @@ export class InMemoryStore implements HeimdallStore {
|
|||
}
|
||||
|
||||
async createAuthCompletion(input: CreateAuthCompletionInput): Promise<StoredAuthCompletion> {
|
||||
// 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<StoredAuthCompletion | null> {
|
||||
const code = this.authCompletionsByAttempt.get(`${appSlug}:${attemptId}`);
|
||||
if (!code) {
|
||||
return null;
|
||||
}
|
||||
return this.consumeAuthCompletion(appSlug, code, at);
|
||||
}
|
||||
|
||||
async upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise<void> {
|
||||
const key = `${input.accountId}:${input.provider}:${input.scope}`;
|
||||
this.entitlementSnapshots.set(key, clone(input));
|
||||
|
|
|
|||
|
|
@ -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<Pool, "query" | "end">) {}
|
||||
|
||||
async registerApp(input: RegisterAppInput): Promise<StoredRegisteredApp> {
|
||||
// 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<RegisteredAppRow>(
|
||||
`
|
||||
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<StoredRegisteredApp | null> {
|
||||
const result = await this.pool.query<RegisteredAppRow>(
|
||||
|
|
@ -742,18 +713,20 @@ export class PostgresStore implements HeimdallStore {
|
|||
}
|
||||
|
||||
async createAuthCompletion(input: CreateAuthCompletionInput): Promise<StoredAuthCompletion> {
|
||||
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<AuthCompletionRow>(
|
||||
`
|
||||
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<StoredAuthCompletion | null> {
|
||||
const result = await this.pool.query<AuthCompletionRow>(
|
||||
`
|
||||
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<void> {
|
||||
await this.pool.query(
|
||||
`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<StoredRegisteredApp>;
|
||||
/**
|
||||
* 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<StoredRegisteredApp | null>;
|
||||
listRegisteredApps(): Promise<StoredRegisteredApp[]>;
|
||||
ensureSchema(): Promise<void>;
|
||||
|
|
@ -262,6 +268,13 @@ export interface HeimdallStore {
|
|||
findPrivateCommandReceipt(appSlug: AppSlug, idempotencyKey: string): Promise<StoredPrivateCommandReceipt | null>;
|
||||
createAuthCompletion(input: CreateAuthCompletionInput): Promise<StoredAuthCompletion>;
|
||||
consumeAuthCompletion(appSlug: AppSlug, code: string, at: string): Promise<StoredAuthCompletion | null>;
|
||||
/**
|
||||
* 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<StoredAuthCompletion | null>;
|
||||
upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise<void>;
|
||||
createAuditEvent(input: CreateAuditEventInput): Promise<void>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue