fix: Bind consent to the server-side validated authorization request #72

Merged
rosa merged 1 commit from fix/consent-bound-to-validated-request into main 2026-07-03 05:09:35 +00:00
Owner

Fixes #48.

A tampered POST /auth/consent could widen the OAuth grant and smuggle an unvalidated PKCE challenge: the consent form's hidden fields were trusted to rebuild the request, so the domain's scope clamp filtered selected against a requested set the form itself supplied, and the challenge entered through CodeChallenge::from_storage, skipping S256 validation.

Changes

  • GET /auth parks the validated AuthorizationRequest in the session under an opaque UUID. This replaces the raw-query parking across the login redirect too, so there is a single mechanism for "an authorization request in flight": after login the user is bounced to a bare /auth, which resumes the parked request.
  • The consent form carries only the opaque request_id, the scope checkboxes, and the approve/deny action. The hidden client_id, redirect_uri, state, code_challenge, and requested_scope fields are gone from the template and ConsentForm.
  • POST /auth/consent consumes the parked request before anything else (a decision is final and cannot be replayed), rejects a missing or mismatched id with a plain 400 — never redirecting to a client-supplied URI — and re-derives the entire grant from the stored request.
  • AuthorizationConsent now has private fields and is only constructible via approve(request, selected) / deny(request), so complete_authorization clamps against a requested set the server validated.
  • The PKCE challenge only ever enters through CodeChallenge::parse; from_storage's docs now pin it to repository rehydration, its sole remaining caller (it stays pub because the repository lives in the infra crate).
  • AuthorizationRequest and its component types gained serde derives so the web layer can park them in the session, with docs noting that this path — like from_storage — must never see user input.

Acceptance criteria

  • A consent POST whose selected scopes exceed the validated request is clamped — tested at the web-handler level through token redemption (indieauth_consent_cannot_widen_the_grant_beyond_the_validated_request), plus a domain-level clamp test.
  • A consent POST cannot influence the code_challenge; smuggled form fields are ignored and the code stays bound to the validated challenge (indieauth_consent_form_cannot_influence_the_code_challenge).
  • An unknown, missing, or already-consumed request id is rejected with a 400 and no redirect (indieauth_consent_with_an_unknown_request_id_is_rejected).
  • A consent decision consumes the stored request; replaying the same POST fails (indieauth_consent_cannot_be_replayed).
  • The happy path works end-to-end, including the park-across-login flow which now completes through code redemption (indieauth_unauthenticated_authorization_returns_to_consent_after_login).
  • The ci mise task passes.
Fixes #48. A tampered `POST /auth/consent` could widen the OAuth grant and smuggle an unvalidated PKCE challenge: the consent form's hidden fields were trusted to rebuild the request, so the domain's scope clamp filtered `selected` against a `requested` set the form itself supplied, and the challenge entered through `CodeChallenge::from_storage`, skipping S256 validation. ## Changes - `GET /auth` parks the **validated** `AuthorizationRequest` in the session under an opaque UUID. This replaces the raw-query parking across the login redirect too, so there is a single mechanism for "an authorization request in flight": after login the user is bounced to a bare `/auth`, which resumes the parked request. - The consent form carries only the opaque `request_id`, the scope checkboxes, and the approve/deny action. The hidden `client_id`, `redirect_uri`, `state`, `code_challenge`, and `requested_scope` fields are gone from the template and `ConsentForm`. - `POST /auth/consent` consumes the parked request before anything else (a decision is final and cannot be replayed), rejects a missing or mismatched id with a plain 400 — never redirecting to a client-supplied URI — and re-derives the entire grant from the stored request. - `AuthorizationConsent` now has private fields and is only constructible via `approve(request, selected)` / `deny(request)`, so `complete_authorization` clamps against a `requested` set the server validated. - The PKCE challenge only ever enters through `CodeChallenge::parse`; `from_storage`'s docs now pin it to repository rehydration, its sole remaining caller (it stays `pub` because the repository lives in the `infra` crate). - `AuthorizationRequest` and its component types gained serde derives so the web layer can park them in the session, with docs noting that this path — like `from_storage` — must never see user input. ## Acceptance criteria - [x] A consent POST whose selected scopes exceed the validated request is clamped — tested at the web-handler level through token redemption (`indieauth_consent_cannot_widen_the_grant_beyond_the_validated_request`), plus a domain-level clamp test. - [x] A consent POST cannot influence the `code_challenge`; smuggled form fields are ignored and the code stays bound to the validated challenge (`indieauth_consent_form_cannot_influence_the_code_challenge`). - [x] An unknown, missing, or already-consumed request id is rejected with a 400 and no redirect (`indieauth_consent_with_an_unknown_request_id_is_rejected`). - [x] A consent decision consumes the stored request; replaying the same POST fails (`indieauth_consent_cannot_be_replayed`). - [x] The happy path works end-to-end, including the park-across-login flow which now completes through code redemption (`indieauth_unauthenticated_authorization_returns_to_consent_after_login`). - [x] The `ci` mise task passes.
fix: Bind consent to the server-side validated authorization request
All checks were successful
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
3c24dad69a
A tampered POST /auth/consent could widen the OAuth grant and smuggle an
unvalidated PKCE challenge, because the consent form's hidden fields were
trusted to rebuild the request: the domain clamp filtered the selected
scopes against a `requested` set that was itself attacker-controlled, and
the challenge entered through `CodeChallenge::from_storage`, skipping
S256 validation.

GET /auth now parks the validated AuthorizationRequest in the session
under an opaque id, replacing the raw-query parking that carried the
request across the login redirect. The consent form round-trips only that
id, the scope checkboxes, and the approve/deny action; POST /auth/consent
consumes the parked request (so a decision cannot be replayed) and
re-derives the client, redirect URI, state, PKCE challenge, and requested
scopes from it. A consent whose id does not resolve is rejected without
redirecting to any client-supplied URI.

AuthorizationConsent is now constructible only from a validated
AuthorizationRequest plus the user's selection and decision, so the
domain clamp operates on trustworthy input, and the only remaining caller
of CodeChallenge::from_storage is repository rehydration.

Closes #48
rosa merged commit e2ab50bcf0 into main 2026-07-03 05:09:35 +00:00
rosa deleted branch fix/consent-bound-to-validated-request 2026-07-03 05:09:35 +00:00
Sign in to join this conversation.
No description provided.