Authorization Flow
What this is
The standard OAuth 2.1 / OIDC browser login: a client (e.g. the Alexa or Google Voice Assistant account-linking web view) redirects the user to GET /oauth2/authorize, the user logs in passwordlessly via OTP on a service-served login UI, and we hand the client back a single-use authorization code at its redirect_uri. The client then exchanges that code (with its PKCE verifier) at POST /oauth2/token for the token set.
This is the interactive counterpart to direct-token OTP: same OTP first factor, but the browser only ever carries an opaque flow id and the final code — never tokens or claims.
Alexa account-linking runs here: the voice platform is a browser authorization-code + PKCE client of this surface. The access token issued here is what an end user later exchanges for AWS/IoT credentials — see user_auth.md.
Why it is needed
Alexa/GVA account linking is a browser redirect flow: the voice platform opens our authorize URL, the user authenticates, and the platform receives a code at its registered redirect. It cannot use the native direct-token path (no app to hold a
flow_id, must be OAuth-standard).The authorization code + PKCE handshake keeps tokens out of the browser URL and binds the code to the client that started the flow (RFC 7636), which direct-token mode sidesteps.
Scope of this slice
In: GET /oauth2/authorize, a minimal service-served login UI, OTP as the first factor, single-use code issuance, and the authorization_code grant at POST /oauth2/token. First-party clients only.
Out (deferred): consent screen, SSO sessions / silent re-auth, social federation, per-client branding, custom-domain→issuer, and the full S3+CloudFront branded SPA. Those are deferred features and are not built here. Since first-party clients skip consent, the flow goes login → OTP → code with no consent step.
Pre-requisites
A registered client (admin-clients.md) with exact-match
redirect_uris,grant_typesincludingauthorization_code, andrequire_pkce=true(forced for public clients).OTP delivery configured (SES/SNS) and the OTP endpoints deployed (auth-flows.md).
The RS256 signing key and discovery/JWKS documents exist (oidc-oauth2.md).
Key Rules
PKCE-S256 is required per the client’s
require_pkcepolicy (forcedtruefor public clients — RFC 9700 §2.1.1; optional for a confidential client registered withrequire_pkce=false). When required and acode_challengeis absent,/oauth2/authorizereturnsinvalid_request(RFC 7636 §4.4.1); a challenge that is present MUST useS256. Downgrade protection at/oauth2/token(RFC 9700 §2.1.1): a code bound to a challenge MUST be redeemed with a matchingcode_verifier, and a code issued without one MUST be redeemed without a verifier.Exact
redirect_urimatch. Theredirect_urimust string-equal one of the client’s registered URIs — no wildcards, no prefix/substring. A mismatch is rejected on our error page, never redirected (an unvalidated redirect is an open redirector).The browser carries only the opaque flow id and the final code. Flow state (client, scopes, PKCE, redirect) lives in the espuser-auth-flows record; the browser holds the flow id in a short-lived
HttpOnlySecureSameSite=Laxcookie. (HttpOnly= unreadable by JavaScript, sodocument.cookiecan’t leak it;Secure= sent only over HTTPS;SameSite=Lax= not sent on cross-site requests, blunting CSRF.)The authorization code is single-use. Redemption deletes the flow record under an item-exists condition, so a concurrent or replayed second exchange fails atomically and is denied with
invalid_grant(RFC 6749 §4.1.2 / §10.5).The redeeming client must match the issuing client.
/oauth2/tokenchecks thatclient_idandredirect_urion the exchange equal those stamped on the code’s flow record; a mismatch isinvalid_grant.Codes are short-lived (TTL ≤ 60s recommended, ≤ 10 min hard cap) via the flow record’s
expires_onTTL.stateis echoed to the client’s redirect for CSRF protection.nonceis not accepted (see Open items).No tokens in URLs beyond the spec-mandated
codeon the authorization redirect.
The flow, end to end
sequenceDiagram
title Authorization code + PKCE (browser OTP login)
participant Client as "Client (Alexa/GVA web view)"
participant Browser
participant Authz as "/oauth2/authorize"
participant UI as "Login UI (service-served)"
participant OTP as "/v1/auth/otp/*"
participant Token as "/oauth2/token"
participant Flows as "espuser-auth-flows"
Client->>Browser: 302 to /oauth2/authorize (client_id, redirect_uri, scope, state, code_challenge S256)
Browser->>Authz: GET /oauth2/authorize
Authz->>Flows: Put LOGIN record (flow_id, client_id, scopes, PKCE, redirect_uri, state)
Authz-->>Browser: 302 to login UI (Set-Cookie: flow_id — HttpOnly)
Browser->>UI: GET /login (cookie: flow_id)
UI-->>Browser: Render email/phone form (reads flow record for client)
Browser->>OTP: POST /v1/auth/otp/initiate (username, flow_id)
OTP-->>Browser: code sent
Browser->>OTP: POST /v1/auth/otp/verify (flow_id, code)
OTP->>Flows: Resolve subject, stamp CODE on the flow record
OTP-->>Browser: { redirect_to: redirect_uri?code=...&state=... }
Browser->>Client: 302 redirect_uri?code=...&state=...
Client->>Token: POST /oauth2/token (grant_type=authorization_code, code, code_verifier, client_id, redirect_uri)
Token->>Flows: GetItem(code) — verify PKCE, client, redirect — consume (false→true)
Token-->>Client: access_token, refresh_token, id_token
GET /oauth2/authorize
Starts the flow. Validates the request, writes a LOGIN flow record, and redirects the browser to the login UI with the flow-id cookie set.
Query parameters:
Param |
Required |
Notes |
|---|---|---|
|
yes |
Must be |
|
yes |
Must be a registered client. |
|
yes |
Exact match against the client’s registered URIs. |
|
no |
Optional (RFC 6749 §4.1.1); space-delimited; include |
|
recommended |
Opaque; echoed back on the redirect (client CSRF token). |
|
conditional |
PKCE (RFC 7636). Required when the client is registered with |
|
conditional |
Must be |
Process:
Validate
response_type=codeand thatclient_idis a registered client. A structural failure here renders the service error page (we cannot safely redirect yet).Validate
redirect_uriis an exact registered URI for the client. Mismatch → error page (never redirect).PKCE: if the client’s
require_pkceis set, acode_challengeMUST be present (RFC 7636 §4.4.1, elseinvalid_request); if a challenge is present its method MUST beS256.Validate requested
scope⊆ client’s allowed scopes. An over-broad scope → redirect the client withinvalid_scope(redirect is now safe, the URI is validated).Write a
LOGINrecord to espuser-auth-flows: freshflow_id,client_id,requested_scope,code_challenge,code_challenge_method,redirect_uri,state, TTL’dexpires_on.Set the
flow_idin a short-livedHttpOnlySecureSameSite=Laxcookie and302to the login UI.
Errors — pre-redirect failures (steps 1–2) render the error page with an RFC 6749 §5.2 error body; post-validation failures (step 3+) redirect to redirect_uri?error=<code>&state=<state>.
Login UI (service-served)
A minimal page (or small SPA) served by the identity service from its own origin — no custom domain, no per-client branding, no CloudFront in this slice. It:
Reads the
flow_idcookie and loads the flow record to learn the client (display only; no secrets).Renders an email/phone field (passwordless — no password field anywhere).
On submit, calls
POST /v1/auth/otp/initiatewith the username and the flow id, then renders the OTP entry field.On code submit, calls
POST /v1/auth/otp/verifywith{ flow_id, code }. In-flow verify returns{ redirect_to }; the UI navigates the browser there, landing back at the client’sredirect_uriwith thecode.
The UI never mints a token, never sees signing keys, and never puts claims in the URL. Responses are Cache-Control: no-store.
The OTP
initiate/verify/resendcontracts and the in-flow vs direct-token split are owned by auth-flows.md; this spec does not redefine them. In-flowverifystamps the flow’ssubject, transitions theLOGINrecord toCODE(mints thecode), and returns{ redirect_to }.
POST /oauth2/token — authorization_code grant
Extends the existing token endpoint (which today serves refresh_token, auth-flows.md) with the authorization_code grant.
Client authentication (both grants, RFC 6749 §3.2.1 → §2.3.1): HTTP Basic only. A confidential client MUST present Authorization: Basic base64(client_id:secret) — a bad/missing secret is 401 invalid_client; a public client sends its client_id (in Basic or the form body) with no secret, and relies on PKCE (code) / rotation (refresh) instead. client_secret_post is not accepted (RFC 6749 §2.3.1 NOT RECOMMENDED). This mirrors /oauth2/revoke.
Request (form-encoded):
grant_type=authorization_code
code=<authorization code>
code_verifier=<PKCE verifier> (required iff the code was bound to a challenge)
client_id=<client>
redirect_uri=<same redirect_uri used at authorize>
Process:
Require
grant_type=authorization_code,code,client_id,redirect_uri. Missing/malformed →invalid_request. (code_verifieris conditional — see step 4.)Look up the
CODEflow record bycode. Unknown/expired/already-consumed →invalid_grant(a consumed code additionally burns the flow — reuse = theft).Verify
client_idandredirect_uriequal those stamped on the record; mismatch →invalid_grant.Verify PKCE:
BASE64URL(SHA256(code_verifier)) == code_challenge. Mismatch, or a verifier supplied for a code with no challenge (or vice-versa), →invalid_grant(RFC 9700 §2.1.1).Consume the code (conditional
consumed = false → true); a lost race →invalid_grant.Mint the token set for the record’s
subject+granted_scope: RS256 access token, a fresh refresh-token family, and an id_token whenopenidis in scope.
Response (200, application/json) — same shape as the refresh grant:
field |
example |
notes |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Errors (RFC 6749 §5.2 error object):
400 invalid_request— missing/malformedcode,code_verifier,client_id, orredirect_uri.400 invalid_grant— unknown/expired/consumed code, client or redirect mismatch, or PKCE failure (uniform; no oracle).400 unsupported_grant_type— agrant_typethis endpoint does not serve.
espuser-auth-flows
The flow record threading a request from /oauth2/authorize through OTP login to the issued code. New table; add to USER_TABLE_NAMES / USER_INDEX_NAMES. ManagedTable via the GSI orchestrator like the other espuser-* tables. TTL’d; holds no long-lived PII.
Keys: flow_id (PK), sk (SK).
Attribute |
Type |
Notes |
|---|---|---|
|
String |
Opaque; only ever in the short-lived session cookie, never a URL. |
|
String |
|
|
String |
Originating client. |
|
List |
From the authorize request. |
|
String |
Validated, exact-match. |
|
String |
Echoed on the redirect (client CSRF token). |
|
String |
PKCE ( |
|
String |
Resolved |
|
List |
Stamped at code issuance (= requested, first-party no-consent). |
|
String |
The single-use authorization code; minted when the record becomes |
|
Bool |
Single-use guard (conditional |
|
Number |
TTL (epoch seconds); code lifetime ≤ 60s recommended. |
Authorize writes the
LOGINrecord byflow_id.In-flow OTP verify (linked by
flow_id) stampssubject/granted_scope, mintscode, setssk=CODE.Token exchange finds the record by
code(aby-codeGSI), verifies, and consumes.
This slice keeps the OTP challenge in espuser-otp linked by
flow_id. Audiences (RFC 8707) and folding the OTP challenge onto this table — see Open items.
Standards reference
OAuth 2.1 / RFC 6749 §4.1 — authorization code grant.
RFC 7636 / RFC 9700 §2.1.1 — PKCE (
S256); required for public clients and any client withrequire_pkce, with token-endpoint downgrade protection.RFC 9700 (BCP 240) §2.1.1 — PKCE downgrade protection; exact redirect matching; no code replay.
OIDC Core 1.0 —
openidscope → id_token.
Open items
TODO:by-codeGSI vs deriving the record key from the code — finalize in the DB slice.TODO:login UI hosting: served inline by an authorize Lambda vs a small static bundle on the service origin (still no CloudFront/custom-domain in this slice).TODO:audit events (authorize,code_issued,code_redeemed) — same open question as the OTP audit log in auth-flows.md.TODO:rate-limit/oauth2/authorize(flow-record creation) per client + IP.TODO:nonce(OIDC replay protection) — accept it and inject it into the id_token together (OIDC Core §2 requires an acceptednonceto appear in the id_token).TODO:requested_audience/granted_audience(RFC 8707) on the flow record — omitted this slice.TODO:fold theOTPchallenge ontoespuser-auth-flowsinstead of a separateespuser-otprow (one flow record carrying its challenge).TODO:additional client-auth methods beyondclient_secret_basic:private_key_jwt/client_secret_jwt(RFC 7523, needs per-client JWKS) and mTLStls_client_auth(RFC 8705, needs cert distribution).TODO:per-clienttoken_endpoint_auth_methodis derived (public →none, confidential → basic), not stored/configurable; make it an explicit registered field when more methods land.TODO:secret rotation for confidential clients (two active secrets during rollover); today a client has a single secret.TODO:client_credentialsgrant (M2M) — a confidential client with no user; separate later slice, same Basic auth.TODO:on authorization-code reuse, revoke the tokens (refresh-token family) minted from that code (RFC 6749 §10.5 SHOULD) — today reuse is only denied (invalid_grant), not revoked. Requires recording the minted family id on the flow record; mirror the refresh-token reuse-detection path.