diff --git a/docs/core/graphql-api.md b/docs/core/graphql-api.md index f0fcd0e..512bce6 100644 --- a/docs/core/graphql-api.md +++ b/docs/core/graphql-api.md @@ -819,6 +819,8 @@ Completes an in-progress, token-withheld MFA first-time-setup offer by recording Either `email` or `phone_number` is required. Returns `AuthResponse` (same shape as [`verify_otp`](#verify_otp)). Fails with an error when MFA is org-enforced (`--enforce-mfa`) — enforcement is never skippable. +This call is authenticated by the MFA session, not a bearer token — none has been issued yet. The session arrives in a `Set-Cookie` header on the `signup`/`login` response and goes back in a `Cookie` header here, so **any HTTP client can complete the flow**, not only a browser. See [Completing the flow from a non-browser client](../core/security#completing-the-flow-from-a-non-browser-client). + ```graphql mutation { skip_mfa_setup(params: { email: "foo@bar.com" }) { diff --git a/docs/core/security.md b/docs/core/security.md index 0d5ead4..c44b894 100644 --- a/docs/core/security.md +++ b/docs/core/security.md @@ -485,11 +485,57 @@ In every withheld case the response carries no `access_token` — only a message and a set of `should_show_*` / `should_offer_*` flags on `AuthResponse` (`should_show_totp_screen`, `should_offer_webauthn_mfa_setup`, `should_offer_email_otp_mfa_setup`, `should_offer_sms_otp_mfa_setup`, -`should_offer_webauthn_mfa_verify`). The frontend authenticates the -follow-up call (`verify_otp`, `totp_mfa_setup`, `webauthn_registration_verify`, -`webauthn_login_verify`, or `skip_mfa_setup`) via a short-lived MFA session -cookie set alongside that response, not a bearer token — none has been -issued yet. `should_offer_mfa_setup` is deprecated and never set; ignore it. +`should_offer_webauthn_mfa_verify`). The follow-up call (`verify_otp`, +`totp_mfa_setup`, `webauthn_registration_verify`, `webauthn_login_verify`, or +`skip_mfa_setup`) is authenticated by a short-lived **MFA session** set alongside +that response, not by a bearer token — none has been issued yet. +`should_offer_mfa_setup` is deprecated and never set; ignore it. + +#### Completing the flow from a non-browser client + +The MFA session travels as a cookie, but it is **not browser-only**. It is an +ordinary `Set-Cookie` response header carrying an opaque handle, and the server +accepts it back in an ordinary `Cookie` request header — so any HTTP client can +complete the flow. A browser does it automatically; everything else reads one +value and sends it back. + +```bash +# 1. Sign up. The token is withheld; the handle arrives in Set-Cookie. +curl -i -X POST "$AUTHORIZER_URL/graphql" \ + -H 'Content-Type: application/json' -H "Origin: $AUTHORIZER_URL" \ + -d '{"query":"mutation { signup(params: {email: \"a@b.com\", password: \"Password@123\", confirm_password: \"Password@123\"}) { access_token message } }"}' + +# HTTP/1.1 200 OK +# Set-Cookie: mfa_session=e415fa93-f51e-4ee1-8568-bd7cf2e0fa67; ... +# {"data":{"signup":{"access_token":null,"message":"Proceed to mfa setup"}}} + +# 2. Echo it back. No cookie jar involved. +curl -X POST "$AUTHORIZER_URL/graphql" \ + -H 'Content-Type: application/json' -H "Origin: $AUTHORIZER_URL" \ + -H 'Cookie: mfa_session=e415fa93-f51e-4ee1-8568-bd7cf2e0fa67' \ + -d '{"query":"mutation { skip_mfa_setup(params: {email: \"a@b.com\"}) { access_token } }"}' +# -> access_token issued +``` + +Over **gRPC** the same handle is carried as metadata: read it from the +`set-cookie` response metadata, send it back as a `cookie` entry. + +:::caution Send it only to your own Authorizer +A browser's cookie jar scopes cookies to the origin that set them. If you carry +the handle by hand you take on that job: **attach it only to requests to your +configured Authorizer base URL, and never follow a redirect while it is +attached.** The handle authenticates a half-completed login — `skip_mfa_setup` +exchanges it for a full access token — so treat it exactly like a credential: +hold it in memory for the duration of the flow, never log it, never persist it. +::: + +You do not need a general-purpose cookie jar for this, and reaching for one has +been a reliable source of bugs: `Secure` cookies are dropped over +`http://localhost` by some HTTP stacks, and domain-matching rules vary between +languages. Reading `mfa_session` by name and echoing it back to one known origin +has none of those failure modes. The official +[Go](https://github.com/authorizerdev/authorizer-go) and +[Python](https://github.com/authorizerdev/authorizer-py) SDKs do this for you. A registered passkey satisfies MFA on its own — no OTP/TOTP re-challenge — because every WebAuthn assertion already requires user verification