Bind link mode to its initiator and redeem to its target #1

Open
heimdall wants to merge 3 commits from heimdall/link-initiator-target-binding into main
Member

Closes follow-up heimdall-body:follow_up:link-initiator-and-target-binding. Not deployed.

What changed

  1. Initiator. link start requires an app-authenticated caller and initiatingAccountId (known account); it rides in the signed state as initiator_account_id. Any other mode rejects the field. The callback refuses 403 link_initiator_mismatch when the provider identity already belongs to a different account, link_initiator_missing for a state with no initiator, link_initiator_unknown if the account is gone. An unlinked identity is attached to the initiator (before, link mode created a fresh account, so it could never link). Link does not overwrite the account's display name/email.
  2. Target. auth_completions.target_id (new nullable column, ALTER ... IF NOT EXISTS) stores connection.targetId. New store.findAuthCompletion (read, no consume). Redeem compares the body's targetId; absent or different gives 403 completion_target_mismatch and the code stays unconsumed.
  3. Redeem caller. A link completion now needs the app secret at redeem (401 app_auth_required, code not burned). Sign-in and connect completions stay unauthenticated.

Tests: tests/link-binding.test.ts (wrong initiator, stripped initiator, wrong/missing target, replay, unauthenticated redeem, row stores target). Full suite 479 passed, typecheck clean.

Not closed / needs a ruling

  • Target grants not checked. capability_grants are scoped global|app per account; nothing models a grant on a connection target, so "check the target's grants" has nothing to check. Needs a ruling on what grant a target holds.
  • Redeem is not app-bound for sign_in/connect. The browser flow redeems those, and the route cannot tell the browser from the app. Binding them means moving those flows to consumeAuthCompletionByAttempt (the authenticated private plane). Left as is.
  • Breaking for connect redeemers: any completion that carries a connection.targetId (StreamPixels creator connect) must now send targetId on redeem. managed-credential.test.ts updated accordingly; StreamPixels' redeem needs the matching change before deploy.
  • The app is trusted to name the initiating account; Heimdall does not verify the end user behind it.

Status: sign_in connection refusal (84c54fb)

A sign_in start with connection was previously accepted and stored targetId on the completion (:1305). It now refuses 400 connection_not_allowed_in_sign_in, pinned by tests/link-binding.test.ts ("sign_in start carries no connection target", with and without app auth). Full suite 481 passed, 151 skipped; tsc clean.

Contract (worded to the code at head 0f2e294; src/app.ts)

Start (startOAuthFlow, route at :902-906; checks run in this order)

Status Error Line When
404 unknown_app :510 no profile for appSlug
400 provider_not_supported_for_app :515 provider not admitted for the mode
400 connection_not_allowed_in_sign_in :524 (commit 84c54fb) sign_in start carrying connection (any caller; targets ride only on connect and link). Checked before creator_connection_required. Line numbers below are at 0f2e294 and shift by +9 after :522.
400 creator_connection_required :522 managed creator provider without connection.kind creator + targetId
401 app_auth_required :536 link start without a valid x-heimdall-app-secret for this app
400 link_initiator_required :539 link start without initiatingAccountId
400 link_initiator_unknown :542 initiatingAccountId names no account (existence only, see gap below)
400 initiator_only_in_link_mode :545 initiatingAccountId sent on any other mode
400 return_to_not_allowed / invalid_handoff / invalid_entitlement_policy / untrusted_backend_callback :550 / :560 / :572 / :581 unchanged by this PR
401 app_auth_required :592 non-link start that sends entitlementPolicy without the app secret
503 provider_not_configured :598 provider has no client id
201 n/a state token issued

x-heimdall-app-secret is required on link start (:536) and, on any other mode, only when entitlementPolicy is sent (:591). Body schema violations are Fastify's 400 before this code runs.

Callback (/v1/oauth/:provider/callback)

Status Error Line
400 missing_state / invalid_state / state_provider_mismatch :940 / :946 / :952
400 provider_error, missing_code :1005 (provider error), :1049 (code)
403 link_initiator_missing :1080, state carries no initiator
403 link_initiator_mismatch :1083, provider identity already belongs to another account
403 link_initiator_unknown :1088, initiator account gone since start
502 oauth_callback_failed :1422, any non-link failure (opaque)
201 n/a :1275 (backend_callback delivery) / :1351 (completion minted)

All three link_initiator_* refusals are thrown as LinkRefusal inside the try and answered 403 at :1422 with an oauth_link_refused audit event. Note link_initiator_unknown is 400 at start and 403 at callback. A backend_callback handoff delivers the session directly (:1275) and mints no completion, so redeem never sees it. The completion row stores targetId from statePayload.connection (:1305), when the start carried a connection.

Redeem (POST /v1/apps/:appSlug/auth-completions/redeem, :1458-1514; the checks only run for a live row: found, unconsumed, expiresAt > now)

Status Error Line When
401 app_auth_required :1467-1469 live link completion, no valid app secret. Code not consumed.
403 completion_target_mismatch :1471-1481 a target was stored at start and the body's targetId is absent or different. Code not consumed. Audit auth_completion_target_refused.
410 invalid_or_expired_completion_code :1485-1488 unknown, consumed or expired code, for any caller and any target. By design, so a code's existence is not probeable by status.
201 n/a :1514 completion payload; code consumed

Order is 401 then 403 then consume. 401 is only ever for a live link completion: an unknown or expired code is 410 even without the secret (by design).

Rules

  • targetId at redeem is supplied by the app from its own server-side attempt record, never from a URL parameter. Heimdall compares it to the target stored at start.
  • 403 completion_target_mismatch fires only when a target was stored at start (pending.targetId !== undefined, :1471). A completion started with no connection has no target and redeems without targetId.
  • x-heimdall-app-secret: required on link start and link redeem; on non-link start only with entitlementPolicy. Sign-in and connect redeem stay unauthenticated (open item b).
  • initiatingAccountId is the signed-in user's Heimdall account id, as named by the app.

Known gap (item d). initiatingAccountId is only checked to exist (:541-542). Heimdall does not verify that the end user behind the app is that account; the app is trusted behind the secret.

Expiry pin. tests/link-binding.test.ts:206: 410 at and after expiresAt for the right and a wrong target (not 403), code left unconsumed; honoured just before. Rerun at 0f2e294: tests/link-binding.test.ts 9 passed. I did not rerun the full suite for this edit; no code changed.

🤖 Generated with Claude Code

Closes follow-up `heimdall-body:follow_up:link-initiator-and-target-binding`. Not deployed. ## What changed 1. **Initiator.** `link` start requires an app-authenticated caller and `initiatingAccountId` (known account); it rides in the signed state as `initiator_account_id`. Any other mode rejects the field. The callback refuses `403 link_initiator_mismatch` when the provider identity already belongs to a different account, `link_initiator_missing` for a state with no initiator, `link_initiator_unknown` if the account is gone. An unlinked identity is attached to the initiator (before, link mode created a fresh account, so it could never link). Link does not overwrite the account's display name/email. 2. **Target.** `auth_completions.target_id` (new nullable column, `ALTER ... IF NOT EXISTS`) stores `connection.targetId`. New `store.findAuthCompletion` (read, no consume). Redeem compares the body's `targetId`; absent or different gives `403 completion_target_mismatch` and the code stays unconsumed. 3. **Redeem caller.** A `link` completion now needs the app secret at redeem (`401 app_auth_required`, code not burned). Sign-in and connect completions stay unauthenticated. Tests: `tests/link-binding.test.ts` (wrong initiator, stripped initiator, wrong/missing target, replay, unauthenticated redeem, row stores target). Full suite 479 passed, typecheck clean. ## Not closed / needs a ruling - **Target grants not checked.** `capability_grants` are scoped `global|app` per account; nothing models a grant on a connection target, so "check the target's grants" has nothing to check. Needs a ruling on what grant a target holds. - **Redeem is not app-bound for sign_in/connect.** The browser flow redeems those, and the route cannot tell the browser from the app. Binding them means moving those flows to `consumeAuthCompletionByAttempt` (the authenticated private plane). Left as is. - **Breaking for connect redeemers:** any completion that carries a `connection.targetId` (StreamPixels creator connect) must now send `targetId` on redeem. `managed-credential.test.ts` updated accordingly; StreamPixels' redeem needs the matching change before deploy. - The app is trusted to name the initiating account; Heimdall does not verify the end user behind it. ## Status: sign_in connection refusal (84c54fb) A sign_in start with `connection` was previously accepted and stored `targetId` on the completion (:1305). It now refuses `400 connection_not_allowed_in_sign_in`, pinned by `tests/link-binding.test.ts` ("sign_in start carries no connection target", with and without app auth). Full suite 481 passed, 151 skipped; tsc clean. ## Contract (worded to the code at head 0f2e294; `src/app.ts`) **Start** (`startOAuthFlow`, route at :902-906; checks run in this order) | Status | Error | Line | When | |---|---|---|---| | 404 | `unknown_app` | :510 | no profile for `appSlug` | | 400 | `provider_not_supported_for_app` | :515 | provider not admitted for the mode | | 400 | `connection_not_allowed_in_sign_in` | :524 (commit 84c54fb) | **sign_in** start carrying `connection` (any caller; targets ride only on connect and link). Checked before `creator_connection_required`. Line numbers below are at 0f2e294 and shift by +9 after :522. | | 400 | `creator_connection_required` | :522 | managed creator provider without `connection.kind creator` + `targetId` | | 401 | `app_auth_required` | :536 | **link** start without a valid `x-heimdall-app-secret` for this app | | 400 | `link_initiator_required` | :539 | link start without `initiatingAccountId` | | 400 | `link_initiator_unknown` | :542 | `initiatingAccountId` names no account (existence only, see gap below) | | 400 | `initiator_only_in_link_mode` | :545 | `initiatingAccountId` sent on any other mode | | 400 | `return_to_not_allowed` / `invalid_handoff` / `invalid_entitlement_policy` / `untrusted_backend_callback` | :550 / :560 / :572 / :581 | unchanged by this PR | | 401 | `app_auth_required` | :592 | **non-link** start that sends `entitlementPolicy` without the app secret | | 503 | `provider_not_configured` | :598 | provider has no client id | | 201 | n/a | | state token issued | `x-heimdall-app-secret` is required on link start (:536) and, on any other mode, only when `entitlementPolicy` is sent (:591). Body schema violations are Fastify's 400 before this code runs. **Callback** (`/v1/oauth/:provider/callback`) | Status | Error | Line | |---|---|---| | 400 | `missing_state` / `invalid_state` / `state_provider_mismatch` | :940 / :946 / :952 | | 400 | `provider_error`, `missing_code` | :1005 (provider error), :1049 (code) | | 403 | `link_initiator_missing` | :1080, state carries no initiator | | 403 | `link_initiator_mismatch` | :1083, provider identity already belongs to another account | | 403 | `link_initiator_unknown` | :1088, initiator account gone since start | | 502 | `oauth_callback_failed` | :1422, any non-link failure (opaque) | | 201 | n/a | :1275 (backend_callback delivery) / :1351 (completion minted) | All three `link_initiator_*` refusals are thrown as `LinkRefusal` inside the try and answered 403 at :1422 with an `oauth_link_refused` audit event. Note `link_initiator_unknown` is 400 at start and 403 at callback. A backend_callback handoff delivers the session directly (:1275) and mints no completion, so redeem never sees it. The completion row stores `targetId` from `statePayload.connection` (:1305), when the start carried a `connection`. **Redeem** (`POST /v1/apps/:appSlug/auth-completions/redeem`, :1458-1514; the checks only run for a live row: found, unconsumed, `expiresAt > now`) | Status | Error | Line | When | |---|---|---|---| | 401 | `app_auth_required` | :1467-1469 | live **link** completion, no valid app secret. Code not consumed. | | 403 | `completion_target_mismatch` | :1471-1481 | a target was stored at start and the body's `targetId` is absent or different. Code not consumed. Audit `auth_completion_target_refused`. | | 410 | `invalid_or_expired_completion_code` | :1485-1488 | unknown, consumed or expired code, for any caller and any target. By design, so a code's existence is not probeable by status. | | 201 | n/a | :1514 | completion payload; code consumed | Order is 401 then 403 then consume. 401 is only ever for a live link completion: an unknown or expired code is 410 even without the secret (by design). **Rules** - `targetId` at redeem is supplied by the app from its own server-side attempt record, never from a URL parameter. Heimdall compares it to the target stored at start. - 403 `completion_target_mismatch` fires only when a target was stored at start (`pending.targetId !== undefined`, :1471). A completion started with no `connection` has no target and redeems without `targetId`. - `x-heimdall-app-secret`: required on link start and link redeem; on non-link start only with `entitlementPolicy`. Sign-in and connect redeem stay unauthenticated (open item b). - `initiatingAccountId` is the signed-in user's Heimdall account id, as named by the app. **Known gap (item d).** `initiatingAccountId` is only checked to exist (:541-542). Heimdall does not verify that the end user behind the app is that account; the app is trusted behind the secret. **Expiry pin.** `tests/link-binding.test.ts:206`: 410 at and after `expiresAt` for the right and a wrong target (not 403), code left unconsumed; honoured just before. Rerun at 0f2e294: `tests/link-binding.test.ts` 9 passed. I did not rerun the full suite for this edit; no code changed. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Link start needs an authenticated app and an initiatingAccountId, carried in
the signed state. The callback refuses an identity held by another account
(link_initiator_mismatch) and attaches an unlinked one to the initiator.
auth_completions stores target_id; redeem compares it and answers
completion_target_mismatch without consuming the code. Link completions are
redeemed only by the authenticated app.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Connection targets ride only on connect and link starts, which carry
x-heimdall-app-secret. A sign_in start is open to any browser; with a
connection it now returns 400 connection_not_allowed_in_sign_in.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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/link-initiator-target-binding:heimdall/link-initiator-target-binding
git switch heimdall/link-initiator-target-binding

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/link-initiator-target-binding
git switch heimdall/link-initiator-target-binding
git rebase main
git switch main
git merge --ff-only heimdall/link-initiator-target-binding
git switch heimdall/link-initiator-target-binding
git rebase main
git switch main
git merge --no-ff heimdall/link-initiator-target-binding
git switch main
git merge --squash heimdall/link-initiator-target-binding
git switch main
git merge --ff-only heimdall/link-initiator-target-binding
git switch main
git merge heimdall/link-initiator-target-binding
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!1
No description provided.