r2: bind OAuth completion to the launching browser, one use per state; gate connect starts #3

Merged
heimdall merged 11 commits from heimdall/r2-state-binding into main 2026-11-05 17:16:40 +00:00
Member

Lands the r2 cut (gamecult-chat:cut_spec:cut-heimdall-binds-completion-to-attempt.r2) on main, plus one extra gate.

What changes

  • Merges gamecult-chat/heimdall-binds-completion (h1: startRequiresAppCaller for Bifrost; r2: launch hop, signed per-flow __Host-heimdall-oauth-<jti> cookie, claimOAuthStateUse for phases launch and callback on the in-memory store and an INSERT-only oauth_state_uses table, callback expiry check, authorizationUrl is now the launch URL, @fastify/cookie).
  • Reconciled with main's #1 (link initiator binding, initiator_account_id, pending links) and #2 (Google OIDC): the OIDC nonce and PKCE secrets are still derived from the state jti and are now put on the provider URL at the launch hop. Both stores keep pending links and gain state-use claims.
  • NEW gate: mode === "connect" with input.connection set requires an app caller for appSlug (401 app_auth_required). Previously a creator connect start was unauthenticated while a comment claimed otherwise. Comment fixed. Failing-first test: no secret 201 before the gate, 401 after; own secret 201; foreign or wrong secret 401; direct foreign caller 401.
  • docs/service-contract.md: launch hop, binding cookie, callback refusals, connect gate, Google secrets built at launch.

Tests

Typecheck tsc --noEmit: clean (0 errors). vitest run: 570 passed, 0 failed, 151 skipped (30 files, 28 passed, 2 skipped files). Existing Google link, link-binding and managed-credential tests were migrated to start -> launch -> callback through tests/oauth-flow.ts; tests that start a creator connect or a Bifrost flow now send the app secret, since those starts are now gated. REQUIRED_COLUMNS 101 and REQUIRED_KEYS 14 after merging pending_links and oauth_state_uses. No ygg-verify here, so no StrykerJS or mutation run. Vendored CultLib dist was borrowed from the deployed release for local runs, not committed. The 151 skipped are the suite's own skips (not changed here).

Deploy

Every client navigates to authorizationUrl unchanged, so no client change is needed. startRequiresAppCaller (set on Bifrost) needs Bifrost's app secret configured in prod. Existing in-flight states from before deploy have no launch; they are refused at the callback with state_not_bound. Not merged, not deployed.

🤖 Generated with Claude Code

Lands the r2 cut (`gamecult-chat:cut_spec:cut-heimdall-binds-completion-to-attempt.r2`) on main, plus one extra gate. ## What changes - Merges `gamecult-chat/heimdall-binds-completion` (h1: `startRequiresAppCaller` for Bifrost; r2: launch hop, signed per-flow `__Host-heimdall-oauth-<jti>` cookie, `claimOAuthStateUse` for phases launch and callback on the in-memory store and an INSERT-only `oauth_state_uses` table, callback expiry check, `authorizationUrl` is now the launch URL, `@fastify/cookie`). - Reconciled with main's #1 (link initiator binding, `initiator_account_id`, pending links) and #2 (Google OIDC): the OIDC nonce and PKCE secrets are still derived from the state jti and are now put on the provider URL at the launch hop. Both stores keep pending links and gain state-use claims. - NEW gate: `mode === "connect"` with `input.connection` set requires an app caller for `appSlug` (`401 app_auth_required`). Previously a creator connect start was unauthenticated while a comment claimed otherwise. Comment fixed. Failing-first test: no secret 201 before the gate, 401 after; own secret 201; foreign or wrong secret 401; direct foreign caller 401. - `docs/service-contract.md`: launch hop, binding cookie, callback refusals, connect gate, Google secrets built at launch. ## Tests Typecheck `tsc --noEmit`: clean (0 errors). `vitest run`: 570 passed, 0 failed, 151 skipped (30 files, 28 passed, 2 skipped files). Existing Google link, link-binding and managed-credential tests were migrated to start -> launch -> callback through `tests/oauth-flow.ts`; tests that start a creator connect or a Bifrost flow now send the app secret, since those starts are now gated. `REQUIRED_COLUMNS` 101 and `REQUIRED_KEYS` 14 after merging pending_links and oauth_state_uses. No ygg-verify here, so no StrykerJS or mutation run. Vendored CultLib dist was borrowed from the deployed release for local runs, not committed. The 151 skipped are the suite's own skips (not changed here). ## Deploy Every client navigates to `authorizationUrl` unchanged, so no client change is needed. `startRequiresAppCaller` (set on Bifrost) needs Bifrost's app secret configured in prod. Existing in-flight states from before deploy have no launch; they are refused at the callback with `state_not_bound`. Not merged, not deployed. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The replay of finding known-nonce-login-csrf at the HTTP surface, plus authenticated, cross-app and public-app cases.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Bifrost's browser binding is the secrecy of a nonce carried readable in the signed state, so only Bifrost may mint a Bifrost state. The gate sits in startOAuthFlow beside the entitlementPolicy caller check, so HTTP and the private command plane share it. Follows the red tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Kills the surviving mutant on the caller.appSlug comparison in startOAuthFlow: over HTTP a foreign secret never resolves to a caller, so only a direct call reaches that arm.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Launch hop, signed per-flow cookie and one-use claims are merged with main's
link-initiator binding and Google OIDC (secrets now derived at launch).
A connect start carrying a connection target now needs the app's own secret.
Google, link and managed-credential tests go through the launch helper.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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!3
No description provided.