From 8e870d20a0b8dc5b9c5b810f94e18bf0ace17281 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 4 Nov 2026 16:10:23 +0000 Subject: [PATCH 1/2] Google OIDC provider: link-only, sub-keyed, PKCE and nonce Google is a Heimdall provider behind Bifrost, with no direct upstream. Scopes openid email profile; the callback verifies the id_token (RS256 against Google's keys, iss, aud, exp, iat, nonce) and requires a verified email. The identity key is sub, never the email. PKCE verifier and nonce are derived from the state jti and Heimdall's signing key, so nothing is stored between start and callback. Google never mints an account. mode=link needs a Heimdall access token for the app and attaches to that account only; sign_in signs in an identity already linked; an unknown sub is refused, and a sub held by another account is never moved. The six existing providers still mint on first login. Admitted to the bifrost profile only. The Idunn recipe declares the Google client id and secret file. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- deployment/idunn/recipe.toml | 2 + docs/service-contract.md | 28 ++ src/app-profiles.ts | 4 +- src/app.ts | 131 +++++++- src/contracts.ts | 9 +- src/google-oidc.ts | 228 +++++++++++++ src/oauth.ts | 50 ++- src/providers.ts | 45 +++ tests/access-plugin.test.ts | 2 +- tests/app.test.ts | 1 + tests/caller-identity-attack-soul-r21.test.ts | 1 + tests/caller-identity-attack-soul.test.ts | 1 + tests/caller-identity-soul.test.ts | 1 + tests/google-link.test.ts | 315 ++++++++++++++++++ tests/google-oidc.test.ts | 197 +++++++++++ tests/private-command-diagnostics.test.ts | 2 +- tests/security.test.ts | 1 + tests/verse-witness.test.ts | 1 + 19 files changed, 1010 insertions(+), 11 deletions(-) create mode 100644 src/google-oidc.ts create mode 100644 tests/google-link.test.ts create mode 100644 tests/google-oidc.test.ts diff --git a/README.md b/README.md index b86dbdc..f0e0a0e 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ The point is painfully simple: ## Front Door -- **Value:** one shared auth authority: provider OAuth (Discord, Patreon, GitHub, Twitch, YouTube, Spotify), linked identities, grants, entitlements and session issuance, with Ed25519-signed claims that app backends verify locally. +- **Value:** one shared auth authority: provider OAuth (Discord, Patreon, GitHub, Twitch, YouTube, Spotify, Google OIDC for linking), linked identities, grants, entitlements and session issuance, with Ed25519-signed claims that app backends verify locally. - **Problem:** every GameCult app was rebuilding Discord/Patreon login glue. Heimdall centralizes identity authority and keeps app-domain data in the apps. - **Entry points:** - `src/index.ts` and `src/app.ts`: the Fastify service. `src/signing.ts` and `src/claims.ts` issue JWTs and serve `/.well-known/jwks.json`; `src/oauth.ts` and `src/providers.ts` own provider flows. diff --git a/deployment/idunn/recipe.toml b/deployment/idunn/recipe.toml index f520258..48c3577 100644 --- a/deployment/idunn/recipe.toml +++ b/deployment/idunn/recipe.toml @@ -157,6 +157,8 @@ optional_environment = [ "GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID", "GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET_FILE", "GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET_FILE", + "GC_ACCESS_PROVIDER_GOOGLE_CLIENT_ID", + "GC_ACCESS_PROVIDER_GOOGLE_CLIENT_SECRET_FILE", "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_ID", "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_SECRET_FILE", "GC_ACCESS_PROVIDER_PATREON_CLIENT_ID", diff --git a/docs/service-contract.md b/docs/service-contract.md index 1567ae5..c28bdeb 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -119,6 +119,7 @@ Supported providers in the skeleton: - `youtube` - `spotify` - `joystick` +- `google` Request body: @@ -179,6 +180,33 @@ Launch note: implemented yet - Repixelizer launch can configure Discord and Patreon on its provider buttons +### Google (OpenID Connect, link-only) + +`google` is an identity provider that never mints an account. It is a second +door into a person who already has one. + +- Callback redirect URI (register exactly this in the Google Cloud OAuth + client): `{GC_ACCESS_BASE_URL}/v1/oauth/google/callback`, which is + `https://heimdall.gamecult.org/v1/oauth/google/callback` in production. +- Scopes `openid email profile`. The authorization request carries `state` + (signed, Heimdall's), a `nonce`, and a PKCE `S256` challenge. The verifier + and the nonce are derived from the state's `jti` and Heimdall's signing key, + so nothing is stored between start and callback. +- The callback verifies the `id_token` (RS256 against Google's published + keys; `iss` is Google, `aud` is this client, `exp`, `iat`, `nonce`) and + requires a `sub` and a verified email. The account key is `sub`; the email + is a display and contact field and never a lookup key. +- `mode: "link"` needs `Authorization: Bearer ` for the + same app; the identity attaches to that token's account and to no other. + `mode: "sign_in"` signs in an account the `sub` is already linked to. +- A `sub` no account holds, arriving with `sign_in`, is refused and mints + 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`. +- Configure with `GC_ACCESS_PROVIDER_GOOGLE_CLIENT_ID` and + `GC_ACCESS_PROVIDER_GOOGLE_CLIENT_SECRET_FILE`. Admitted to the `bifrost` + app profile only. + ### `GET /v1/oauth/{provider}/callback` Purpose: diff --git a/src/app-profiles.ts b/src/app-profiles.ts index 3489a48..d4d2d8e 100644 --- a/src/app-profiles.ts +++ b/src/app-profiles.ts @@ -137,8 +137,8 @@ const streampixelsProfile: AppProfile = { const bifrostProfile: AppProfile = { slug: "bifrost", displayName: "Bifrost", - profileVersion: "2026-06-08", - identityProviders: ["discord", "patreon"], + profileVersion: "2026-11-04", + identityProviders: ["discord", "patreon", "google"], entitlementSources: ["discord", "patreon"], managedConnectionProviders: [], allowedReturnOrigins: ["https://bifrost.gamecult.org"], diff --git a/src/app.ts b/src/app.ts index 40ceb20..1ddfc76 100644 --- a/src/app.ts +++ b/src/app.ts @@ -28,6 +28,7 @@ import { renderBrowserHandoffPage } from "./browser-handoff.js"; import { type HeimdallConfig, loadConfig } from "./config.js"; import { createTokenCustody, type TokenCustody } from "./custody.js"; import { grantFact } from "./facts.js"; +import { deriveOidcSecrets } from "./google-oidc.js"; import { createOAuthRuntimeRegistry, fetchPatreonIdentity, @@ -93,8 +94,9 @@ function buildOAuthStateToken(options: { connection: OAuthConnectionBinding | undefined; handoff: OAuthHandoff; entitlementPolicy: OAuthEntitlementPolicy | undefined; + linkAccountId: string | undefined; keys: ReturnType; -}): { token: string; expiresAt: string } { +}): { token: string; jti: string; expiresAt: string } { const issuedAt = nowEpochSeconds(); const expiresAtEpoch = issuedAt + options.config.stateTtlSeconds; const payload: OAuthStatePayload = { @@ -113,11 +115,13 @@ function buildOAuthStateToken(options: { connection: options.connection ?? null, handoff: options.handoff, entitlement_policy: options.entitlementPolicy ?? null, + link_account_id: options.linkAccountId ?? null, }; const token = signJwt(payload as unknown as Record, options.keys); return { token, + jti: payload.jti, expiresAt: new Date(expiresAtEpoch * 1000).toISOString(), }; } @@ -168,7 +172,10 @@ function parseOAuthStatePayload(payload: Record): OAuthStatePay typeof payload.nbf !== "number" || typeof payload.exp !== "number" || !handoffIsValid || - !entitlementPolicyIsValid + !entitlementPolicyIsValid || + (payload.link_account_id !== undefined && + payload.link_account_id !== null && + typeof payload.link_account_id !== "string") ) { return null; } @@ -180,6 +187,7 @@ function parseOAuthStatePayload(payload: Record): OAuthStatePay if (!normalizedPayload.entitlement_policy) { normalizedPayload.entitlement_policy = null; } + normalizedPayload.link_account_id ??= null; return normalizedPayload; } @@ -253,6 +261,21 @@ function relayedProviderText(value: string | undefined, bound: number): string | * code, each only when it is a short identifier. Never its message, which * carries whatever the thrower quoted (a host, a database, a provider's body). */ +/** + * A callback that would have attached an identity to the wrong account, or + * minted one for a person who has one. It carries a closed code and no text + * from the provider; `errorIdentity` records the class and the code. + */ +export class AccountLinkRefused extends Error { + readonly code: "identity_not_linked" | "identity_linked_elsewhere" | "link_account_missing"; + + constructor(code: AccountLinkRefused["code"]) { + super(`Account link refused (${code}).`); + this.name = "AccountLinkRefused"; + this.code = code; + } +} + export function errorIdentity(error: unknown): { errorClass: string; errorCode: string | null } { const code = (error as { code?: unknown } | null)?.code; return { @@ -486,6 +509,13 @@ export async function startOAuthFlow( * which one reaches the signed state token. */ trustedBrowserAttemptId?: string; + /** + * The account an explicit link binds to. Like `trustedBrowserAttemptId` + * it is a function argument, never a value parsed out of the request: + * the HTTP route sets it only from a verified Heimdall access token for + * this app (`resolveLinkAccount`), so a caller cannot name an account. + */ + linkAccountId?: string; } ): Promise { const { config, keys } = ctx; @@ -558,6 +588,22 @@ export async function startOAuthFlow( return { statusCode: 401, body: { error: "app_auth_required" } }; } + const descriptor = providerCatalog[provider]; + // A provider that never mints an account attaches an identity only through + // a bound link. A link start without a verified Heimdall session would end + // in a refusal at the callback, so it is refused before the redirect. + // `sign_in` stays open: it signs in an identity already linked, and the + // callback refuses one that is not. + if (descriptor.firstLogin === "requires_link" && input.mode === "link" && !options?.linkAccountId) { + return { + statusCode: 401, + body: { + error: "link_session_required", + detail: `Linking ${descriptor.displayName} needs a Heimdall access token for ${profile.slug}: send it as a Bearer token.`, + }, + }; + } + const providerConfig = config.providers[provider]; if (!providerConfig.clientId) { return { @@ -576,8 +622,10 @@ export async function startOAuthFlow( connection: input.connection, handoff, entitlementPolicy, + linkAccountId: options?.linkAccountId, keys, }); + const oidc = descriptor.oidc ? deriveOidcSecrets(state.jti, keys.privateKey) : undefined; return { statusCode: 201, @@ -592,6 +640,7 @@ export async function startOAuthFlow( redirectUri: callbackUrl, state: state.token, requestedScopes: input.requestedScopes, + ...(oidc ? { oidc: { nonce: oidc.nonce, codeChallenge: oidc.codeChallenge } } : {}), }), handoff, stateToken: state.token, @@ -600,6 +649,49 @@ export async function startOAuthFlow( }; } +/** + * The account a link start binds to: the one a live Heimdall access token for + * this app names. The token must verify under Heimdall's own key, be an access + * token for `appSlug`, be inside its validity window, and belong to a session + * the store still holds for that account at that revision. A bearer that fails + * any of these binds nothing: a link is explicit or it does not happen. + */ +async function resolveLinkAccount( + ctx: Pick, + appSlug: AppSlug, + authorization: string | undefined +): Promise { + const token = /^Bearer\s+(\S+)$/i.exec(authorization ?? "")?.[1]; + if (!token) return null; + const verification = verifyJwt(token, ctx.keys.publicKey); + if (!verification.valid) return null; + const claims = verification.payload; + const now = nowEpochSeconds(); + if ( + claims.typ !== "heimdall_access" || + claims.iss !== ctx.config.issuer || + claims.aud !== appSlug || + typeof claims.account_id !== "string" || + typeof claims.sid !== "string" || + typeof claims.access_revision !== "number" || + typeof claims.exp !== "number" || + claims.exp <= now || + (typeof claims.nbf === "number" && claims.nbf > now) + ) { + return null; + } + const session = await ctx.store.findSession(appSlug, claims.sid); + if ( + !session || + session.accountId !== claims.account_id || + session.accessRevision !== claims.access_revision || + Date.parse(session.expiresAt) <= Date.now() + ) { + return null; + } + return claims.account_id; +} + /** * Session refresh, extracted for the same reason as startOAuthFlow: the HTTP * route and the plane's `refreshAuth` (which owns Ghostlight identity) call @@ -866,7 +958,18 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { const caller = resolveAppCaller(config, request.body.appSlug, request.headers); - const result = await startOAuthFlow({ config, keys, store }, request.params.provider, caller, request.body); + let linkAccountId: string | undefined; + if (request.body.mode === "link") { + const bound = await resolveLinkAccount({ config, keys, store }, request.body.appSlug, request.headers.authorization); + if (bound === null && request.headers.authorization !== undefined) { + reply.code(401); + return { error: "invalid_link_session", detail: "The bearer is not a live Heimdall access token for this app." }; + } + linkAccountId = bound ?? undefined; + } + const result = await startOAuthFlow({ config, keys, store }, request.params.provider, caller, request.body, { + ...(linkAccountId !== undefined ? { linkAccountId } : {}), + }); reply.code(result.statusCode); return result.body; } @@ -1023,17 +1126,39 @@ export async function buildApp(options: BuildAppOptions = {}): Promise; expiresAt: number; fetchedAt: number } | null = null; + +export function resetGoogleJwksCache(): void { + cachedKeys = null; +} + +async function fetchGoogleKeys(now: number): Promise<{ keys: Map; expiresAt: number; fetchedAt: number }> { + let response: Response; + try { + response = await fetch(GOOGLE_JWKS_URI, { headers: { Accept: "application/json" } }); + } catch { + throw new GoogleIdTokenError("jwks_unavailable"); + } + if (!response.ok) { + throw new GoogleIdTokenError("jwks_unavailable"); + } + let body: { keys?: Jwk[] }; + try { + body = (await response.json()) as { keys?: Jwk[] }; + } catch { + throw new GoogleIdTokenError("jwks_unavailable"); + } + const keys = new Map(); + for (const jwk of body.keys ?? []) { + if (jwk.kty !== "RSA" || !jwk.kid || (jwk.alg !== undefined && jwk.alg !== "RS256")) continue; + try { + keys.set(jwk.kid, createPublicKey({ key: jwk as never, format: "jwk" })); + } catch { + // A key Node cannot read verifies nothing; the others stand. + } + } + const maxAge = /max-age=(\d+)/.exec(response.headers.get("cache-control") ?? ""); + const ttl = Math.min(maxAge ? Number(maxAge[1]) : DEFAULT_JWKS_TTL_SECONDS, MAX_JWKS_TTL_SECONDS); + return { keys, expiresAt: now + ttl * 1000, fetchedAt: now }; +} + +async function keyForKid(kid: string, now: number): Promise { + if (cachedKeys && cachedKeys.expiresAt > now) { + const hit = cachedKeys.keys.get(kid); + if (hit) return hit; + // Unknown kid in a live cache: Google may have rotated. Refetch, but not on every bad token. + if (now - cachedKeys.fetchedAt < MIN_JWKS_REFETCH_MS) { + throw new GoogleIdTokenError("unknown_key"); + } + } + cachedKeys = await fetchGoogleKeys(now); + const key = cachedKeys.keys.get(kid); + if (!key) throw new GoogleIdTokenError("unknown_key"); + return key; +} + +function decodeSegment(segment: string): Record { + try { + const value = JSON.parse(Buffer.from(segment, "base64url").toString("utf8")) as unknown; + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + return value as Record; + } + } catch { + // fallthrough + } + throw new GoogleIdTokenError("malformed"); +} + +/** + * Verifies a Google id_token for this client. Order is signature first, then + * claims, so no claim of an unauthenticated token decides anything. Refuses + * on: algorithm other than RS256, unknown key, bad signature, `iss` not + * Google, `aud` not this client (an array `aud` also needs `azp` to be this + * client), `exp` passed, `iat` or `nbf` in the future, `nonce` not the one + * this authorization request carried, no `sub`, no `email`, and an email + * Google has not verified. + */ +export async function verifyGoogleIdToken( + idToken: string, + expected: { clientId: string; nonce: string }, + nowMs: number = Date.now() +): Promise { + const parts = idToken.split("."); + if (parts.length !== 3 || parts.some((part) => part === "")) { + throw new GoogleIdTokenError("malformed"); + } + const [encodedHeader, encodedPayload, encodedSignature] = parts as [string, string, string]; + const header = decodeSegment(encodedHeader); + if (header.alg !== "RS256") { + throw new GoogleIdTokenError("unsupported_algorithm"); + } + if (typeof header.kid !== "string" || header.kid === "") { + throw new GoogleIdTokenError("unknown_key"); + } + + const key = await keyForKid(header.kid, nowMs); + const signatureValid = verify( + "RSA-SHA256", + Buffer.from(`${encodedHeader}.${encodedPayload}`, "utf8"), + key, + Buffer.from(encodedSignature, "base64url") + ); + if (!signatureValid) { + throw new GoogleIdTokenError("bad_signature"); + } + + const claims = decodeSegment(encodedPayload); + const nowSeconds = Math.floor(nowMs / 1000); + + if (typeof claims.iss !== "string" || !(GOOGLE_ISSUERS as readonly string[]).includes(claims.iss)) { + throw new GoogleIdTokenError("bad_issuer"); + } + const aud = claims.aud; + const audienceOk = + typeof aud === "string" + ? aud === expected.clientId + : Array.isArray(aud) && aud.includes(expected.clientId) && claims.azp === expected.clientId; + if (!audienceOk) { + throw new GoogleIdTokenError("bad_audience"); + } + if (typeof claims.exp !== "number" || claims.exp + CLOCK_LEEWAY_SECONDS <= nowSeconds) { + throw new GoogleIdTokenError("expired"); + } + if (typeof claims.iat !== "number" || claims.iat - CLOCK_LEEWAY_SECONDS > nowSeconds) { + throw new GoogleIdTokenError("not_yet_valid"); + } + if (claims.nbf !== undefined && (typeof claims.nbf !== "number" || claims.nbf - CLOCK_LEEWAY_SECONDS > nowSeconds)) { + throw new GoogleIdTokenError("not_yet_valid"); + } + if (typeof claims.nonce !== "string" || claims.nonce !== expected.nonce) { + throw new GoogleIdTokenError("bad_nonce"); + } + if (typeof claims.sub !== "string" || claims.sub === "" || claims.sub.length > 255) { + throw new GoogleIdTokenError("missing_subject"); + } + if (typeof claims.email !== "string" || claims.email === "") { + throw new GoogleIdTokenError("email_missing"); + } + if (claims.email_verified !== true && claims.email_verified !== "true") { + throw new GoogleIdTokenError("email_not_verified"); + } + + const identity: GoogleIdentityClaims = { sub: claims.sub, email: claims.email }; + if (typeof claims.name === "string" && claims.name !== "") identity.name = claims.name; + if (typeof claims.picture === "string" && claims.picture !== "") identity.picture = claims.picture; + if (typeof claims.hd === "string" && claims.hd !== "") identity.hd = claims.hd; + return identity; +} + +/** + * The PKCE verifier and the nonce of one authorization request, derived from + * the request's state `jti` and Heimdall's signing key instead of stored. The + * state token travels through the browser and carries the `jti`, so whoever + * can read the redirect can read the `jti`; they cannot compute an Ed25519 + * signature over it. The signature is deterministic, so start and callback + * derive the same pair, and nothing is persisted between them. + */ +export function deriveOidcSecrets(stateJti: string, signingKey: KeyObject): { + codeVerifier: string; + codeChallenge: string; + nonce: string; +} { + const derive = (label: string): string => + createHash("sha256") + .update(sign(null, Buffer.from(`heimdall:oidc:${label}:${stateJti}`, "utf8"), signingKey)) + .digest("base64url"); + const codeVerifier = derive("pkce-verifier"); + return { + codeVerifier, + codeChallenge: createHash("sha256").update(codeVerifier, "ascii").digest("base64url"), + nonce: derive("nonce"), + }; +} diff --git a/src/oauth.ts b/src/oauth.ts index b6658e8..346726d 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -7,6 +7,7 @@ import { } from "./contracts.js"; import { type HeimdallConfig } from "./config.js"; import { entitlementFacts } from "./facts.js"; +import { verifyGoogleIdToken } from "./google-oidc.js"; import { providerCatalog } from "./providers.js"; import { type CreateEntitlementSnapshotInput } from "./store/types.js"; @@ -49,13 +50,22 @@ export interface OAuthProviderRuntime { config: HeimdallConfig; code: string; redirectUri: string; + /** PKCE verifier of an OIDC provider's request (RFC 7636). */ + codeVerifier?: string; }): Promise; refreshAccessToken?(options: { config: HeimdallConfig; refreshToken: string; signal?: AbortSignal; }): Promise; - resolveIdentity(options: { config: HeimdallConfig; accessToken: string }): Promise; + resolveIdentity(options: { + config: HeimdallConfig; + accessToken: string; + /** The exchange's answer: an OIDC provider's identity is its id_token, not a lookup with the access token. */ + tokenSet?: OAuthTokenSet; + /** The nonce this authorization request carried; an OIDC provider's id_token must echo it. */ + oidcNonce?: string; + }): Promise; evaluateEntitlements(options: { config: HeimdallConfig; callback: OAuthCallbackContext; @@ -144,7 +154,7 @@ function normalizeScopes(scope: string | string[] | undefined): string[] { } export type TokenGrant = - | { grant_type: "authorization_code"; code: string; redirect_uri: string } + | { grant_type: "authorization_code"; code: string; redirect_uri: string; code_verifier?: string } | { grant_type: "refresh_token"; refresh_token: string }; /** @@ -249,6 +259,7 @@ function sharedRuntimeExchange(provider: Provider): OAuthProviderRuntime["exchan grant_type: "authorization_code", code: options.code, redirect_uri: options.redirectUri, + ...(options.codeVerifier !== undefined ? { code_verifier: options.codeVerifier } : {}), }); // A rotating provider's refresh token is the only way back to the // credential: a grant that arrives without one would be stored as a row @@ -527,6 +538,31 @@ async function resolveYouTubeIdentity(options: { accessToken: string }): Promise return identity; } +async function resolveGoogleIdentity(options: { + config: HeimdallConfig; + tokenSet?: OAuthTokenSet; + oidcNonce?: string; +}): Promise { + const clientId = options.config.providers.google.clientId; + const idToken = options.tokenSet?.raw.id_token; + if (!clientId || typeof idToken !== "string" || !options.oidcNonce) { + throw new Error("Google sign-in needs a client id, an id_token and the request's nonce."); + } + const claims = await verifyGoogleIdToken(idToken, { clientId, nonce: options.oidcNonce }); + const identity: ResolvedIdentity = { + provider: "google", + providerUserId: claims.sub, + username: claims.email, + displayName: claims.name ?? claims.email, + primaryEmail: claims.email, + profile: { sub: claims.sub, email: claims.email, email_verified: true, name: claims.name, picture: claims.picture, hd: claims.hd }, + }; + if (claims.picture) { + identity.avatarUrl = claims.picture; + } + return identity; +} + async function resolveSpotifyIdentity(options: { accessToken: string }): Promise { const profile = await fetchJson<{ id?: string; @@ -945,6 +981,15 @@ const youtubeRuntime: OAuthProviderRuntime = { }, }; +const googleRuntime: OAuthProviderRuntime = { + exchangeAuthorizationCode: sharedRuntimeExchange("google"), + refreshAccessToken: sharedRuntimeRefresh("google"), + resolveIdentity: resolveGoogleIdentity, + async evaluateEntitlements() { + return { facts: [], snapshots: [] }; + }, +}; + const spotifyRuntime: OAuthProviderRuntime = { exchangeAuthorizationCode: sharedRuntimeExchange("spotify"), refreshAccessToken: sharedRuntimeRefresh("spotify"), @@ -986,5 +1031,6 @@ export function createOAuthRuntimeRegistry(): OAuthRuntimeRegistry { youtube: youtubeRuntime, spotify: spotifyRuntime, joystick: joystickRuntime, + google: googleRuntime, }; } diff --git a/src/providers.ts b/src/providers.ts index 77eebf4..b0fae2b 100644 --- a/src/providers.ts +++ b/src/providers.ts @@ -22,6 +22,18 @@ export interface ProviderDescriptor { clientIdEnv: string; clientSecretEnv: string; additionalAuthorizationParams?: Record; + /** + * What a callback does with a (provider, subject) no account holds yet. + * `mints_account` is every provider's behaviour before Google: the first + * login makes an account. `requires_link` never mints: the identity + * attaches only to the account a verified link start bound + * (`link_account_id`), otherwise the callback refuses. Google is a second + * door into a person who already has an account, so a first Google login + * must not make a stranger of them. + */ + firstLogin: "mints_account" | "requires_link"; + /** OpenID Connect: the callback verifies an id_token and uses PKCE (S256) and a nonce. */ + oidc?: true; } export const providerCatalog: Record = { @@ -35,6 +47,7 @@ export const providerCatalog: Record = { defaultAccessTokenLifetimeSeconds: 3600, defaultScopes: ["identify", "guilds.members.read"], roles: ["identity", "entitlement"], + firstLogin: "mints_account", clientIdEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID", clientSecretEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET_FILE", }, @@ -48,6 +61,7 @@ export const providerCatalog: Record = { defaultAccessTokenLifetimeSeconds: 3600, defaultScopes: ["identity", "identity[email]"], roles: ["identity", "entitlement"], + firstLogin: "mints_account", clientIdEnv: "GC_ACCESS_PROVIDER_PATREON_CLIENT_ID", clientSecretEnv: "GC_ACCESS_PROVIDER_PATREON_CLIENT_SECRET_FILE", }, @@ -61,6 +75,7 @@ export const providerCatalog: Record = { defaultAccessTokenLifetimeSeconds: 3600, defaultScopes: ["read:user", "user:email"], roles: ["identity", "managed_credential"], + firstLogin: "mints_account", clientIdEnv: "GC_ACCESS_PROVIDER_GITHUB_CLIENT_ID", clientSecretEnv: "GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET_FILE", }, @@ -74,6 +89,7 @@ export const providerCatalog: Record = { defaultAccessTokenLifetimeSeconds: 3600, defaultScopes: ["user:read:email"], roles: ["identity", "managed_credential"], + firstLogin: "mints_account", clientIdEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_ID", clientSecretEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_SECRET_FILE", }, @@ -92,6 +108,7 @@ export const providerCatalog: Record = { "https://www.googleapis.com/auth/youtube.readonly", ], roles: ["identity", "managed_credential"], + firstLogin: "mints_account", clientIdEnv: "GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_ID", clientSecretEnv: "GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_SECRET_FILE", additionalAuthorizationParams: { @@ -114,9 +131,26 @@ export const providerCatalog: Record = { "user-modify-playback-state", ], roles: ["identity", "managed_credential"], + firstLogin: "mints_account", clientIdEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID", clientSecretEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE", }, + google: { + key: "google", + displayName: "Google", + authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth", + tokenEndpoint: "https://oauth2.googleapis.com/token", + tokenEndpointAuthMethod: "client_secret_post", + rotatesRefreshTokens: false, + defaultAccessTokenLifetimeSeconds: 3600, + defaultScopes: ["openid", "email", "profile"], + roles: ["identity"], + firstLogin: "requires_link", + oidc: true, + clientIdEnv: "GC_ACCESS_PROVIDER_GOOGLE_CLIENT_ID", + clientSecretEnv: "GC_ACCESS_PROVIDER_GOOGLE_CLIENT_SECRET_FILE", + additionalAuthorizationParams: { prompt: "select_account" }, + }, joystick: { key: "joystick", displayName: "Joystick", @@ -127,6 +161,7 @@ export const providerCatalog: Record = { defaultAccessTokenLifetimeSeconds: 3600, defaultScopes: ["identity:read", "stream:read", "chat:read", "followers:read", "subscribers:read"], roles: ["identity", "managed_credential"], + firstLogin: "mints_account", clientIdEnv: "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_ID", clientSecretEnv: "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_SECRET_FILE", }, @@ -138,6 +173,8 @@ export function buildAuthorizationUrl(options: { redirectUri: string; state: string; requestedScopes: string[] | undefined; + /** For an OIDC provider: the nonce, and the S256 challenge of the PKCE verifier. */ + oidc?: { nonce: string; codeChallenge: string }; }): string { const descriptor = providerCatalog[options.provider]; const url = new URL(descriptor.authorizationEndpoint); @@ -148,6 +185,14 @@ export function buildAuthorizationUrl(options: { url.searchParams.set("response_type", "code"); url.searchParams.set("scope", scopes.join(" ")); url.searchParams.set("state", options.state); + if (descriptor.oidc) { + if (!options.oidc) { + throw new Error(`${descriptor.displayName} is an OIDC provider: its authorization URL needs a nonce and a PKCE challenge.`); + } + url.searchParams.set("nonce", options.oidc.nonce); + url.searchParams.set("code_challenge", options.oidc.codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + } for (const [key, value] of Object.entries(descriptor.additionalAuthorizationParams ?? {})) { url.searchParams.set(key, value); diff --git a/tests/access-plugin.test.ts b/tests/access-plugin.test.ts index 4789bf8..ee902bd 100644 --- a/tests/access-plugin.test.ts +++ b/tests/access-plugin.test.ts @@ -291,7 +291,7 @@ function testConfig(): HeimdallConfig { storage: { backend: "memory", applySchemaOnStartup: true }, providers: { discord: { clientId: "discord-client", clientSecret: "discord-secret" }, - patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {}, + patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {}, google: {}, }, }; } diff --git a/tests/app.test.ts b/tests/app.test.ts index 7f4d96d..2b2e590 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -54,6 +54,7 @@ function createTestConfig(): HeimdallConfig { youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" }, spotify: { clientId: "spotify-client", clientSecret: "spotify-secret" }, joystick: { clientId: "joystick-client", clientSecret: "joystick-secret" }, + google: { clientId: "google-client", clientSecret: "google-secret" }, }, }; } diff --git a/tests/caller-identity-attack-soul-r21.test.ts b/tests/caller-identity-attack-soul-r21.test.ts index de959a8..660d540 100644 --- a/tests/caller-identity-attack-soul-r21.test.ts +++ b/tests/caller-identity-attack-soul-r21.test.ts @@ -66,6 +66,7 @@ function testConfig(overrides: Partial = {}): HeimdallConfig { youtube: {}, spotify: {}, joystick: {}, + google: {}, }, ...overrides, }; diff --git a/tests/caller-identity-attack-soul.test.ts b/tests/caller-identity-attack-soul.test.ts index 8f77801..501e38a 100644 --- a/tests/caller-identity-attack-soul.test.ts +++ b/tests/caller-identity-attack-soul.test.ts @@ -52,6 +52,7 @@ function testConfig(): HeimdallConfig { youtube: {}, spotify: {}, joystick: {}, + google: {}, }, }; } diff --git a/tests/caller-identity-soul.test.ts b/tests/caller-identity-soul.test.ts index b5562a7..5fe6179 100644 --- a/tests/caller-identity-soul.test.ts +++ b/tests/caller-identity-soul.test.ts @@ -60,6 +60,7 @@ function testConfig(): HeimdallConfig { youtube: {}, spotify: {}, joystick: {}, + google: {}, }, }; } diff --git a/tests/google-link.test.ts b/tests/google-link.test.ts new file mode 100644 index 0000000..379329a --- /dev/null +++ b/tests/google-link.test.ts @@ -0,0 +1,315 @@ +import { createHash, createSign, generateKeyPairSync } from "node:crypto"; +import { afterEach, describe, expect, it } from "vitest"; +import { type FastifyInstance } from "fastify"; +import { buildApp } from "../src/app.js"; +import { loadConfig, type HeimdallConfig } from "../src/config.js"; +import { resetGoogleJwksCache } from "../src/google-oidc.js"; +import { type OAuthProviderRuntime } from "../src/oauth.js"; +import { InMemoryStore } from "../src/store/index.js"; + +const CLIENT_ID = "google-client"; +const REDIRECT = "https://heimdall.gamecult.org/v1/oauth/google/callback"; +const rsa = generateKeyPairSync("rsa", { modulusLength: 2048 }); +const originalFetch = globalThis.fetch; +const apps: FastifyInstance[] = []; + +function config(): HeimdallConfig { + const base = loadConfig({ + GC_ACCESS_BASE_URL: "https://heimdall.gamecult.org", + GC_ACCESS_PROVIDER_GOOGLE_CLIENT_ID: CLIENT_ID, + GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID: "discord-client", + } as NodeJS.ProcessEnv); + return { + ...base, + storage: { backend: "memory", applySchemaOnStartup: true }, + tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"), + bootstrapSigningPrivateKeyOnMissing: true, + providers: { + ...base.providers, + google: { clientId: CLIENT_ID, clientSecret: "google-secret" }, + discord: { clientId: "discord-client", clientSecret: "discord-secret" }, + }, + }; +} + +function discordRuntime(providerUserId: string, name: string): OAuthProviderRuntime { + return { + async exchangeAuthorizationCode() { + return { accessToken: "d-at", refreshToken: "d-rt", tokenType: "Bearer", scope: ["identify"], expiresAt: "2099-01-01T00:00:00.000Z", raw: {} }; + }, + async resolveIdentity() { + return { provider: "discord", providerUserId, username: name, displayName: name, profile: { id: providerUserId } }; + }, + async evaluateEntitlements() { + return { facts: [], snapshots: [] }; + }, + }; +} + +function b64(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +function signedIdToken(claims: Record): string { + const now = Math.floor(Date.now() / 1000); + const body = `${b64({ alg: "RS256", kid: "k1" })}.${b64({ + iss: "https://accounts.google.com", + aud: CLIENT_ID, + azp: CLIENT_ID, + sub: "g-sub-1", + email: "person@example.org", + email_verified: true, + name: "Person", + iat: now, + exp: now + 600, + ...claims, + })}`; + return `${body}.${createSign("RSA-SHA256").update(body).sign(rsa.privateKey).toString("base64url")}`; +} + +interface GoogleLeg { + tokenRequests: URLSearchParams[]; +} + +/** Google's two endpoints: the key set, and a token endpoint whose id_token echoes the nonce it was told. */ +function fakeGoogle(options: { nonceFrom: () => string | undefined; claims?: Record }): GoogleLeg { + const leg: GoogleLeg = { tokenRequests: [] }; + globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { + const url = String(input); + if (url === "https://www.googleapis.com/oauth2/v3/certs") { + return new Response( + JSON.stringify({ keys: [{ ...(rsa.publicKey.export({ format: "jwk" }) as object), kid: "k1", alg: "RS256" }] }), + { status: 200 } + ); + } + if (url === "https://oauth2.googleapis.com/token") { + leg.tokenRequests.push(new URLSearchParams(String(init?.body))); + return new Response( + JSON.stringify({ + access_token: "g-at", + token_type: "Bearer", + expires_in: 3600, + scope: "openid email profile", + id_token: signedIdToken({ nonce: options.nonceFrom(), ...options.claims }), + }), + { status: 200 } + ); + } + throw new Error(`unexpected fetch ${url}`); + }) as typeof fetch; + return leg; +} + +function accountCount(store: InMemoryStore): number { + return (store as unknown as { accounts: Map }).accounts.size; +} + +async function signInWithDiscord(app: FastifyInstance, providerUserId = "discord-1"): Promise<{ accessToken: string; accountId: string }> { + const start = await app.inject({ + method: "POST", + url: "/v1/oauth/discord/start", + payload: { appSlug: "bifrost", mode: "sign_in", returnTo: "https://bifrost.gamecult.org/auth/heimdall/complete" }, + }); + expect(start.statusCode).toBe(201); + const callback = await app.inject({ + method: "GET", + url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(start.json().stateToken)}`, + }); + expect(callback.statusCode).toBe(201); + const body = callback.json(); + return { accessToken: body.accessToken as string, accountId: body.account.id as string }; +} + +async function build(store = new InMemoryStore(), discordUser = "discord-1") { + const app = await buildApp({ config: config(), store, oauthRuntimes: { discord: discordRuntime(discordUser, "Person") } }); + apps.push(app); + return { app, store }; +} + +function startGoogle(app: FastifyInstance, accessToken?: string, mode: "link" | "sign_in" = "link") { + return app.inject({ + method: "POST", + url: "/v1/oauth/google/start", + headers: accessToken ? { authorization: `Bearer ${accessToken}` } : {}, + payload: { appSlug: "bifrost", mode, returnTo: "https://bifrost.gamecult.org/auth/heimdall/complete" }, + }); +} + +function callbackGoogle(app: FastifyInstance, stateToken: string) { + return app.inject({ method: "GET", url: `/v1/oauth/google/callback?code=g-code&state=${encodeURIComponent(stateToken)}` }); +} + +afterEach(async () => { + globalThis.fetch = originalFetch; + resetGoogleJwksCache(); + while (apps.length) await apps.pop()?.close(); +}); + +describe("Google as a Heimdall OIDC provider", () => { + it("builds the authorization request: openid email profile, the exact callback, state, nonce and an S256 challenge", async () => { + const { app } = await build(); + const { accessToken } = await signInWithDiscord(app); + const start = await startGoogle(app, accessToken); + + expect(start.statusCode).toBe(201); + expect(start.json().callbackUrl).toBe(REDIRECT); + const url = new URL(start.json().authorizationUrl); + expect(`${url.origin}${url.pathname}`).toBe("https://accounts.google.com/o/oauth2/v2/auth"); + expect(url.searchParams.get("client_id")).toBe(CLIENT_ID); + expect(url.searchParams.get("redirect_uri")).toBe(REDIRECT); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("scope")).toBe("openid email profile"); + expect(url.searchParams.get("state")).toBe(start.json().stateToken); + expect(url.searchParams.get("nonce")).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(url.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + }); + + it("links Google to the account that asked, with PKCE proven end to end and the id_token's nonce matched", 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); + const leg = fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined }); + + const done = await callbackGoogle(app, start.json().stateToken); + + expect(done.statusCode).toBe(201); + expect(done.json().account.id).toBe(accountId); + expect(accountCount(store)).toBe(1); + const sent = leg.tokenRequests[0]!; + expect(sent.get("grant_type")).toBe("authorization_code"); + expect(sent.get("redirect_uri")).toBe(REDIRECT); + // The verifier Heimdall sent hashes to the challenge it put in the authorization URL. + expect(createHash("sha256").update(sent.get("code_verifier") ?? "", "ascii").digest("base64url")).toBe( + url.searchParams.get("code_challenge") + ); + const stored = await store.findStoredLinkedIdentity("google", "g-sub-1"); + expect(stored?.accountId).toBe(accountId); + expect(stored?.primaryEmail).toBe("person@example.org"); + }); + + 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); + + const signIn = await startGoogle(app, undefined, "sign_in"); + expect(signIn.statusCode).toBe(201); + const signInUrl = new URL(signIn.json().authorizationUrl); + fakeGoogle({ nonceFrom: () => signInUrl.searchParams.get("nonce") ?? undefined }); + const done = await callbackGoogle(app, signIn.json().stateToken); + + expect(done.statusCode).toBe(201); + expect(done.json().account.id).toBe(accountId); + expect(accountCount(store)).toBe(1); + }); + + it("refuses a link start with no Heimdall session", async () => { + const { app, store } = await build(); + const start = await startGoogle(app); + expect(start.statusCode).toBe(401); + expect(start.json().error).toBe("link_session_required"); + expect(accountCount(store)).toBe(0); + }); + + it.each([ + ["a garbage bearer", () => "not-a-token"], + ["a token for another app", async (app: FastifyInstance) => { + const start = 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(start.json().stateToken)}` }); + return cb.json().accessToken as string; + }], + ])("refuses a link start carrying %s", async (_name, token) => { + const { app, store } = await build(); + const bearer = await token(app); + const before = accountCount(store); + const start = await startGoogle(app, bearer); + expect(start.statusCode).toBe(401); + expect(start.json().error).toBe("invalid_link_session"); + expect(accountCount(store)).toBe(before); + }); + + it("refuses a first Google login from a subject no account holds, and mints no account for it", async () => { + // The person who has a Discord account arrives with Google for the first time, signing in rather than linking. + const { app, store } = await build(); + await signInWithDiscord(app); + const start = await startGoogle(app, undefined, "sign_in"); + expect(start.statusCode).toBe(201); + const url = new URL(start.json().authorizationUrl); + fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined }); + + const before = accountCount(store); + const done = await callbackGoogle(app, start.json().stateToken); + + expect(done.statusCode).toBe(502); + expect(done.json().error).toBe("oauth_callback_failed"); + expect(accountCount(store)).toBe(before); + expect(await store.findStoredLinkedIdentity("google", "g-sub-1")).toBeNull(); + }); + + it("never moves or merges: a Google subject already linked to one account cannot be linked to another", async () => { + 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); + + // A second person, a second account, the same Google subject. + const second = await build(store, "discord-2"); + const b = await signInWithDiscord(second.app, "discord-2"); + expect(b.accountId).not.toBe(a.accountId); + const startB = await startGoogle(second.app, b.accessToken); + const urlB = new URL(startB.json().authorizationUrl); + fakeGoogle({ nonceFrom: () => urlB.searchParams.get("nonce") ?? undefined }); + const done = await callbackGoogle(second.app, startB.json().stateToken); + + expect(done.statusCode).toBe(502); + expect((await store.findStoredLinkedIdentity("google", "g-sub-1"))?.accountId).toBe(a.accountId); + }); + + it.each([ + ["an unverified email", { email_verified: false }], + ["another client's audience", { aud: "other-client", azp: "other-client" }], + ["a nonce this request did not carry", { nonce: "replayed" }], + ["an expired token", { exp: Math.floor(Date.now() / 1000) - 3600 }], + ])("links nothing when the id_token has %s", async (_name, claims) => { + const { app, store } = await build(); + const { accessToken } = await signInWithDiscord(app); + const start = await startGoogle(app, accessToken); + const url = new URL(start.json().authorizationUrl); + fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined, claims }); + + const done = await callbackGoogle(app, start.json().stateToken); + + expect(done.statusCode).toBe(502); + expect(await store.findStoredLinkedIdentity("google", "g-sub-1")).toBeNull(); + expect(accountCount(store)).toBe(1); + }); + + 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); + expect((await store.findStoredLinkedIdentity("google", "g-sub-2"))?.accountId).toBe(accountId); + expect(await store.findStoredLinkedIdentity("google", "person@example.org")).toBeNull(); + }); + + it("leaves the six existing providers minting on first login", async () => { + const { app, store } = await build(); + await signInWithDiscord(app); + expect(accountCount(store)).toBe(1); + }); +}); diff --git a/tests/google-oidc.test.ts b/tests/google-oidc.test.ts new file mode 100644 index 0000000..e92dc50 --- /dev/null +++ b/tests/google-oidc.test.ts @@ -0,0 +1,197 @@ +import { createSign, generateKeyPairSync, type KeyObject } from "node:crypto"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + deriveOidcSecrets, + GoogleIdTokenError, + resetGoogleJwksCache, + verifyGoogleIdToken, + type GoogleIdTokenFailure, +} from "../src/google-oidc.js"; + +const CLIENT_ID = "google-client.apps.googleusercontent.com"; +const NONCE = "the-nonce"; +const NOW_MS = Date.parse("2026-11-04T12:00:00.000Z"); +const NOW_S = Math.floor(NOW_MS / 1000); + +const rsa = generateKeyPairSync("rsa", { modulusLength: 2048 }); +const otherRsa = generateKeyPairSync("rsa", { modulusLength: 2048 }); +const originalFetch = globalThis.fetch; +let jwksFetches = 0; + +function jwks(kidKeys: Record) { + return { + keys: Object.entries(kidKeys).map(([kid, key]) => ({ + ...(key.export({ format: "jwk" }) as Record), + kid, + alg: "RS256", + use: "sig", + })), + }; +} + +function serveJwks(kidKeys: Record) { + globalThis.fetch = (async (input: string | URL) => { + expect(String(input)).toBe("https://www.googleapis.com/oauth2/v3/certs"); + jwksFetches += 1; + return new Response(JSON.stringify(jwks(kidKeys)), { status: 200, headers: { "cache-control": "public, max-age=3600" } }); + }) as typeof fetch; +} + +function b64(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +function idToken( + claims: Record = {}, + options: { kid?: string; alg?: string; key?: KeyObject; omit?: string[] } = {} +): string { + const payload: Record = { + iss: "https://accounts.google.com", + aud: CLIENT_ID, + azp: CLIENT_ID, + sub: "110169484474386276334", + email: "meta@example.org", + email_verified: true, + name: "Meta", + picture: "https://lh3.googleusercontent.com/a/x", + nonce: NONCE, + iat: NOW_S - 10, + exp: NOW_S + 3000, + ...claims, + }; + for (const key of options.omit ?? []) delete payload[key]; + const header = b64({ alg: options.alg ?? "RS256", typ: "JWT", kid: options.kid ?? "k1" }); + const body = `${header}.${b64(payload)}`; + const signature = createSign("RSA-SHA256").update(body).sign(options.key ?? rsa.privateKey).toString("base64url"); + return `${body}.${signature}`; +} + +async function refusal(token: string, nonce = NONCE): Promise { + try { + await verifyGoogleIdToken(token, { clientId: CLIENT_ID, nonce }, NOW_MS); + } catch (error) { + expect(error).toBeInstanceOf(GoogleIdTokenError); + return (error as GoogleIdTokenError).code; + } + throw new Error("the token was accepted"); +} + +beforeEach(() => { + resetGoogleJwksCache(); + jwksFetches = 0; + serveJwks({ k1: rsa.publicKey }); +}); +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("verifyGoogleIdToken", () => { + it("accepts a good token and returns the subject as the identity", async () => { + const claims = await verifyGoogleIdToken(idToken(), { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS); + expect(claims).toEqual({ + sub: "110169484474386276334", + email: "meta@example.org", + name: "Meta", + picture: "https://lh3.googleusercontent.com/a/x", + }); + }); + + it("accepts the bare-host issuer Google also uses", async () => { + await expect(verifyGoogleIdToken(idToken({ iss: "accounts.google.com" }), { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS)).resolves.toMatchObject({ + sub: "110169484474386276334", + }); + }); + + it.each<[string, () => string, GoogleIdTokenFailure]>([ + ["a foreign issuer", () => idToken({ iss: "https://evil.example" }), "bad_issuer"], + ["an issuer that merely contains Google's", () => idToken({ iss: "https://accounts.google.com.evil.example" }), "bad_issuer"], + ["another client's audience", () => idToken({ aud: "other-client", azp: "other-client" }), "bad_audience"], + ["an array audience whose azp is not this client", () => idToken({ aud: [CLIENT_ID, "other"], azp: "other" }), "bad_audience"], + ["an array audience with no azp", () => idToken({ aud: [CLIENT_ID], azp: undefined }), "bad_audience"], + ["an expired token", () => idToken({ exp: NOW_S - 61 }), "expired"], + ["a token with no exp", () => idToken({}, { omit: ["exp"] }), "expired"], + ["a token issued in the future", () => idToken({ iat: NOW_S + 3600 }), "not_yet_valid"], + ["a token with no iat", () => idToken({}, { omit: ["iat"] }), "not_yet_valid"], + ["a nonce this request did not carry", () => idToken({ nonce: "someone-elses" }), "bad_nonce"], + ["a token with no nonce", () => idToken({}, { omit: ["nonce"] }), "bad_nonce"], + ["no subject", () => idToken({}, { omit: ["sub"] }), "missing_subject"], + ["an empty subject", () => idToken({ sub: "" }), "missing_subject"], + ["no email", () => idToken({}, { omit: ["email"] }), "email_missing"], + ["an unverified email", () => idToken({ email_verified: false }), "email_not_verified"], + ["no email_verified claim", () => idToken({}, { omit: ["email_verified"] }), "email_not_verified"], + ["a string email_verified other than true", () => idToken({ email_verified: "yes" }), "email_not_verified"], + ["alg none", () => idToken({}, { alg: "none" }), "unsupported_algorithm"], + ["alg HS256", () => idToken({}, { alg: "HS256" }), "unsupported_algorithm"], + ["a key Google does not publish", () => idToken({}, { kid: "unknown" }), "unknown_key"], + ["a signature by another key under Google's kid", () => idToken({}, { key: otherRsa.privateKey }), "bad_signature"], + ["three empty parts", () => "..", "malformed"], + ["two parts", () => "a.b", "malformed"], + ])("refuses %s", async (_name, make, code) => { + expect(await refusal(make())).toBe(code); + }); + + it("checks the signature before it believes any claim", async () => { + // A forged payload with every claim right is still refused as a bad signature. + const forged = idToken({}, { key: otherRsa.privateKey }); + expect(await refusal(forged)).toBe("bad_signature"); + }); + + it("takes email_verified string 'true' as Google's older tokens spell it", async () => { + await expect(verifyGoogleIdToken(idToken({ email_verified: "true" }), { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS)).resolves.toMatchObject({ + email: "meta@example.org", + }); + }); + + it("caches Google's keys and refetches once for a rotated kid, not for every bad token", async () => { + await verifyGoogleIdToken(idToken(), { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS); + await verifyGoogleIdToken(idToken(), { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS + 1000); + expect(jwksFetches).toBe(1); + serveJwks({ k1: rsa.publicKey, k2: otherRsa.publicKey }); + const rotated = idToken({}, { kid: "k2", key: otherRsa.privateKey }); + // Inside the refetch floor the unknown kid is refused without a fetch. + await expect(verifyGoogleIdToken(rotated, { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS + 2000)).rejects.toMatchObject({ code: "unknown_key" }); + expect(jwksFetches).toBe(1); + await expect(verifyGoogleIdToken(rotated, { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS + 120_000)).resolves.toMatchObject({ sub: "110169484474386276334" }); + expect(jwksFetches).toBe(2); + }); + + it("refuses with jwks_unavailable when Google's key endpoint fails", async () => { + resetGoogleJwksCache(); + globalThis.fetch = (async () => new Response("down", { status: 503 })) as typeof fetch; + expect(await refusal(idToken())).toBe("jwks_unavailable"); + }); + + it("never quotes the token in its refusal", async () => { + const token = idToken({ email: "secret-person@example.org", nonce: "wrong" }); + try { + await verifyGoogleIdToken(token, { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS); + } catch (error) { + expect(String((error as Error).message)).not.toContain("secret-person"); + expect(String((error as Error).message)).not.toContain(token.slice(0, 20)); + } + }); +}); + +describe("deriveOidcSecrets", () => { + const key = generateKeyPairSync("ed25519").privateKey; + const otherKey = generateKeyPairSync("ed25519").privateKey; + + it("is the same pair for the same state and Heimdall key, so nothing is stored between start and callback", () => { + expect(deriveOidcSecrets("jti-1", key)).toEqual(deriveOidcSecrets("jti-1", key)); + }); + + it("differs per state, per Heimdall key, and between verifier and nonce", () => { + const a = deriveOidcSecrets("jti-1", key); + expect(deriveOidcSecrets("jti-2", key).codeVerifier).not.toBe(a.codeVerifier); + expect(deriveOidcSecrets("jti-2", key).nonce).not.toBe(a.nonce); + expect(deriveOidcSecrets("jti-1", otherKey).codeVerifier).not.toBe(a.codeVerifier); + expect(a.codeVerifier).not.toBe(a.nonce); + }); + + it("yields an RFC 7636 verifier (43-128 unreserved characters) and its S256 challenge", () => { + const { codeVerifier, codeChallenge } = deriveOidcSecrets("jti-1", key); + expect(codeVerifier).toMatch(/^[A-Za-z0-9_-]{43,128}$/); + expect(codeChallenge).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(codeChallenge).not.toBe(codeVerifier); + }); +}); diff --git a/tests/private-command-diagnostics.test.ts b/tests/private-command-diagnostics.test.ts index d1801a5..d76d054 100644 --- a/tests/private-command-diagnostics.test.ts +++ b/tests/private-command-diagnostics.test.ts @@ -30,7 +30,7 @@ function config(): HeimdallConfig { bootstrapSigningPrivateKeyOnMissing: false, tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"), appSharedSecrets: { ghostlight: secret }, appRuntimeIds: { ghostlight: ["yggdrasil-ghostlight"] }, appBackendCallbacks: {}, storage: { backend: "memory", applySchemaOnStartup: true }, - providers: { discord: { clientId: "discord-client", clientSecret: "discord-secret" }, patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {} }, + providers: { discord: { clientId: "discord-client", clientSecret: "discord-secret" }, patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {}, google: {} }, } as HeimdallConfig; } diff --git a/tests/security.test.ts b/tests/security.test.ts index 4f3f39d..0f9ab42 100644 --- a/tests/security.test.ts +++ b/tests/security.test.ts @@ -39,6 +39,7 @@ function createTestConfig(overrides: Partial = {}): HeimdallConf youtube: {}, spotify: {}, joystick: {}, + google: {}, }, ...overrides, }; diff --git a/tests/verse-witness.test.ts b/tests/verse-witness.test.ts index 07c141f..043f735 100644 --- a/tests/verse-witness.test.ts +++ b/tests/verse-witness.test.ts @@ -32,6 +32,7 @@ describe("Heimdall Verse witness advertisement", () => { "youtube", "spotify", "joystick", + "google", ]); expect(advertisement.appProfiles.map((profile) => profile.slug)).toEqual([ "repixelizer", -- 2.47.3 From c829429cd5c1ee92234461d62ba96859bb5c8719 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 4 Nov 2026 17:15:33 +0000 Subject: [PATCH 2/2] Google link is two-phase; callback evaluates all linked identities The callback records a typed pending link (short TTL, single use) and writes no linked identity; POST /v1/apps/:appSlug/links/confirm with the Heimdall access token of the initiating account attaches it. A victim who completes an attacker's link URL attaches nothing. The sign-in callback also evaluates the account's other linked identities with the start-time policy, so a Discord-origin member returning via Google keeps app_access. Co-Authored-By: Claude Sonnet 5.5 --- docs/service-contract.md | 11 +++ src/app.ts | 192 ++++++++++++++++++++++++++++++------ src/backend-handoff.ts | 11 ++- src/browser-handoff.ts | 4 + src/store/in-memory.ts | 18 ++++ src/store/postgres.ts | 76 ++++++++++++++ src/store/schema.ts | 13 +++ src/store/types.ts | 29 ++++++ tests/google-link.test.ts | 201 +++++++++++++++++++++++++++++++++++--- tests/store.test.ts | 5 +- 10 files changed, 513 insertions(+), 47 deletions(-) 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: "" }, -- 2.47.3