diff --git a/docs/architecture.md b/docs/architecture.md index b91b4bd..f816a14 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -617,8 +617,9 @@ Only the browser-driven OAuth flow: | Path | Why it must be public | |---|---| -| `/v1/oauth/{provider}/start` | the user's browser goes here | -| `/v1/oauth/{provider}/callback` | the provider redirects here; one-shot and not retryable | +| `/v1/oauth/{provider}/start` | the app's server calls it, with its app secret where the profile requires one | +| `/v1/oauth/{provider}/launch` | the user's browser goes here first; it sets the binding cookie and redirects to the provider, once per state | +| `/v1/oauth/{provider}/callback` | the provider redirects here; one-shot: it needs the binding cookie and consumes the state | | `/.well-known/jwks.json` | *nothing external consumes it today* — see below | Everything else is internal by the design already written down: diff --git a/docs/service-contract.md b/docs/service-contract.md index 249c965..13b1374 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -149,12 +149,29 @@ Response: "appSlug": "repixelizer", "mode": "sign_in", "callbackUrl": "https://heimdall.gamecult.org/v1/oauth/discord/callback", - "authorizationUrl": "https://discord.com/oauth2/authorize?...", - "stateToken": "", + "authorizationUrl": "https://heimdall.gamecult.org/v1/oauth/discord/launch?state=", "stateExpiresAt": "2026-04-26T12:10:00.000Z" } ``` +Send the user's browser to `authorizationUrl` unchanged. It is Heimdall's launch +hop, not the provider's URL. + +### `GET /v1/oauth/{provider}/launch?state=` + +The first thing the user's browser does with a state. Heimdall verifies the +state (signature, issuer, provider, `nbf`/`exp` with 30 s of skew), claims the +state's single launch, sets the binding cookie +`__Host-heimdall-oauth-` (`Path=/`, `Secure`, `HttpOnly`, `SameSite=Lax`, +`Max-Age` until the state expires; the value is a Heimdall-signed binding for +that state) and answers `302` to the provider's authorize URL, with +`Cache-Control: no-store` and `Referrer-Policy: no-referrer`. A second launch of +the same state is `400 state_already_launched` and sets no cookie. The state is +refused with `400 invalid_state`, `state_provider_mismatch` or `state_expired`. + +The binding cookie needs an `https` public base URL (or `localhost`). A browser +must finish the flow in the browser that launched it. + Failure modes: - `503 provider_not_configured` when the provider client id is missing @@ -194,7 +211,9 @@ Purpose: Current status: -- validates signed `state` +- validates signed `state` (signature, issuer, provider, expiry), requires the + binding cookie the launch hop set, and consumes the state: a second callback + is `400 state_already_used`, a missing or foreign cookie `400 state_not_bound` - returns provider errors cleanly - for Discord, exchanges the authorization code, resolves the provider identity, persists the local account/link/session/audit records, evaluates