Skip to main content
Federated login | MandateFederated login in the source-owned Mandate documentation.Mandatereferencemandatereferenceadopterdeveloperoperatorreference

Federated login

A person is already signed in to your platform. Mandate turns the proof your identity provider issued them into a Mandate session, and that session into a credential your resource server can check — without a second password, a second account, or any per-user administration ahead of the first login.

This page describes what the code in this repository does today. Where a standard is named in the repository but is not on this road, it says so.

The flow​

Pan the full-size diagram: swipe or scroll, or focus the canvas and use the arrow keys.

DiagramA visual explanation of the surrounding documentation.
#StepWhat decides it
1The request names a connection_id; the deployment resolves it to a configured, enabled connection. A connection nothing seeded is refused.crates/mandate-federation/src/authenticate.rs
2The proof's iss must equal the connection's configured issuer. The issuer is never taken from the request.crates/mandate-federation/src/verifier_real.rs
3The signature is verified against a key fetched from the issuer's own JWK Set, under the algorithm the connection is configured for. No algorithm is selected from the proof's header, and there is no default.verifier_real.rs, RefusalReason::ConnectionAlgorithmUnconfigured
4The proof's aud must name the client the connection was registered with.verifier_real.rs
5The organization comes only from the connection's tenant_resolution rule. Zero or several matches deny; nothing is guessed, and no email domain or hostname is consulted.crates/mandate-federation/src/authenticate.rs
6The external subject is resolved to a Mandate principal through an explicit link. With no link, a connection whose jit_provisioning is true creates the principal and the link during this login; false refuses and creates nothing.services/control-plane/src/adapters.rs, Deployment::authenticate
7A session is opened and answered with session_id and session_proof.services/control-plane/src/serve.rs
8GET /oauth/authorize, carrying the session proof as a bearer token, returns an authorization code by redirect. The client must be registered, enabled, public, in the session's organization, and must present a redirection URI it registered — compared byte for byte, normalized in no way.crates/mandate-federation/src/publicclient.rs
9POST /oauth/token exchanges the code and the PKCE verifier for the credential.services/sts/src/redemption.rs

Just-in-time provisioning is a composition inside the control plane, not a round trip the user agent sees: the adapter calls the login, and on an absent-link refusal from a connection that admits provisioning it creates the principal and calls the login once more. It is taken once, never in a loop, and a second refusal is the login's answer.

One case it deliberately does not cover: an external subject whose link was explicitly unlinked is not re-provisioned. Provisioning there would mint a new principal for a revoked subject and defeat the unlinking, so that login stays refused.

The standards, and what Mandate does with each​

StandardWhat is implemented
RFC 6749 — OAuth 2.0The authorization code grant, and only it. GET /oauth/authorize answers 302 with code and the exact state received; POST /oauth/token answers the credential. Every refusal is an RFC 6749 error object of exactly two members, error and error_description; no description carries a byte the caller sent.
RFC 7636 — PKCERequired, S256 only. mandate.core.PkceMethod declares one variant, the authorization decoder refuses any other code_challenge_method, and each registered client states its pkce_method. A verifier that does not match the challenge is refused invalid_grant.
RFC 8414 — Authorization server metadataGET /.well-known/oauth-authorization-server publishes nine members. Every endpoint URL is the configured issuer joined with a path read from the same route table the listener dispatches, so the document cannot advertise a path nothing serves. A member naming an endpoint this deployment does not serve — revocation_endpoint, registration_endpoint, device_authorization_endpoint — is absent rather than empty.
RFC 7662 — IntrospectionPOST /oauth/introspect, taking token and optional token_type_hint, and requiring the calling resource server's own credential as a bearer token. Answers active, and where the credential resolves, credential_id, aud and sub. A refusal about the caller is 401 invalid_client; anything about the presented token is 200 {"active": false}.
RFC 7517 — JWKSGET /oauth/jwks publishes the key set the --key documents configure. The member set is closed: kty, kid, use, alg, plus n, e, crv, x, y. RFC 7517 section 9.2's private members are refused at construction, as is a repeated kid anywhere in the set.
OpenID Connect DiscoveryConsumed, not published. To find a connection's key set, Mandate fetches {issuer}/.well-known/openid-configuration and reads jwks_uri; the document is read once per issuer and held. Mandate publishes no /.well-known/openid-configuration of its own — no OpenID Connect command is declared, and the route table has no such path. Mandate is not an OpenID Provider.

Three further standards are named in this repository's design sources and are not implemented on this road. They appear in the combined architecture and the preserved design snapshots, and in no crate:

StandardStatus
RFC 8693 — Token ExchangeNot implemented. No exchange grant is admitted at the token endpoint; the only grant_type is authorization_code.
RFC 8707 — Resource IndicatorsNot implemented. The authorization request names its target with a target parameter carrying a registered resource-server id, which is Mandate's own registration and not RFC 8707's resource.
RFC 9700 — OAuth 2.0 Security BCPNamed as the intended baseline. No conformance to it is claimed or measured here.

Token revocation is likewise not on this road: there is no /oauth/revoke.

The endpoints​

Six routes. A path not in this table is a path nothing serves — including the generated /<domain>/commands/<Command> projections, which are refused like any other unknown path.

MethodPathTakesAnswers
POST/v1/federation/loginapplication/json: connection_id, proof (the IdP's JWT, base64)200 with session_id, principal_id, organization_id, epochs, expires_at, session_proof
GET/oauth/authorizeQuery: response_type=code, client_id, redirect_uri, code_challenge, code_challenge_method=S256, state, nonce, target, scope. Header: Authorization: Bearer <session_proof>302 with Location: <redirect_uri>?code=…&state=…
POST/oauth/tokenapplication/x-www-form-urlencoded: grant_type=authorization_code, client_id, code, code_verifier, redirect_uri200 with access_token, token_type: "Bearer", credential_id
POST/oauth/introspectapplication/x-www-form-urlencoded: token, token_type_hint. Header: Authorization: Bearer <caller's own credential>200 with active, and where it resolves credential_id, aud, sub
GET/.well-known/oauth-authorization-servernothing200 with the RFC 8414 document
GET/oauth/jwksnothing200 with the RFC 7517 key set

expires_in is absent from the token response: it is RECOMMENDED rather than required by RFC 6749 section 5.1, the contract's response does not declare it, and it is not derived from a clock the response does not carry. Introspection answers the credential's expiry instead.

Responses carrying credentials are sent no-store.

Configuration and deployment​

mandate-control-plane serve --listen <addr> --issuer <url>
[--code-lifetime <ISO 8601 duration>] [--session-lifetime <ISO 8601 duration>]
[--connection <path>]... [--key <path>]... [--client <path>]... [--resource-server <path>]...
FlagMeaning
--listenThe socket address to bind.
--issuerThe issuer identifier this deployment publishes its metadata under, and the base every advertised endpoint URL is built from.
--code-lifetimeThe longest life of an authorization code. Default PT5M.
--session-lifetimeThe longest life of a federated session. Default PT8H.
--connectionA federation connection. Repeatable.
--keyA public JWK the key set publishes. Repeatable.
--clientA registered OAuth public client. Repeatable.
--resource-serverA registered resource server. Repeatable.

Each document is JSON, one per file, and unknown members are refused. A document that configures no deployment exits 2 before the socket is bound, naming the file; a listener that cannot bind exits 1.

--connection​

MemberRequiredMeaning
connection_idnoStated, or allocated and printed.
organizationyesThe organization this connection is bound to.
issueryesThe issuer a proof's iss must equal. Immutable: changing it means a new connection.
client_idyesThe client identifier your IdP issued Mandate, which a proof's aud must name.
algorithmnoThe signing algorithm this connection's proofs are verified under. Absent means verification is unconfigured and every login through this connection is refused — there is no default. ES256 and RS256 are the admitted names.
jwks_hostsnoHosts, beside the issuer's own origin, that this connection's key set may be fetched from. An entry is host or host:port.
tenant_resolutionyesHow the organization is resolved. configured_organization is required; verified_claim_name and verified_claim_value optionally bind the rule to a validated claim.
jit_provisioningyesWhether a first login with no link may create the principal. Stated, never defaulted.
linknoA pre-existing link: principal_id, subject, linked_at, and optionally external_principal_id.
{
"connection_id": "3f1c…",
"organization": "0a0a…",
"issuer": "https://accounts.google.com",
"client_id": "1234567890-abc.apps.googleusercontent.com",
"algorithm": "RS256",
"jwks_hosts": ["www.googleapis.com"],
"tenant_resolution": { "configured_organization": "0a0a…" },
"jit_provisioning": true
}

jwks_hosts is what lets an issuer publish its key set on a second host, as Google and Okta do: Google's discovery document is served by accounts.google.com and names a key set on www.googleapis.com. With no entry, the issuer's own origin is admitted and nothing else — the discovery document is served by the issuer and is not permitted to introduce a destination the deployment never named. An entry is admitted only over https, and never for a literal address or the loopback interface.

--key​

One public JWK, published verbatim in the key set. Private members are refused, and two documents naming one kid are refused together.

{ "kty": "EC", "kid": "login-key-1", "use": "sig", "alg": "ES256",
"crv": "P-256", "x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
"y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0" }

This composition holds no signer. The keys are published for readers; the credential the token endpoint issues under a Reference profile is minted from the host CSPRNG and is not a JWT signed with a published key.

--client​

{ "client_id": "…", "organization": "0a0a…", "public": true,
"redirect_uris": ["https://client.example/callback"], "pkce_method": "S256" }

public is stated, never defaulted. redirect_uris are compared byte for byte: a URI written with a trailing slash the client does not send is a URI the client has not registered.

--resource-server​

{ "resource_server_id": "…", "organization": "0a0a…",
"audience": "https://api.example",
"profile": { "name": "reference", "kind": "Reference", "revocation": "ImmediateOnline",
"max_ttl": "PT1H", "positive_cache_ttl": "PT30S",
"requires_online_authorization": true },
"allowed_exchange_sources": [] }

allowed_exchange_sources is stated rather than defaulted — every member widens what may be exchanged into this target — and empty is the ordinary value.

The process prints the connection, client and resource-server identities to stdout before the listener binds, because those are what the three selecting routes are called with.

Operational truths​

  • Nothing is durable. The folds start empty and are seeded per process from the flags. A restart forgets every provisioned link and every issued session and credential. Event-log-backed persistence is the next milestone.
  • There is no HTTP route that registers a connection. The flags are the only way in. Registration, linking and mapping routes are later work.
  • This road has never been driven over https. There is no TLS listener in the repository; the end-to-end cases run on loopback http, which UreqJwks::admits permits for loopback hosts only. Outbound, admits requires https for any JWKS host that is not the issuer's own origin. Terminate TLS in front of this listener.
  • The listener answers one connection at a time, with no thread and no async runtime behind it. One client's request bounds every other client's wait.
  • A connection admitting just-in-time provisioning trusts the IdP to decide who gets an account in that organization. That is the point of the flag, and the reason it must be stated explicitly.

What each party delivers​

PartyProvidesMust configureCan rely on
Customer's IdPSigned proofs (JWT), an OpenID Connect discovery document, and a reachable JWK SetA client registration for Mandate, whose identifier appears in each proof's audBeing the only issuer its connection accepts; its iss and aud are never taken from a request
Customer's administratorThe organization the connection is bound to and the tenant rule that selects itWhether the connection admits just-in-time provisioning; any pre-existing linksZero or ambiguous tenant matches deny rather than resolve; email equality never links an account
Relying applicationA public OAuth clientIts registered redirection URIs and S256 PKCEIts state returned exactly as sent; its code bound to its own client, redirection URI and verifier
Resource serverIts registration, audience and credential profileIts audience, and its own credential for calling introspectionCredentials bound to its audience; introspection answering active without it ever seeing the customer's IdP
Mandate operatorThe running process and its metadataEvery document above, TLS termination, and the process lifetime that bounds all stateA configuration it cannot serve being refused before the socket, not at every login

A resource server trusts the Mandate credential and never the customer's IdP. That is the boundary the whole road exists to draw.

What is proven, and what is not​

services/control-plane/tests/end_to_end.rs spawns the composition binary as a child process and drives all six routes against it over a real socket, with a loopback issuer that really signs the proof with a P-256 key generated when the case runs. The child fetches the discovery document and the key set off that issuer and verifies a signature under a key it fetched. No key is committed.

Fifteen cases: three that complete a login, one that decides the JWKS host wiring in-process, and eleven refusals — each refusal asserted by its whole response body rather than by its status alone.

RefusedAnswer
A connection_id nothing seeded400 access_denied
A proof whose iss is not the connection's400 access_denied
A proof whose kid the published set does not hold400 access_denied
A proof signed by a key outside the published set400 access_denied
A proof whose aud is not the connection's client400 invalid_scope
A code verifier that does not match the challenge400 invalid_grant
A connection configured for no algorithm400 access_denied
A first login a connection does not admit provisioning for400 access_denied
A split issuer's key set on a host no document lists400 access_denied
A split issuer's key set on loopback, even when listed400 access_denied
An ambiguous tenant resolutionexit 2, before the socket

The accepted cases cover the full four-step road, a first login that provisioning opens, a connection document naming jwks_hosts, and that those hosts reach the verifier.

What this does not establish:

  • No TLS. Every case is loopback http. Nothing here measures this road behind TLS.
  • No persistence. Every case configures a fresh process from flags. Nothing here measures a restart, a shared store, or concurrent writers.
  • No real identity provider. The issuer is a loopback listener standing in for one. Nothing here measures Google, Okta or Entra, their key rotation, or their discovery documents.
  • No successful cross-host JWKS fetch. That a listed second host reaches the verifier is decided in-process; a fetch from one has never been driven end to end, because it requires https that a loopback case cannot present.
  • One process, one connection at a time. Nothing here measures load, concurrency or a revocation race.

The architecture page has the boundaries this road sits in, and the contract reference has the declared model behind it.