Bind link mode to its initiator and redeem to its target #1
Loading…
Reference in a new issue
No description provided.
Delete branch "heimdall/link-initiator-target-binding"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes follow-up
heimdall-body:follow_up:link-initiator-and-target-binding. Not deployed.What changed
linkstart requires an app-authenticated caller andinitiatingAccountId(known account); it rides in the signed state asinitiator_account_id. Any other mode rejects the field. The callback refuses403 link_initiator_mismatchwhen the provider identity already belongs to a different account,link_initiator_missingfor a state with no initiator,link_initiator_unknownif 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.auth_completions.target_id(new nullable column,ALTER ... IF NOT EXISTS) storesconnection.targetId. Newstore.findAuthCompletion(read, no consume). Redeem compares the body'stargetId; absent or different gives403 completion_target_mismatchand the code stays unconsumed.linkcompletion 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
capability_grantsare scopedglobal|appper 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.consumeAuthCompletionByAttempt(the authenticated private plane). Left as is.connection.targetId(StreamPixels creator connect) must now sendtargetIdon redeem.managed-credential.test.tsupdated accordingly; StreamPixels' redeem needs the matching change before deploy.Status: sign_in connection refusal (
84c54fb)A sign_in start with
connectionwas previously accepted and storedtargetIdon the completion (:1305). It now refuses400 connection_not_allowed_in_sign_in, pinned bytests/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)unknown_appappSlugprovider_not_supported_for_appconnection_not_allowed_in_sign_in84c54fb)connection(any caller; targets ride only on connect and link). Checked beforecreator_connection_required. Line numbers below are at0f2e294and shift by +9 after :522.creator_connection_requiredconnection.kind creator+targetIdapp_auth_requiredx-heimdall-app-secretfor this applink_initiator_requiredinitiatingAccountIdlink_initiator_unknowninitiatingAccountIdnames no account (existence only, see gap below)initiator_only_in_link_modeinitiatingAccountIdsent on any other modereturn_to_not_allowed/invalid_handoff/invalid_entitlement_policy/untrusted_backend_callbackapp_auth_requiredentitlementPolicywithout the app secretprovider_not_configuredx-heimdall-app-secretis required on link start (:536) and, on any other mode, only whenentitlementPolicyis sent (:591). Body schema violations are Fastify's 400 before this code runs.Callback (
/v1/oauth/:provider/callback)missing_state/invalid_state/state_provider_mismatchprovider_error,missing_codelink_initiator_missinglink_initiator_mismatchlink_initiator_unknownoauth_callback_failedAll three
link_initiator_*refusals are thrown asLinkRefusalinside the try and answered 403 at :1422 with anoauth_link_refusedaudit event. Notelink_initiator_unknownis 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 storestargetIdfromstatePayload.connection(:1305), when the start carried aconnection.Redeem (
POST /v1/apps/:appSlug/auth-completions/redeem, :1458-1514; the checks only run for a live row: found, unconsumed,expiresAt > now)app_auth_requiredcompletion_target_mismatchtargetIdis absent or different. Code not consumed. Auditauth_completion_target_refused.invalid_or_expired_completion_codeOrder 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
targetIdat 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.completion_target_mismatchfires only when a target was stored at start (pending.targetId !== undefined, :1471). A completion started with noconnectionhas no target and redeems withouttargetId.x-heimdall-app-secret: required on link start and link redeem; on non-link start only withentitlementPolicy. Sign-in and connect redeem stay unauthenticated (open item b).initiatingAccountIdis the signed-in user's Heimdall account id, as named by the app.Known gap (item d).
initiatingAccountIdis 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 afterexpiresAtfor the right and a wrong target (not 403), code left unconsumed; honoured just before. Rerun at0f2e294:tests/link-binding.test.ts9 passed. I did not rerun the full suite for this edit; no code changed.🤖 Generated with Claude Code
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.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.