diff --git a/docs/service-contract.md b/docs/service-contract.md index c28bdeb..b78bd4c 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -203,6 +203,17 @@ door into a person who already has one. nothing. A `sub` linked to another account is refused and never moved. Both audit as `oauth_callback_failed` with error class `AccountLinkRefused` and the code `identity_not_linked` or `identity_linked_elsewhere`. +- Link is two-phase. The callback verifies the identity and records a pending + link (TTL `completionTtlSeconds`), answering `status: "link_pending"` with a + `pendingLinkId` and no session; it writes no linked identity. The app calls + `POST /v1/apps/:appSlug/links/confirm` with `{ pendingLinkId }` and the + Heimdall access token of the account that began the link; only that call + attaches the identity. A different account, an expired, used or unknown id + answers 410; a `sub` another account holds by then answers 409. A refused + confirm does not consume the link. +- A sign-in callback evaluates every linked identity of the account with the + start-time entitlement policy, so a member returning through Google keeps + what Discord or Patreon grant. - Configure with `GC_ACCESS_PROVIDER_GOOGLE_CLIENT_ID` and `GC_ACCESS_PROVIDER_GOOGLE_CLIENT_SECRET_FILE`. Admitted to the `bifrost` app profile only. diff --git a/src/app.ts b/src/app.ts index 1ddfc76..31da8d8 100644 --- a/src/app.ts +++ b/src/app.ts @@ -415,6 +415,8 @@ async function evaluateStoredEntitlements(options: { appSlug: AppSlug; accountId: string; entitlementPolicies: OAuthEntitlementPolicy[]; + /** A provider the caller has already evaluated with fresh tokens. */ + skipProvider?: Provider; }): Promise { const profile = getAppProfile(options.appSlug); if (!profile) { @@ -424,7 +426,7 @@ async function evaluateStoredEntitlements(options: { const facts: string[] = []; for (const storedIdentity of linkedIdentities) { - if (!profile.entitlementSources.includes(storedIdentity.provider)) { + if (!profile.entitlementSources.includes(storedIdentity.provider) || storedIdentity.provider === options.skipProvider) { continue; } @@ -1160,6 +1162,99 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { + const linkedIdentityInput: UpsertLinkedIdentityInput = { + accountId, + provider: identity.provider, + providerUserId: identity.providerUserId, + scopes: tokenSet.scope, + profileJson: identity.profile, + createdAt: nowIso, + updatedAt: nowIso, + }; + if (identity.username !== undefined) { + linkedIdentityInput.username = identity.username; + } + if (identity.displayName !== undefined) { + linkedIdentityInput.displayName = identity.displayName; + } + if (identity.primaryEmail !== undefined) { + linkedIdentityInput.primaryEmail = identity.primaryEmail; + } + linkedIdentityInput.accessTokenEncrypted = tokenCustody.encrypt(tokenSet.accessToken); + if (tokenSet.refreshToken !== undefined) { + linkedIdentityInput.refreshTokenEncrypted = tokenCustody.encrypt(tokenSet.refreshToken); + } + if (tokenSet.expiresAt !== undefined) { + linkedIdentityInput.tokenExpiresAt = tokenSet.expiresAt; + } + return linkedIdentityInput; + }; + + // A link is two-phase. The callback cannot tie itself to the browser + // that began the link (start is a server-to-server POST), so whoever + // completes the Google login here may be someone the initiator sent + // the URL to. Nothing is written to the account: the verified identity + // waits as a pending link, and only a confirm bearing the access token + // of the account that began the link attaches it. + if (boundAccountId) { + const pendingLink = await store.createPendingLink({ + appSlug: statePayload.app_slug, + accountId: boundAccountId, + provider: identity.provider, + providerUserId: identity.providerUserId, + identity: buildLinkedIdentityInput(boundAccountId), + returnTo: statePayload.return_to, + createdAt: nowIso, + expiresAt: new Date(Date.now() + config.completionTtlSeconds * 1000).toISOString(), + }); + await store.createAuditEvent({ + accountId: boundAccountId, + appSlug: statePayload.app_slug, + eventType: "link_pending", + // Never the pending link's id: it is the confirm secret. + eventPayloadJson: { provider: identity.provider, expiresAt: pendingLink.expiresAt }, + createdAt: nowIso, + }); + const pendingBody = { + status: "link_pending" as const, + provider: request.params.provider, + mode: statePayload.mode, + appSlug: statePayload.app_slug, + returnTo: statePayload.return_to, + pendingLinkId: pendingLink.id, + expiresAt: pendingLink.expiresAt, + confirmEndpoint: `${config.publicBaseUrl}/v1/apps/${statePayload.app_slug}/links/confirm`, + }; + if (handoff.kind === "backend_callback") { + await maybeDeliverBackendHandoff(handoff, { + source: "heimdall", + kind: "oauth_result", + handoffKind: "backend_callback", + attemptId: handoff.attemptId, + connection: statePayload.connection, + ...pendingBody, + }); + } + if (prefersHtml(request.headers.accept)) { + reply.type("text/html; charset=utf-8"); + return reply.send( + renderBrowserHandoffPage({ + handoffKind: handoff.kind, + status: "success", + provider: request.params.provider, + appSlug: statePayload.app_slug, + mode: statePayload.mode, + returnTo: statePayload.return_to, + pendingLinkId: pendingLink.id, + ...(handoff.attemptId ? { attemptId: handoff.attemptId } : {}), + }) + ); + } + reply.code(201); + return pendingBody; + } + if (!account) { const createAccountInput: CreateAccountInput = { createdAt: nowIso, @@ -1185,32 +1280,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise( + "/v1/apps/:appSlug/links/confirm", + { + schema: { + params: { + type: "object", + required: ["appSlug"], + additionalProperties: false, + properties: { appSlug: { type: "string", enum: [...appSlugs] } }, + }, + body: { + type: "object", + required: ["pendingLinkId"], + additionalProperties: false, + properties: { pendingLinkId: { type: "string", minLength: 1 } }, + }, + }, + }, + async (request, reply) => { + const accountId = await resolveLinkAccount({ config, keys, store }, request.params.appSlug, request.headers.authorization); + if (!accountId) { + reply.code(401); + return { error: "invalid_link_session", detail: "The bearer is not a live Heimdall access token for this app." }; + } + const nowIso = new Date().toISOString(); + const pending = await store.consumePendingLink(request.params.appSlug, request.body.pendingLinkId, accountId, nowIso); + if (!pending) { + reply.code(410); + return { error: "invalid_or_expired_pending_link" }; + } + const holder = await store.findAccountByLinkedIdentity(pending.provider, pending.providerUserId); + if (holder && holder.id !== accountId) { + reply.code(409); + return { error: "identity_linked_elsewhere" }; + } + await store.upsertLinkedIdentity({ ...pending.identity, accountId, updatedAt: nowIso }); + await store.createAuditEvent({ + accountId, + appSlug: pending.appSlug, + eventType: "link_confirmed", + eventPayloadJson: { provider: pending.provider }, + createdAt: nowIso, + }); + reply.code(201); + return { status: "linked", provider: pending.provider, accountId, returnTo: pending.returnTo }; + } + ); + app.post<{ Params: { appSlug: AppSlug }; Body: RedeemAuthCompletionRequest }>( "/v1/apps/:appSlug/auth-completions/redeem", { diff --git a/src/backend-handoff.ts b/src/backend-handoff.ts index 204c570..d49a125 100644 --- a/src/backend-handoff.ts +++ b/src/backend-handoff.ts @@ -39,7 +39,16 @@ export interface BackendHandoffErrorPayload extends BackendHandoffBase { errorDescription?: string; } -export type BackendHandoffPayload = BackendHandoffSuccessPayload | BackendHandoffErrorPayload; +/** A verified link that waits for the initiating account's confirm: no session, no tokens. */ +export interface BackendHandoffLinkPendingPayload extends BackendHandoffBase { + status: "link_pending"; + mode: OAuthMode; + pendingLinkId: string; + expiresAt: string; + confirmEndpoint: string; +} + +export type BackendHandoffPayload = BackendHandoffSuccessPayload | BackendHandoffErrorPayload | BackendHandoffLinkPendingPayload; export async function deliverBackendHandoff(callbackUrl: string, payload: BackendHandoffPayload): Promise { const response = await fetch(callbackUrl, { diff --git a/src/browser-handoff.ts b/src/browser-handoff.ts index e23601e..c21c01e 100644 --- a/src/browser-handoff.ts +++ b/src/browser-handoff.ts @@ -12,6 +12,8 @@ export interface BrowserHandoffSuccess extends BrowserHandoffBase { status: "success"; mode: OAuthMode; completionCode?: string; + /** A verified link waiting for its initiator's confirm; carries no session. */ + pendingLinkId?: string; attemptId?: string; } @@ -47,6 +49,8 @@ function buildBrowserFallbackUrl(payload: BrowserHandoffPayload): string { if (payload.status === "success" && payload.completionCode) { fragment.set("heimdall_completion_code", payload.completionCode); + } else if (payload.status === "success" && payload.pendingLinkId) { + fragment.set("heimdall_pending_link_id", payload.pendingLinkId); } else { if (payload.status === "error") { fragment.set("heimdall_error", payload.error); diff --git a/src/store/in-memory.ts b/src/store/in-memory.ts index 6a53dad..c37dd7b 100644 --- a/src/store/in-memory.ts +++ b/src/store/in-memory.ts @@ -4,6 +4,8 @@ import { type CreateAccountInput, type CreateAuthAttemptInput, type CreateAuthCompletionInput, + type CreatePendingLinkInput, + type StoredPendingLink, type CreateAuditEventInput, type CreateCapabilityGrantInput, type CredentialDecision, @@ -47,6 +49,7 @@ export class InMemoryStore implements HeimdallStore { private readonly authAttempts = new Map(); private readonly privateCommandReceipts = new Map(); private readonly authCompletions = new Map(); + private readonly pendingLinks = new Map(); private readonly authCompletionsByAttempt = new Map(); private readonly entitlementSnapshots = new Map(); private readonly auditEvents = new Map(); @@ -424,6 +427,21 @@ export class InMemoryStore implements HeimdallStore { return this.consumeAuthCompletion(appSlug, code, at); } + async createPendingLink(input: CreatePendingLinkInput): Promise { + const link: StoredPendingLink = { id: randomUUID(), ...clone(input) }; + this.pendingLinks.set(link.id, clone(link)); + return clone(link); + } + + async consumePendingLink(appSlug: AppSlug, id: string, accountId: string, at: string): Promise { + const link = this.pendingLinks.get(id); + if (!link || link.appSlug !== appSlug || link.accountId !== accountId || link.expiresAt <= at || link.consumedAt) { + return null; + } + link.consumedAt = at; + return clone(link); + } + 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 487f375..a6013d1 100644 --- a/src/store/postgres.ts +++ b/src/store/postgres.ts @@ -6,6 +6,8 @@ import { type CreateAccountInput, type CreateAuthAttemptInput, type CreateAuthCompletionInput, + type CreatePendingLinkInput, + type StoredPendingLink, type CreateAuditEventInput, type CreateCapabilityGrantInput, type CredentialDecision, @@ -88,6 +90,19 @@ interface AuthCompletionRow extends QueryResultRow { consumed_at: string | Date | null; } +interface PendingLinkRow extends QueryResultRow { + id: string; + app_slug: AppSlug; + account_id: string; + provider: Provider; + provider_user_id: string; + identity_json: StoredPendingLink["identity"]; + return_to: string; + created_at: string | Date; + expires_at: string | Date; + consumed_at: string | Date | null; +} + interface AuthAttemptRow extends QueryResultRow { handle: string; app_slug: AppSlug; @@ -231,6 +246,24 @@ function mapSessionRow(row: SessionRow): StoredSession { }; } +function mapPendingLinkRow(row: PendingLinkRow): StoredPendingLink { + const link: StoredPendingLink = { + id: row.id, + appSlug: row.app_slug, + accountId: row.account_id, + provider: row.provider, + providerUserId: row.provider_user_id, + identity: row.identity_json, + returnTo: row.return_to, + createdAt: normalizeTimestamp(row.created_at), + expiresAt: normalizeTimestamp(row.expires_at), + }; + if (row.consumed_at) { + link.consumedAt = normalizeTimestamp(row.consumed_at); + } + return link; +} + function mapAuthCompletionRow(row: AuthCompletionRow): StoredAuthCompletion { const completion: StoredAuthCompletion = { code: row.code, @@ -378,6 +411,7 @@ export const STORE_TABLE_PRIVILEGES: Readonly { + const result = await this.pool.query( + ` + INSERT INTO pending_links ( + id, app_slug, account_id, provider, provider_user_id, identity_json, + return_to, created_at, expires_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING * + `, + [ + randomUUID(), + input.appSlug, + input.accountId, + input.provider, + input.providerUserId, + JSON.stringify(input.identity), + input.returnTo, + input.createdAt, + input.expiresAt, + ] + ); + return mapPendingLinkRow(expectRow(result.rows[0], "createPendingLink")); + } + + async consumePendingLink(appSlug: AppSlug, id: string, accountId: string, at: string): Promise { + const result = await this.pool.query( + ` + UPDATE pending_links + SET consumed_at = $4 + WHERE id = $1 + AND app_slug = $2 + AND account_id = $3 + AND consumed_at IS NULL + AND expires_at > $4 + RETURNING * + `, + [id, appSlug, accountId, at] + ); + return result.rowCount ? mapPendingLinkRow(expectRow(result.rows[0], "consumePendingLink")) : null; + } + async upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise { await this.pool.query( ` diff --git a/src/store/schema.ts b/src/store/schema.ts index 420f5e6..629120a 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -82,6 +82,19 @@ CREATE TABLE IF NOT EXISTS private_command_receipts ( CREATE INDEX IF NOT EXISTS private_command_receipts_expiry_idx ON private_command_receipts(expires_at); +CREATE TABLE IF NOT EXISTS pending_links ( + id TEXT PRIMARY KEY, + app_slug TEXT NOT NULL, + account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + provider_user_id TEXT NOT NULL, + identity_json JSONB NOT NULL, + return_to TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ +); + CREATE TABLE IF NOT EXISTS auth_completions ( code TEXT PRIMARY KEY, app_slug TEXT NOT NULL, diff --git a/src/store/types.ts b/src/store/types.ts index 5307e1f..c88243e 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -182,6 +182,28 @@ export interface CreateAuthCompletionInput { payloadJson: Record; } +/** + * A link the callback has verified but not written. The identity attaches to + * `accountId` (the account whose Heimdall access token began the link) only + * when that same account confirms it; until then no linked-identity row + * exists. `identity` holds the already-encrypted row to write. + */ +export interface StoredPendingLink { + id: string; + appSlug: AppSlug; + accountId: string; + provider: Provider; + providerUserId: string; + identity: UpsertLinkedIdentityInput; + returnTo: string; + createdAt: string; + expiresAt: string; + consumedAt?: string; +} + +/** The store mints `id`; a caller cannot choose it. */ +export type CreatePendingLinkInput = Omit; + export interface StoredAuthAttempt { handle: string; appSlug: AppSlug; @@ -271,6 +293,13 @@ export interface HeimdallStore { * it created without ever needing to know (or forge) the code. */ consumeAuthCompletionByAttempt(appSlug: AppSlug, attemptId: string, at: string): Promise; + createPendingLink(input: CreatePendingLinkInput): Promise; + /** + * Single-use redemption: returns the link only for the app and the account + * it was begun by, before it expires and once. A mismatch returns null and + * leaves the link unconsumed. + */ + consumePendingLink(appSlug: AppSlug, id: string, accountId: string, at: string): Promise; upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise; createAuditEvent(input: CreateAuditEventInput): Promise; } diff --git a/tests/google-link.test.ts b/tests/google-link.test.ts index 379329a..af0d3b5 100644 --- a/tests/google-link.test.ts +++ b/tests/google-link.test.ts @@ -1,5 +1,5 @@ import { createHash, createSign, generateKeyPairSync } from "node:crypto"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { type FastifyInstance } from "fastify"; import { buildApp } from "../src/app.js"; import { loadConfig, type HeimdallConfig } from "../src/config.js"; @@ -24,6 +24,7 @@ function config(): HeimdallConfig { storage: { backend: "memory", applySchemaOnStartup: true }, tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"), bootstrapSigningPrivateKeyOnMissing: true, + appSharedSecrets: { ...base.appSharedSecrets, bifrost: "bifrost-secret" }, providers: { ...base.providers, google: { clientId: CLIENT_ID, clientSecret: "google-secret" }, @@ -40,8 +41,9 @@ function discordRuntime(providerUserId: string, name: string): OAuthProviderRunt async resolveIdentity() { return { provider: "discord", providerUserId, username: name, displayName: name, profile: { id: providerUserId } }; }, - async evaluateEntitlements() { - return { facts: [], snapshots: [] }; + // Discord grants app access only under a policy the app named at start. + async evaluateEntitlements(input) { + return { facts: input.callback.entitlementPolicy ? ["entitlement.app_access"] : [], snapshots: [] }; }, }; } @@ -139,6 +141,35 @@ function callbackGoogle(app: FastifyInstance, stateToken: string) { return app.inject({ method: "GET", url: `/v1/oauth/google/callback?code=g-code&state=${encodeURIComponent(stateToken)}` }); } +const DISCORD_POLICY = { kind: "discord_role_access", guildId: "g", allowedRoleIds: ["member"] } as const; + +/** Begin a link with `token`, let Google answer as `claims` says, and return the callback's answer. */ +async function linkGoogleCallback(app: FastifyInstance, token: string, claims?: Record) { + const start = await startGoogle(app, token); + expect(start.statusCode).toBe(201); + const url = new URL(start.json().authorizationUrl); + fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined, ...(claims ? { claims } : {}) }); + return callbackGoogle(app, start.json().stateToken); +} + +function confirmLink(app: FastifyInstance, token: string | undefined, pendingLinkId: string) { + return app.inject({ + method: "POST", + url: "/v1/apps/bifrost/links/confirm", + headers: token ? { authorization: `Bearer ${token}` } : {}, + payload: { pendingLinkId }, + }); +} + +/** The whole link: callback, then the initiator's confirm. */ +async function linkGoogle(app: FastifyInstance, token: string, claims?: Record) { + const done = await linkGoogleCallback(app, token, claims); + expect(done.statusCode).toBe(201); + const confirmed = await confirmLink(app, token, done.json().pendingLinkId); + expect(confirmed.statusCode).toBe(201); + return confirmed; +} + afterEach(async () => { globalThis.fetch = originalFetch; resetGoogleJwksCache(); @@ -174,8 +205,14 @@ describe("Google as a Heimdall OIDC provider", () => { const done = await callbackGoogle(app, start.json().stateToken); + // Phase one verified the identity and recorded it; it linked nothing and issued no session. expect(done.statusCode).toBe(201); - expect(done.json().account.id).toBe(accountId); + expect(done.json().status).toBe("link_pending"); + expect(done.json().accessToken).toBeUndefined(); + expect(await store.findStoredLinkedIdentity("google", "g-sub-1")).toBeNull(); + const confirmed = await confirmLink(app, accessToken, done.json().pendingLinkId); + expect(confirmed.statusCode).toBe(201); + expect(confirmed.json().accountId).toBe(accountId); expect(accountCount(store)).toBe(1); const sent = leg.tokenRequests[0]!; expect(sent.get("grant_type")).toBe("authorization_code"); @@ -192,10 +229,7 @@ describe("Google as a Heimdall OIDC provider", () => { it("signs in the account Google is linked to, with no session, and mints nothing", async () => { const { app, store } = await build(); const { accessToken, accountId } = await signInWithDiscord(app); - const link = await startGoogle(app, accessToken); - const linkUrl = new URL(link.json().authorizationUrl); - fakeGoogle({ nonceFrom: () => linkUrl.searchParams.get("nonce") ?? undefined }); - expect((await callbackGoogle(app, link.json().stateToken)).statusCode).toBe(201); + await linkGoogle(app, accessToken); const signIn = await startGoogle(app, undefined, "sign_in"); expect(signIn.statusCode).toBe(201); @@ -259,10 +293,7 @@ describe("Google as a Heimdall OIDC provider", () => { const store = new InMemoryStore(); const first = await build(store, "discord-1"); const a = await signInWithDiscord(first.app, "discord-1"); - const startA = await startGoogle(first.app, a.accessToken); - const urlA = new URL(startA.json().authorizationUrl); - fakeGoogle({ nonceFrom: () => urlA.searchParams.get("nonce") ?? undefined }); - expect((await callbackGoogle(first.app, startA.json().stateToken)).statusCode).toBe(201); + await linkGoogle(first.app, a.accessToken); // A second person, a second account, the same Google subject. const second = await build(store, "discord-2"); @@ -299,14 +330,152 @@ describe("Google as a Heimdall OIDC provider", () => { it("keys the identity by sub: the same email under a different sub is a different, unlinkable identity", async () => { const { app, store } = await build(); const { accessToken, accountId } = await signInWithDiscord(app); - const start = await startGoogle(app, accessToken); - const url = new URL(start.json().authorizationUrl); - fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined, claims: { sub: "g-sub-2" } }); - await callbackGoogle(app, start.json().stateToken); + await linkGoogle(app, accessToken, { sub: "g-sub-2" }); expect((await store.findStoredLinkedIdentity("google", "g-sub-2"))?.accountId).toBe(accountId); expect(await store.findStoredLinkedIdentity("google", "person@example.org")).toBeNull(); }); + describe("link is two-phase", () => { + it("a victim completing an attacker's link URL attaches the victim's Google sub to no account", async () => { + const store = new InMemoryStore(); + const attacker = await build(store, "discord-attacker"); + const a = await signInWithDiscord(attacker.app, "discord-attacker"); + const victim = await build(store, "discord-victim"); + const v = await signInWithDiscord(victim.app, "discord-victim"); + + // The attacker begins a link and sends the Google URL to the victim, who finishes it as themselves. + const done = await linkGoogleCallback(attacker.app, a.accessToken, { sub: "victim-google-sub" }); + expect(done.statusCode).toBe(201); + expect(await store.findStoredLinkedIdentity("google", "victim-google-sub")).toBeNull(); + + // The victim's app holds the victim's token: Heimdall refuses it, and the link stays unwritten. + const asVictim = await confirmLink(victim.app, v.accessToken, done.json().pendingLinkId); + expect(asVictim.statusCode).toBe(410); + expect(await store.findStoredLinkedIdentity("google", "victim-google-sub")).toBeNull(); + + // No token at all, and a garbage token, are refused the same way. + expect((await confirmLink(attacker.app, undefined, done.json().pendingLinkId)).statusCode).toBe(401); + expect((await confirmLink(attacker.app, "garbage", done.json().pendingLinkId)).statusCode).toBe(401); + expect(await store.findStoredLinkedIdentity("google", "victim-google-sub")).toBeNull(); + + // The refused attempts did not burn the pending link: its initiator can still confirm it. + expect((await confirmLink(attacker.app, a.accessToken, done.json().pendingLinkId)).statusCode).toBe(201); + }); + + it("signing in with Google cannot reach a Google sub the attacker's pending link recorded", async () => { + const { app } = await build(); + const a = await signInWithDiscord(app); + await linkGoogleCallback(app, a.accessToken, { sub: "victim-google-sub" }); + + const signIn = await startGoogle(app, undefined, "sign_in"); + const url = new URL(signIn.json().authorizationUrl); + fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined, claims: { sub: "victim-google-sub" } }); + expect((await callbackGoogle(app, signIn.json().stateToken)).statusCode).toBe(502); + }); + + it("a pending link is single-use", async () => { + const { app } = await build(); + const a = await signInWithDiscord(app); + const done = await linkGoogleCallback(app, a.accessToken); + expect((await confirmLink(app, a.accessToken, done.json().pendingLinkId)).statusCode).toBe(201); + expect((await confirmLink(app, a.accessToken, done.json().pendingLinkId)).statusCode).toBe(410); + }); + + it("a pending link expires after the completion TTL", async () => { + const { app, store } = await build(); + const a = await signInWithDiscord(app); + const done = await linkGoogleCallback(app, a.accessToken); + const ttlMs = Date.parse(done.json().expiresAt) - Date.now(); + expect(ttlMs).toBeGreaterThan(0); + expect(ttlMs).toBeLessThanOrEqual(300_000); + + vi.useFakeTimers({ toFake: ["Date"], now: Date.now() + 301_000 }); + try { + expect((await confirmLink(app, a.accessToken, done.json().pendingLinkId)).statusCode).toBe(410); + } finally { + vi.useRealTimers(); + } + expect(await store.findStoredLinkedIdentity("google", "g-sub-1")).toBeNull(); + }); + + it("a pending link is bound to its app: another app's session cannot confirm it", async () => { + const { app, store } = await build(); + const a = await signInWithDiscord(app); + const done = await linkGoogleCallback(app, a.accessToken); + const other = await app.inject({ + method: "POST", + url: "/v1/oauth/discord/start", + payload: { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" }, + }); + const cb = await app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(other.json().stateToken)}` }); + const res = await app.inject({ + method: "POST", + url: "/v1/apps/repixelizer/links/confirm", + headers: { authorization: `Bearer ${cb.json().accessToken}` }, + payload: { pendingLinkId: done.json().pendingLinkId }, + }); + expect(res.statusCode).toBe(410); + expect(await store.findStoredLinkedIdentity("google", "g-sub-1")).toBeNull(); + }); + + it("refuses at confirm a sub another account linked in between, and never moves it", async () => { + const store = new InMemoryStore(); + const first = await build(store, "discord-1"); + const a = await signInWithDiscord(first.app, "discord-1"); + const second = await build(store, "discord-2"); + const b = await signInWithDiscord(second.app, "discord-2"); + + const pendingA = await linkGoogleCallback(first.app, a.accessToken); + const pendingB = await linkGoogleCallback(second.app, b.accessToken); + expect((await confirmLink(first.app, a.accessToken, pendingA.json().pendingLinkId)).statusCode).toBe(201); + const lost = await confirmLink(second.app, b.accessToken, pendingB.json().pendingLinkId); + + expect(lost.statusCode).toBe(409); + expect((await store.findStoredLinkedIdentity("google", "g-sub-1"))?.accountId).toBe(a.accountId); + }); + }); + + describe("entitlements at the callback", () => { + it("a Discord-origin member returning through Google keeps entitlement.app_access under the start-time policy", async () => { + const { app } = await build(); + const member = await signInWithDiscord(app); + await linkGoogle(app, member.accessToken); + + const start = await app.inject({ + method: "POST", + url: "/v1/oauth/google/start", + headers: { "x-heimdall-app-secret": "bifrost-secret" }, + payload: { + appSlug: "bifrost", + mode: "sign_in", + returnTo: "https://bifrost.gamecult.org/auth/heimdall/complete", + entitlementPolicy: DISCORD_POLICY, + }, + }); + expect(start.statusCode).toBe(201); + const url = new URL(start.json().authorizationUrl); + fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined }); + const done = await callbackGoogle(app, start.json().stateToken); + + expect(done.statusCode).toBe(201); + expect(done.json().claimSet.facts).toContain("entitlement.app_access"); + }); + + it("without a start-time policy the other identities grant nothing", async () => { + const { app } = await build(); + const member = await signInWithDiscord(app); + await linkGoogle(app, member.accessToken); + + const start = await startGoogle(app, undefined, "sign_in"); + const url = new URL(start.json().authorizationUrl); + fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined }); + const done = await callbackGoogle(app, start.json().stateToken); + + expect(done.statusCode).toBe(201); + expect(done.json().claimSet.facts).not.toContain("entitlement.app_access"); + }); + }); + it("leaves the six existing providers minting on first login", async () => { const { app, store } = await build(); await signInWithDiscord(app); diff --git a/tests/store.test.ts b/tests/store.test.ts index c5f6445..97b0830 100644 --- a/tests/store.test.ts +++ b/tests/store.test.ts @@ -232,12 +232,13 @@ describe("PostgresStore.checkSchema", () => { "sessions", "auth_attempts", "private_command_receipts", + "pending_links", "auth_completions", "capability_grants", "entitlement_snapshots", "audit_events", ]); - expect(REQUIRED_COLUMNS).toHaveLength(85); + expect(REQUIRED_COLUMNS).toHaveLength(95); expect(REQUIRED_COLUMNS).toEqual( expect.arrayContaining([ { table: "auth_completions", column: "code", type: "TEXT", nullable: false }, @@ -248,7 +249,7 @@ describe("PostgresStore.checkSchema", () => { { table: "auth_completions", column: "attempt_id", type: "TEXT", nullable: true }, ]) ); - expect(REQUIRED_KEYS).toHaveLength(12); + expect(REQUIRED_KEYS).toHaveLength(13); expect(REQUIRED_KEYS).toEqual( expect.arrayContaining([ { table: "linked_identities", columns: "provider,provider_user_id", predicate: "" },