Google OIDC provider: link-only, sub-keyed, PKCE and nonce #2

Open
heimdall wants to merge 2 commits from heimdall/google-oidc-provider into main
Member

Adds google as a Heimdall OpenID Connect provider, per gamecult-chat:ruling:google-first-path-ruled (Google behind Heimdall; Bifrost has no direct Google upstream). Not deployed, not merged, no secret touched.

Callback redirect URI (register exactly this in the Google OAuth client):
https://heimdall.gamecult.org/v1/oauth/google/callback
It is {GC_ACCESS_BASE_URL}/v1/oauth/google/callback; production's value is taken from docs/architecture.md, not from the live host.

What it does

  • Scopes openid email profile; state (Heimdall-signed), nonce, PKCE S256.
  • src/google-oidc.ts owns the decision: RS256 only, signature checked against Google's published keys before any claim is read; iss Google, aud this client (an array aud also needs azp), exp/iat/nbf with 60 s leeway, nonce equal to this request's, sub present, email present and email_verified.
  • The identity key is sub. The email is a display/contact field and is never a lookup key.
  • PKCE verifier and nonce are derived from the state jti and Heimdall's Ed25519 signing key (deterministic signature), so nothing is stored between start and callback and a reader of the redirect cannot compute them.

Linking (settled)

  • Google never mints an account (firstLogin: "requires_link" on the descriptor).
  • mode: link needs Authorization: Bearer <Heimdall access token> for the same app (verified signature, issuer, audience, window, and a live session at its revision). The identity attaches to that account only.
  • mode: sign_in signs in an account the sub is already linked to. An unknown sub is refused and nothing is minted; a sub held by another account is refused and never moved.
  • The six existing providers still mint on first login. The bound-account check applies to any provider that is started with a verified link.
  • Admitted to the bifrost profile only (profile version bumped). The Idunn recipe declares GC_ACCESS_PROVIDER_GOOGLE_CLIENT_ID and ..._CLIENT_SECRET_FILE.

Tests

tests/google-oidc.test.ts (33) and tests/google-link.test.ts (14), including PKCE proven end to end (the verifier sent hashes to the challenge in the URL), nonce replay, wrong audience, unverified email, expired token, rotated keys, a stranger's sub, and a sub linked elsewhere. Mutation check by hand: removing the no-mint guard, the linked-elsewhere check, the email_verified check, the verifier or the nonce check each fails a test.

Full suite: 19 failed / 432 passed / 146 skipped. The same 19 fail on main at 5146c72 before this change: 13 test files cannot load @gamecult/cultcache-ts/cultnet-ts because vendor/CultLib packages are not built in my workspace (ERR_MODULE_NOT_FOUND ... dist/index.js). Typecheck is clean apart from those module errors.

  1. Link CSRF. The link binds at Heimdall's callback, and Heimdall cannot tie the callback to the browser that started it (start is a server-to-server POST). An attacker's link URL completed by a victim's Google login would attach the victim's Google to the attacker's account. Needs a two-phase link: callback records a pending link, the app confirms it from the browser holding its own attempt cookie.
  2. Invitation path does not exist. Heimdall has no invitation concept; Bifrost's invitations are keyed by GitHub login. First attach is therefore explicit link only.
  3. Member access across providers. A Google sign-in evaluates no entitlements; entitlement.app_access from a linked Discord/Patreon identity appears only after a refresh with policies. Bifrost must refresh after a Google sign-in or Heimdall must evaluate all linked identities at the callback.
  4. The generic callback stores the Google access token encrypted although Google here is identity-only; it could skip custody.
  5. A refused link surfaces to the user as the generic oauth_callback_failed; the audit event carries AccountLinkRefused and the code.

🤖 Generated with Claude Code

Adds `google` as a Heimdall OpenID Connect provider, per `gamecult-chat:ruling:google-first-path-ruled` (Google behind Heimdall; Bifrost has no direct Google upstream). Not deployed, not merged, no secret touched. **Callback redirect URI (register exactly this in the Google OAuth client):** `https://heimdall.gamecult.org/v1/oauth/google/callback` It is `{GC_ACCESS_BASE_URL}/v1/oauth/google/callback`; production's value is taken from `docs/architecture.md`, not from the live host. ## What it does - Scopes `openid email profile`; `state` (Heimdall-signed), `nonce`, PKCE `S256`. - `src/google-oidc.ts` owns the decision: RS256 only, signature checked against Google's published keys before any claim is read; `iss` Google, `aud` this client (an array `aud` also needs `azp`), `exp`/`iat`/`nbf` with 60 s leeway, `nonce` equal to this request's, `sub` present, `email` present and `email_verified`. - The identity key is `sub`. The email is a display/contact field and is never a lookup key. - PKCE verifier and nonce are derived from the state `jti` and Heimdall's Ed25519 signing key (deterministic signature), so nothing is stored between start and callback and a reader of the redirect cannot compute them. ## Linking (settled) - Google never mints an account (`firstLogin: "requires_link"` on the descriptor). - `mode: link` needs `Authorization: Bearer <Heimdall access token>` for the same app (verified signature, issuer, audience, window, and a live session at its revision). The identity attaches to that account only. - `mode: sign_in` signs in an account the `sub` is already linked to. An unknown `sub` is refused and nothing is minted; a `sub` held by another account is refused and never moved. - The six existing providers still mint on first login. The bound-account check applies to any provider that is started with a verified link. - Admitted to the `bifrost` profile only (profile version bumped). The Idunn recipe declares `GC_ACCESS_PROVIDER_GOOGLE_CLIENT_ID` and `..._CLIENT_SECRET_FILE`. ## Tests `tests/google-oidc.test.ts` (33) and `tests/google-link.test.ts` (14), including PKCE proven end to end (the verifier sent hashes to the challenge in the URL), nonce replay, wrong audience, unverified email, expired token, rotated keys, a stranger's `sub`, and a `sub` linked elsewhere. Mutation check by hand: removing the no-mint guard, the linked-elsewhere check, the `email_verified` check, the verifier or the nonce check each fails a test. Full suite: 19 failed / 432 passed / 146 skipped. The same 19 fail on `main` at 5146c72 before this change: 13 test files cannot load `@gamecult/cultcache-ts`/`cultnet-ts` because `vendor/CultLib` packages are not built in my workspace (`ERR_MODULE_NOT_FOUND ... dist/index.js`). Typecheck is clean apart from those module errors. ## Known gaps, do not enable `link` in production until these are settled 1. **Link CSRF.** The link binds at Heimdall's callback, and Heimdall cannot tie the callback to the browser that started it (start is a server-to-server POST). An attacker's link URL completed by a victim's Google login would attach the victim's Google to the attacker's account. Needs a two-phase link: callback records a pending link, the app confirms it from the browser holding its own attempt cookie. 2. **Invitation path does not exist.** Heimdall has no invitation concept; Bifrost's invitations are keyed by GitHub login. First attach is therefore explicit link only. 3. **Member access across providers.** A Google sign-in evaluates no entitlements; `entitlement.app_access` from a linked Discord/Patreon identity appears only after a refresh with policies. Bifrost must refresh after a Google sign-in or Heimdall must evaluate all linked identities at the callback. 4. The generic callback stores the Google access token encrypted although Google here is identity-only; it could skip custody. 5. A refused link surfaces to the user as the generic `oauth_callback_failed`; the audit event carries `AccountLinkRefused` and the code. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Author
Member

Pushed c829429: both follow-ups fixed.

google-link-two-phase. The callback no longer writes a link. It records a pending link (pending_links, TTL = completionTtlSeconds, single use, store-minted id) and answers status: link_pending with pendingLinkId and no session. Only POST /v1/apps/:appSlug/links/confirm with the Heimdall access token of the account that began the link writes the identity. A different account, expired, used or unknown id gives 410; a sub another account holds by then gives 409; a refused confirm does not burn the link. Applies to any provider started with mode: link. Test: a victim completing an attacker's link URL attaches the victim's sub to no account.

google-linked-entitlements-at-callback. Every callback now also evaluates the account's other linked identities with the start-time entitlement policy (the refresh route's call), so a Discord-origin member returning via Google keeps entitlement.app_access. Without a start-time policy they grant nothing.

Tests: 22 in google-link (8 new), mutation-checked (dropping the account bind or the linked-facts merge each fails a test). Full suite: only the two failures that were already there (caller-identity-attack-soul-r21 startOAuthFlow callers; verse-runtime providers=2/7 vs 2/8). Postgres tests skipped here, no database: pending_links schema and store.test.ts counts (95 columns, 13 keys) are unrun against Postgres.

Still open: the Bifrost side. Bifrost has to call confirm with the user's token (and the pending link id from the callback result), and needs a link action. For a backend_callback handoff the link_pending payload goes to the app keyed by attemptId, so Bifrost must only confirm for the browser that holds that attempt's cookie; otherwise an attacker's attempt can still receive the victim's pending id. Not deployed, no hosts, no secrets.

Pushed c829429: both follow-ups fixed. **google-link-two-phase.** The callback no longer writes a link. It records a pending link (`pending_links`, TTL = `completionTtlSeconds`, single use, store-minted id) and answers `status: link_pending` with `pendingLinkId` and no session. Only `POST /v1/apps/:appSlug/links/confirm` with the Heimdall access token of the account that began the link writes the identity. A different account, expired, used or unknown id gives 410; a `sub` another account holds by then gives 409; a refused confirm does not burn the link. Applies to any provider started with `mode: link`. Test: a victim completing an attacker's link URL attaches the victim's sub to no account. **google-linked-entitlements-at-callback.** Every callback now also evaluates the account's other linked identities with the start-time entitlement policy (the refresh route's call), so a Discord-origin member returning via Google keeps `entitlement.app_access`. Without a start-time policy they grant nothing. Tests: 22 in google-link (8 new), mutation-checked (dropping the account bind or the linked-facts merge each fails a test). Full suite: only the two failures that were already there (`caller-identity-attack-soul-r21` startOAuthFlow callers; `verse-runtime` providers=2/7 vs 2/8). Postgres tests skipped here, no database: `pending_links` schema and `store.test.ts` counts (95 columns, 13 keys) are unrun against Postgres. **Still open: the Bifrost side.** Bifrost has to call confirm with the user's token (and the pending link id from the callback result), and needs a link action. For a `backend_callback` handoff the `link_pending` payload goes to the app keyed by attemptId, so Bifrost must only confirm for the browser that holds that attempt's cookie; otherwise an attacker's attempt can still receive the victim's pending id. Not deployed, no hosts, no secrets.
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin heimdall/google-oidc-provider:heimdall/google-oidc-provider
git switch heimdall/google-oidc-provider

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff heimdall/google-oidc-provider
git switch heimdall/google-oidc-provider
git rebase main
git switch main
git merge --ff-only heimdall/google-oidc-provider
git switch heimdall/google-oidc-provider
git rebase main
git switch main
git merge --no-ff heimdall/google-oidc-provider
git switch main
git merge --squash heimdall/google-oidc-provider
git switch main
git merge --ff-only heimdall/google-oidc-provider
git switch main
git merge heimdall/google-oidc-provider
git push origin main
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
GameCult/Heimdall!2
No description provided.