Opt-in sign_in step-up (force_verify / prompt=login), app-authenticated, signed-state flag #11

Merged
heimdall merged 2 commits from heimdall/signin-step-up into main 2026-11-12 23:22:19 +00:00
Member

Closes follow-up joystick:follow_up:chat-s7055-7056. Not deployed, not merged. Context: /opt/commons/handoff/streampixels-link-hardening-map.md and heimdall-answers-streampixels-link-20261112.md (second factor: a fresh sign-in through an identity already on the target).

What changed

  • POST /v1/oauth/{provider}/start accepts stepUp: true, sign_in only, off by default (false equals absent).
  • App-authenticated: no secret, or another app's, is 401 app_auth_required. link or connect with stepUp is 400 step_up_only_in_sign_in.
  • Signed state: the flag rides as step_up in the state JWT (src/app.ts build and parse; a non-true value fails the parse). The launch hop reads it from the state only. A stepUp in a redeem body is ignored; the completion reports stepUpRequested: true only when the start asked.
  • Provider parameters (stepUpAuthorizationParams, src/providers.ts): Twitch force_verify=true; YouTube prompt=login consent; Google prompt=login. Any other provider is 400 step_up_not_supported at start, and buildAuthorizationUrl throws instead of omitting the step.
  • Docs: docs/service-contract.md (start section). Changelog: the repo has no changelog file; the line is an evidence record in state/evidence.jsonl.
  • Tests: tests/step-up.test.ts (StreamPixels start with and without step-up for twitch and youtube, Google via Bifrost, 401 without secret, refusal for discord, mode refusal, state-only flag). One pinned count in tests/caller-identity-attack-soul-r21.test.ts goes from 3 to 4 guarded caller.appSlug reads because the new gate adds one. Full suite 652 passed, typecheck clean.

Limits, stated

  • Strengths differ. Google prompt=login forces re-authentication. Twitch force_verify re-shows the authorize page: it proves someone at the browser chose to continue, not that credentials were entered. The doc says a consumer that needs credentials must not rely on Twitch step-up.
  • Heimdall requests the step and cannot observe that the provider honoured it: stepUpRequested means "asked".
  • No behaviour changes unless a start sets stepUp.

For Soul to falsify

  1. A start without the app secret (none, wrong, another app's) never signs a state with step_up, in every mode and provider, including through the private command plane's beginAuth.
  2. step_up is read from the verified state only: a hand-built state with step_up as a string or false is rejected or ignored; nothing on callback or redeem input changes the provider URL or stepUpRequested.
  3. For every provider in the catalog, stepUp either adds its parameter or is refused at start; no path yields an authorize URL without the step for a step-up state (including a state signed before this change, which has no step_up).
  4. The new caller.appSlug read really is guarded; check the R21.2 count edit is the only test change and not a loosening.
  5. Google's prompt=login and YouTube's login consent override the existing select_account/consent values, not append a second prompt.

🤖 Generated with Claude Code

Closes follow-up `joystick:follow_up:chat-s7055-7056`. Not deployed, not merged. Context: `/opt/commons/handoff/streampixels-link-hardening-map.md` and `heimdall-answers-streampixels-link-20261112.md` (second factor: a fresh sign-in through an identity already on the target). ## What changed - `POST /v1/oauth/{provider}/start` accepts `stepUp: true`, **sign_in only, off by default** (`false` equals absent). - **App-authenticated:** no secret, or another app's, is `401 app_auth_required`. `link` or `connect` with `stepUp` is `400 step_up_only_in_sign_in`. - **Signed state:** the flag rides as `step_up` in the state JWT (`src/app.ts` build and parse; a non-`true` value fails the parse). The launch hop reads it from the state only. A `stepUp` in a redeem body is ignored; the completion reports `stepUpRequested: true` only when the start asked. - **Provider parameters** (`stepUpAuthorizationParams`, `src/providers.ts`): Twitch `force_verify=true`; YouTube `prompt=login consent`; Google `prompt=login`. Any other provider is `400 step_up_not_supported` at start, and `buildAuthorizationUrl` throws instead of omitting the step. - Docs: `docs/service-contract.md` (start section). Changelog: the repo has no changelog file; the line is an evidence record in `state/evidence.jsonl`. - Tests: `tests/step-up.test.ts` (StreamPixels start with and without step-up for twitch and youtube, Google via Bifrost, 401 without secret, refusal for discord, mode refusal, state-only flag). One pinned count in `tests/caller-identity-attack-soul-r21.test.ts` goes from 3 to 4 guarded `caller.appSlug` reads because the new gate adds one. Full suite 652 passed, typecheck clean. ## Limits, stated - **Strengths differ.** Google `prompt=login` forces re-authentication. Twitch `force_verify` re-shows the authorize page: it proves someone at the browser chose to continue, not that credentials were entered. The doc says a consumer that needs credentials must not rely on Twitch step-up. - Heimdall requests the step and cannot observe that the provider honoured it: `stepUpRequested` means "asked". - No behaviour changes unless a start sets `stepUp`. ## For Soul to falsify 1. A start without the app secret (none, wrong, another app's) never signs a state with `step_up`, in every mode and provider, including through the private command plane's `beginAuth`. 2. `step_up` is read from the verified state only: a hand-built state with `step_up` as a string or `false` is rejected or ignored; nothing on callback or redeem input changes the provider URL or `stepUpRequested`. 3. For every provider in the catalog, `stepUp` either adds its parameter or is refused at start; no path yields an authorize URL without the step for a step-up state (including a state signed before this change, which has no `step_up`). 4. The new `caller.appSlug` read really is guarded; check the R21.2 count edit is the only test change and not a loosening. 5. Google's `prompt=login` and YouTube's `login consent` override the existing `select_account`/`consent` values, not append a second `prompt`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
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!11
No description provided.