Brokered Federation
The federation callback is also where a single-sign-on session is established — see sso-sessions.md.
Implements the brokered-federation design (the Dex “normalize and reissue” broker) for the first concrete upstream provider, AWS Cognito. This spec is the authoritative description of what is built;
What it is
ESP User is its own OIDC issuer. An upstream identity provider (here, a Cognito
user pool with its hosted UI) authenticates the human, but the downstream client
never sees the upstream’s tokens. ESP User brokers the upstream login, reads
the verified identity, resolves it to our user_id, and mints our access /
id / refresh tokens. To the client, ESP User is the only issuer, and refresh is
100% ours — the upstream is never contacted again after the callback.
This is the user path only. Admins authenticate against Cognito directly as a separate issuer with no brokering — users carry the ESP User issuer, admins carry Cognito’s native issuer, and clients serving both validate both (“two issuers”).
Why brokered, not pass-through (security)
The client’s downstream leg is unchanged: our /oauth2/authorize validates our
client, redirect_uri, PKCE and state, and issues our code bound to our
flow record. Federation adds a second, independent OAuth leg where ESP User is a
confidential client of the upstream. The two legs never share PKCE/state.
We deliberately do not replay the client’s own PKCE/state against the upstream (the way the MCP OAuth proxy does). That pass-through is safe only because the MCP proxy re-exposes the upstream token unchanged — there is one authorization server and one PKCE binding. Here we mint our own tokens, so a replay would:
verify PKCE at the upstream, not at the token-minting server — our
/oauth2/tokenwould bind the code to nothing it proved possession of;collapse every downstream client into one upstream client, destroying the client↔code binding and enabling cross-client code injection (RFC 6749 §4.1.3, RFC 9700 §4.5);
leak the client’s
stateto a third party and couple our protocol surface to the upstream’s PKCE/state quirks, breaking the multi-provider goal.
RFC 8693 token-exchange (client integrates the upstream itself, then swaps the
upstream token at our /oauth2/token) is not the base flow — it exposes
upstream tokens to clients and voice assistants cannot use it. It is reserved as
a future additive grant for native apps (RFC 8693 token exchange: the app presents
an upstream token at our token endpoint and receives OUR tokens).
The flow, end to end
sequenceDiagram
title Brokered federation (authorization code + PKCE, both legs)
participant Client as "Client (app / VA web view)"
participant Browser
participant Authz as "/oauth2/authorize"
participant Start as "/oauth2/federation/start"
participant IdP as "Upstream IdP (Cognito hosted UI)"
participant CB as "/oauth2/federation/callback"
participant Token as "/oauth2/token"
participant Flows as "espuser-auth-flows"
participant Users as "espuser-user-details"
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 PKCE/state/redirect)
Authz-->>Browser: 302 to federation/start?provider=cognito (Set-Cookie: __Host-esp_flow_id) — single-provider auto-redirect
Browser->>Start: GET /oauth2/federation/start?provider=cognito (cookie: __Host-esp_flow_id)
Start->>Flows: Store UPSTREAM leg (our state=HMAC(flow_id), our nonce, our PKCE verifier)
Start-->>Browser: 302 to IdP authorize (broker client_id, OUR state/nonce/challenge)
Browser->>IdP: hosted-UI login (user authenticates upstream)
IdP-->>Browser: 302 to federation/callback?code=&state=
Browser->>CB: GET /oauth2/federation/callback?code=&state= (cookie: __Host-esp_flow_id)
Note over CB: the cookie must name the flow in state, else 400 before any read
CB->>Flows: Recover flow by HMAC state — exact-match stored state
CB->>IdP: POST token endpoint (broker secret + OUR PKCE verifier) — server side
IdP-->>CB: upstream id_token (validated: iss/aud/nonce/exp/signature)
CB->>Users: resolve by verified contacts — reuse, or create (under a trusted upstream's id), storing profile claims
CB->>Flows: Bind subject, issue OUR single-use code
CB-->>Browser: 302 redirect_uri?code=...&state=... (client's own state)
Browser->>Client: delivers OUR code
Client->>Token: POST /oauth2/token (code, code_verifier, client_id, redirect_uri)
Token->>Flows: verify client/redirect/PKCE — consume code (single use)
Token-->>Client: OUR access/refresh/id tokens (profile claims scope-gated)
Note over CB,IdP: Upstream tokens never leave the server and are discarded — refresh is 100% ours.
The two legs never mix: the client’s state/PKCE ride only the downstream leg;
the broker mints its own state (HMAC over the flow id), nonce, and PKCE
verifier for the upstream leg.
Endpoints
Both live on the authorize lambda (same EspUserApi), auth type NONE.
GET /oauth2/federation/start
Begins the upstream leg for an in-flight authorization flow.
Input:
provider(query) + the__Host-esp_flow_idHttpOnly cookie set by/oauth2/authorize.Process: resolve a live
LOGINflow by the cookie; load the provider fromespuser-identity-providers(must beenabled); generate a fresh upstreamstate,nonce, and PKCEcode_verifier/S256code_challenge; persist them on the flow record; build the upstream authorize URL (our brokerclient_id, provider scopes, our callbackredirect_uri,state,nonce,code_challenge).Output:
302to the providerauthorization_endpoint.
State is flow_id + an HMAC tag (keyed by a domain-separated derivation of the refresh-token secret) so the callback can trust it without a new GSI, and it is also compared to the value stored on the flow. That proves we issued it and nobody altered it, and defends against issuer mix-up (RFC 9207). It does not say which browser is returning: the __Host-esp_flow_id cookie set at /oauth2/authorize does, so a callback URL copied from someone else’s sign-in fails in every browser but the one that started the flow (RFC 6749 §10.12).
GET /oauth2/federation/callback
Completes the upstream leg and issues our code.
Input:
code,state(+ optionaliss), and the__Host-esp_flow_idcookie.Process:
Verify the
stateHMAC. The__Host-esp_flow_idcookie must name the same flow, else400with no cookie set and no flow read, so an attacker’s own sign-in cannot be finished in a victim’s browser (RFC 6749 §10.12). Load the flow; confirm it matches the flow’s stored upstreamstate. Reject on any mismatch (CSRF/replay). Ifissis present, assert it equals the provider’s issuer (RFC 9207).Exchange
codeat the providertoken_endpointserver-side, authenticating as our confidential broker client (secret) and sending the storedcode_verifier(PKCE).Verify the upstream
id_token: RS256 signature against the provider JWKS,iss== provider issuer,aud== our brokerclient_id,nonce== the flow’s stored nonce, andexp.Normalize claims to an
Identity. A contact the upstream did not mark verified is dropped, and an identity with nothing verified does not resolve to an account (rendered as a non-enumerating error).Identity resolution against every verified contact, described below. The upstream refresh token is discarded — never persisted.
Bind the resolved subject onto the flow (conditional on
subjectempty), then issue our authorization code viaCompleteAuthFlowForSubject.
Output:
302to the clientredirect_uri?code=…&state=…. The client then redeems the code at/oauth2/tokenfor our tokens, exactly as any login.
Identity resolution
A login can vouch for an email, a phone, or both. Either contact finds the account, and a contact the account did not yet have is recorded on it. So one person stays one user however they sign in next, and verifying a second contact upstream later does not strand them in a fresh empty account.
Situation |
Outcome |
|---|---|
Neither contact known |
New account: under the claim the provider row maps to |
Neither contact known, and the mapped |
Refused with |
One contact known |
That account is reused; the other contact is recorded on it |
Both contacts known, same account |
That account is reused |
Both contacts known, different accounts |
Refused. Choosing one would either strand the other’s data or attach this login to the wrong account, so the login fails and an operator decides |
Nothing verified |
Refused |
Recording a contact is conditional on the account not already holding one, so a login can never overwrite another user’s email or phone.
Attribute mapping
A provider row’s attribute_mapping names, for each of OUR keys, the upstream claim to read; a key it
leaves out reads the default claim. Only these keys are ever read, so a mapping can redirect a claim but
cannot widen what we ingest. Federation and the password proxy read claims the
same way.
Our key |
Default claim |
Used for |
|---|---|---|
|
same |
Identity resolution, by verified contact |
|
|
Identity resolution, by verified contact |
|
|
Stored on the account as |
|
same |
Stored on the account |
|
same |
Read, not stored |
|
same |
Session metadata |
|
none |
When a row maps it (e.g. |
What trust rests on. Mapping sub trusts the claim: ESP User cannot tell a real id from one a user
wrote into their own upstream record, so a pool user who can write the mapped claim can open an
account under the id of anyone who has not yet signed in. Before mapping sub, every app client on
that pool must set WriteAttributes without the mapped claim (an unset list makes every attribute
writable), and our broker client must have it in ReadAttributes, or every login mints.
test/itest/test_tenant_identity_security.py checks the first on every pool a row maps sub for. The
seeded cognito row maps no sub, and keeps one an operator adds across redeploys.
Provider registry
DynamoDB table espuser-identity-providers, PK provider_name. Each row is
self-describing: everything the broker needs lives on the row, with anything
omitted resolved from the issuer’s OIDC discovery document
({issuer}/.well-known/openid-configuration, RFC 8414, cached per container).
Seeded at deploy with the Cognito provider (refreshing put — the pool id/client
can change on pool replacement). Fields:
field |
meaning |
|---|---|
|
stable id, e.g. |
|
|
|
drives selection above |
|
chooser label |
|
upstream OIDC issuer (discovery base + id_token |
|
our broker client id at the upstream |
|
the app client secret |
|
space-separated upstream scopes (default |
|
|
|
OUR claim name → upstream claim name; see Attribute mapping |
|
where a login begins: the upstream authorize endpoint, or the hosted page for an |
|
optional pins; absent → OIDC discovery |
|
optional URL pin for the upstream JWKS; absent → fetched from discovery |
The enterprise OTP addon writes its own otp row at install, so the core needs no
redeploy to gain a provider. Future admin CRUD for providers lands on this table.
Flow-record additions
espuser-auth-flows gains (all omitempty): provider, upstream_state,
upstream_nonce, upstream_pkce_verifier. Written by federation/start, read +
cleared by federation/callback. No new GSI — the callback loads by flow id
recovered from the HMAC state.
Cognito as the first provider (CDK)
Re-add the
ESP-UsersCognito user pool + hosted-UI domain (settings copied from the pre-OIDCmain; helpers inapp_common.py).One confidential broker client
espuser-idp-broker(auth-code grant, scopesopenid email phone, callback = our/oauth2/federation/callback,generate_secret). Nova-client/user-pool-clienton the pool — those ids live in our own client registry.Fetch the pool JWKS into SSM (reuse the admin-pool
jwks_fetcher_lambdapattern) for theid_tokensignature check.Seed the
cognitoprovider row.
MCP is unaffected: the proxy still fronts our issuer and validates our tokens.
Deferred (tracked)
Provider chooser page (≥2 providers) and per-client
allowed_providersgating.Native RFC 8693 token-exchange grant; profile re-sync
sync_mode.Linking an existing account to an upstream identity, which also restores access after an upstream email change (today the taken
user_idrefuses its owner). – We won’t do this as fo rNeo email/phone is the primary identity.