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 e356ae2..df2fcc9 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,44 @@ 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 `LinkRefusal` + and the code `identity_not_linked` or `link_initiator_mismatch`. +- 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. + ### `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 a1fe611..2a19125 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, @@ -95,7 +96,7 @@ function buildOAuthStateToken(options: { handoff: OAuthHandoff; entitlementPolicy: OAuthEntitlementPolicy | 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 = { @@ -120,6 +121,7 @@ function buildOAuthStateToken(options: { return { token, + jti: payload.jti, expiresAt: new Date(expiresAtEpoch * 1000).toISOString(), }; } @@ -254,18 +256,14 @@ function relayedProviderText(value: string | undefined, bound: number): string | return printable ? printable : undefined; } -/** - * What may be recorded or answered about a caught error: its class and its - * 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 link callback the signed state's initiator does not license. Its message is static and safe to relay. */ -class LinkRefusal extends Error { +export class LinkRefusal extends Error { constructor( - readonly code: "link_initiator_missing" | "link_initiator_mismatch" | "link_initiator_unknown", + readonly code: "link_initiator_missing" | "link_initiator_mismatch" | "link_initiator_unknown" | "identity_not_linked", message: string ) { super(message); + this.name = "LinkRefusal"; } } @@ -408,6 +406,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) { @@ -417,7 +417,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; } @@ -502,6 +502,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; @@ -537,19 +544,43 @@ export async function startOAuthFlow( }; } + const descriptor = providerCatalog[provider]; + // A provider that never mints an account attaches an identity only through + // a bound link: the user's own access token, since the app's word does not + // name this person to Google. 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.`, + }, + }; + } + // Link mode binds an identity to an existing account, so the account must // be named by someone Heimdall can believe: the authenticated app backend. // The name rides in the signed state and the callback holds the provider's // identity to it. No other mode carries an initiator. if (input.mode === "link") { - if (!isAppCaller(caller) || caller.appSlug !== profile.slug) { - return { statusCode: 401, body: { error: "app_auth_required" } }; - } - if (!input.initiatingAccountId) { - return { statusCode: 400, body: { error: "link_initiator_required", detail: "link mode needs initiatingAccountId." } }; - } - if (!(await ctx.store.findAccountById(input.initiatingAccountId))) { - return { statusCode: 400, body: { error: "link_initiator_unknown", detail: "initiatingAccountId names no account." } }; + if (options?.linkAccountId) { + // The user's own access token for this app names the account. + if (input.initiatingAccountId !== undefined && input.initiatingAccountId !== options.linkAccountId) { + return { statusCode: 400, body: { error: "link_initiator_conflict", detail: "initiatingAccountId differs from the bearer's account." } }; + } + } else { + if (!isAppCaller(caller) || caller.appSlug !== profile.slug) { + return { statusCode: 401, body: { error: "app_auth_required" } }; + } + if (!input.initiatingAccountId) { + return { statusCode: 400, body: { error: "link_initiator_required", detail: "link mode needs initiatingAccountId." } }; + } + if (!(await ctx.store.findAccountById(input.initiatingAccountId))) { + return { statusCode: 400, body: { error: "link_initiator_unknown", detail: "initiatingAccountId names no account." } }; + } } } else if (input.initiatingAccountId !== undefined) { return { statusCode: 400, body: { error: "initiator_only_in_link_mode" } }; @@ -618,11 +649,12 @@ export async function startOAuthFlow( mode: input.mode, returnTo: input.returnTo, connection: input.connection, - initiatorAccountId: input.initiatingAccountId, + initiatorAccountId: options?.linkAccountId ?? input.initiatingAccountId, handoff, entitlementPolicy, keys, }); + const oidc = descriptor.oidc ? deriveOidcSecrets(state.jti, keys.privateKey) : undefined; return { statusCode: 201, @@ -637,6 +669,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, @@ -645,6 +678,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 @@ -912,7 +988,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; } @@ -1069,35 +1156,136 @@ 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 && callbackDescriptor.firstLogin === "requires_link") { + 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) { @@ -1126,32 +1314,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/contracts.ts b/src/contracts.ts index 718523b..8639fa4 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -1,4 +1,4 @@ -export const providers = ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick"] as const; +export const providers = ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick", "google"] as const; export type Provider = (typeof providers)[number]; diff --git a/src/google-oidc.ts b/src/google-oidc.ts new file mode 100644 index 0000000..b368e45 --- /dev/null +++ b/src/google-oidc.ts @@ -0,0 +1,228 @@ +import { createHash, createPublicKey, sign, verify, type KeyObject } from "node:crypto"; + +/** + * Google sign-in as OpenID Connect: the id_token is the identity, and this + * file is the one place that decides whether to believe it. The subject + * (`sub`) is the account key; the email is a claim about the subject, never + * its key. + */ + +export const GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"] as const; +export const GOOGLE_JWKS_URI = "https://www.googleapis.com/oauth2/v3/certs"; + +const CLOCK_LEEWAY_SECONDS = 60; +const DEFAULT_JWKS_TTL_SECONDS = 3600; +const MAX_JWKS_TTL_SECONDS = 24 * 3600; +/** An unknown `kid` refetches the key set at most this often. */ +const MIN_JWKS_REFETCH_MS = 60_000; + +export type GoogleIdTokenFailure = + | "malformed" + | "unsupported_algorithm" + | "unknown_key" + | "bad_signature" + | "bad_issuer" + | "bad_audience" + | "expired" + | "not_yet_valid" + | "bad_nonce" + | "missing_subject" + | "email_missing" + | "email_not_verified" + | "jwks_unavailable"; + +/** A refusal that names the rule that failed and quotes nothing from the token. */ +export class GoogleIdTokenError extends Error { + readonly code: GoogleIdTokenFailure; + + constructor(code: GoogleIdTokenFailure) { + super(`Google id_token refused (${code}).`); + this.name = "GoogleIdTokenError"; + this.code = code; + } +} + +export interface GoogleIdentityClaims { + sub: string; + email: string; + name?: string; + picture?: string; + hd?: string; +} + +interface Jwk { + kid?: string; + kty?: string; + alg?: string; + n?: string; + e?: string; +} + +let cachedKeys: { keys: Map; 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..b649ede 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 + * (`initiator_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/src/store/in-memory.ts b/src/store/in-memory.ts index b89e7eb..cc97b15 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(); @@ -430,6 +433,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 a1224dd..7d75d47 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, @@ -89,6 +91,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; @@ -232,6 +247,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, @@ -383,6 +416,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 3433ef8..c3da42e 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 f5b9873..2e0526d 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -185,6 +185,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; @@ -276,6 +298,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/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 60cf9a6..7d16a03 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, }; @@ -264,8 +265,8 @@ describe("R21.1: the attempt handle is unreachable from the public start route", const calls = source.match(/(?): 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)}` }); +} + +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(); + 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); + + // Phase one verified the identity and recorded it; it linked nothing and issued no session. + expect(done.statusCode).toBe(201); + 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"); + 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); + await linkGoogle(app, accessToken); + + 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 whose named initiator is not the bearer's account", async () => { + const { app, store } = await build(); + const a = await signInWithDiscord(app, "discord-1"); + const start = await app.inject({ + method: "POST", + url: "/v1/oauth/google/start", + headers: { authorization: `Bearer ${a.accessToken}` }, + payload: { + appSlug: "bifrost", + mode: "link", + returnTo: "https://bifrost.gamecult.org/auth/heimdall/complete", + initiatingAccountId: `${a.accountId}-other`, + }, + }); + expect([start.statusCode, start.json().error]).toEqual([400, "link_initiator_conflict"]); + 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(403); + expect(done.json().error).toBe("identity_not_linked"); + 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"); + await linkGoogle(first.app, a.accessToken); + + // 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(403); + 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); + 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(403); + }); + + 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); + 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/link-binding.test.ts b/tests/link-binding.test.ts index 9f52acb..0e7c985 100644 --- a/tests/link-binding.test.ts +++ b/tests/link-binding.test.ts @@ -49,6 +49,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/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/store.test.ts b/tests/store.test.ts index 7f892c8..1212094 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(86); + expect(REQUIRED_COLUMNS).toHaveLength(96); 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: "" }, diff --git a/tests/verse-runtime.test.ts b/tests/verse-runtime.test.ts index 3196cfa..29e61aa 100644 --- a/tests/verse-runtime.test.ts +++ b/tests/verse-runtime.test.ts @@ -71,7 +71,7 @@ describe("Heimdall verse runtime publication", () => { expect(bySchema.get("idunn.daemon_health")).toEqual([ "yggdrasil-heimdall", "active", - "Heimdall auth runtime active; storage=postgres; providers=2/7; apps=4; healthTransport=CultNet/RUDP", + "Heimdall auth runtime active; storage=postgres; providers=2/8; apps=4; healthTransport=CultNet/RUDP", "2026-06-16T17:10:00.000Z", "heimdall.cultnet-rudp-provider-health", "daemon-published", 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",