Demo: two-IdP federation
Location: contrib/demo/federation/
Starts two ahdapa instances on loopback ports 8080 and 8081 and demonstrates section-6 authentication delegation: a downstream IdP (IdP A) redirects users to an upstream IdP (IdP B) for authentication, then maps the returned identity to a local account.
Topology
| Instance | Role | URL | Realm |
|---|---|---|---|
| IdP A node1 — “Downstream Corp” | downstream (relying) IdP | http://127.0.0.1:8080 | CORP.LOCAL |
| IdP A node2 — “Downstream Corp” | gossip replica of node1 | http://127.0.0.1:8082 | CORP.LOCAL |
| IdP B — “Partner IdP” | upstream (authenticating) IdP | http://127.0.0.1:8081 | PARTNER.LOCAL |
What it shows
- Federation login —
alice@CORP.LOCALis linked tobob@PARTNER.LOCAL. Enteringaliceat IdP A’s login page redirects the browser to IdP B; the user authenticates asbobthere, and IdP A issues a local session foralice. - Local login —
carol@CORP.LOCALlogs in with a local password at IdP A without any federation redirect. - Two-stage login UX — the login page first asks for a username, calls
GET /api/auth/federated-hintto check for a federation hint, then either redirects to the upstream (federated case) or shows a password field (local case). - OIDC dynamic client registration — IdP A registers itself as a client at
IdP B via
POST /register(RFC 7591) during setup; the assignedclient_idis substituted into IdP A’s runtime config. - Federated account linking — the admin API (
POST /api/admin/federated-accounts) creates thebob@PARTNER.LOCAL → alice@CORP.LOCALmapping; no manual database editing is needed. - Upstream token store — upstream tokens from IdP B are automatically
encrypted and stored during federated login; retrievable via SPNEGO
(
curl --negotiate) when Kerberos tools are installed. - Device authorization grant with
return_to— a device-flow client requests a device code, then the user authenticates via the federated login flow withreturn_to=/device?user_code=...so that after upstream authentication the browser lands on the device consent page instead of the default WebUI. - Upstream token refresh verification — after a device-flow federation login deposits new upstream tokens, the demo verifies that the previously stored token has been replaced by the fresh one.
- Workload federation — a CI-runner client exchanges an upstream ID
token at the
/federation/tokenendpoint (Basic Auth adapter), and separately exchanges IdP B’s raw access token directly via RFC 8693 token exchange, for a scoped local access token — using a federation policy to resolve the upstream identity to a local service account in both cases. - Gossip replication — IdP A runs as a two-node cluster. Federation policies created on node1 are verified to replicate to node2 via CRDT gossip, demonstrating that federation policies are cluster-wide.
Demo accounts
Passwords are generated randomly at startup and printed to the console.
| Username | IdP | What happens on login at IdP A |
|---|---|---|
| alice | A (local) | Redirect to IdP B; log in as bob; redirected back as alice |
| carol | A (local) | Local password login; no federation redirect |
| bob | B (local) | Direct login at IdP B only |
| diana | B (local) | Direct login at IdP B only |
Prerequisites
ahdapabinary — the script looks in$PATHfirst (resolved to its full path), thentarget/release/ahdapa, thentarget/debug/ahdapa, and falls back tocargo buildif none is found.python3— for JSON parsing in the setup script.curl.openssl— for generating the EC P-256 key used in the upstream client auth stanza.npm— if the WebUIdist/directory does not yet exist, the script builds it.- Ports 8080 and 8081 free.
Running
# Non-interactive (automated test, exits with pass/fail):
contrib/demo/federation/run.sh
# Interactive (run tests, then keep servers up for manual exploration):
contrib/demo/federation/run.sh --interactive
What the script does
- Checks that ports 8080 and 8081 are free; exits with an error if not.
- Builds the WebUI (
webui/dist/) if the directory is absent, then locates or builds theahdapabinary (PATH → release → debug →cargo build). - Generates a P-256 private key for IdP A’s upstream client auth stanza (stored
at
/tmp/ahdapa-demo-idpa-upstream.pem). - If Kerberos tools are installed, starts an ephemeral MIT KDC for realm
CORP.LOCALwithalice@CORP.LOCALandHTTP/localhost@CORP.LOCALprincipals. - Starts IdP B on port 8081; waits for its discovery document to be served.
- Registers IdP A as a public OIDC client at IdP B via
POST http://127.0.0.1:8081/registerusing the demo registration token (demo-federation-setup-token— set inidpb.toml). The response contains the generatedclient_id. - Writes a runtime config for IdP A at
/tmp/ahdapa-demo-idpa-runtime.tomlby substituting the realclient_idand keytab path into the template. - Starts IdP A on port 8080; waits for its discovery document.
- Logs in as
aliceat IdP A via the admin API, then callsPOST /api/admin/federated-accountsto linkbob@PARTNER.LOCALtoalice@CORP.LOCAL. - Runs automated tests:
- Drives a scripted federated login (alice → IdP B → bob → callback).
- Verifies
upstream-token.depositedaudit event in idpa.log. - If KDC is available: kinit + SPNEGO retrieval of the stored upstream token.
- Prints PASS/FAIL summary.
- In interactive mode: prints URLs and waits for Ctrl-C.
Example non-interactive output
==> Starting IdP A (Downstream Corp) on :8080…
Waiting for IdP A. ready.
==> Creating admin session at IdP A (alice)…
==> Linking bob@PARTNER.LOCAL → alice@CORP.LOCAL…
bob@PARTNER.LOCAL → alice@CORP.LOCAL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Step 1 — drive federated login (alice → bob) to store upstream token
✓ Federation redirect to IdP B
✓ Got callback URL for IdP A
✓ Federation callback completed (HTTP 302)
Step 2 — verify upstream token was deposited
✓ upstream-token.deposited audit event found in idpa.log
Step 3 — retrieve upstream token via SPNEGO
✓ Retrieved upstream token via SPNEGO (HTTP 200)
✓ upstream-token.retrieved audit event found in idpa.log
Step 5 — device authorization grant with federated login + return_to
✓ Got device code (user_code: ABCD-EFGH)
✓ Federation redirect to IdP B (with return_to)
✓ Callback redirected to device verification page (return_to works)
✓ Device authorized
✓ Device token obtained
Step 6 — re-retrieve upstream token after device-flow federation
✓ Upstream token refreshed after device-flow federation
Step 8 — workload federation (keyless CI/CD)
✓ CI-runner client created
✓ Federation policy created
✓ ID token minted at IdP B
✓ Token exchanged at /federation/token
✓ Access token sub matches ci-runner client_id
✓ Got access_token from IdP B (… chars)
✓ access_token exchange via trusted_issuers succeeded (HTTP 200)
✓ access_token exchange sub = ci-runner client_id (…)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PASS — all federation demo steps succeeded.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Exploring the demo
With --interactive, the servers stay running after the automated tests.
Open http://127.0.0.1:8080/ui/ in a browser.
Federated login path:
- Enter
alicein the username field and press Enter. - The page detects the federation hint and redirects to IdP B’s login form.
- Enter
boband the password printed at startup at IdP B. - IdP B redirects back to IdP A’s callback (
/internal/callback/partner-idp). - IdP A maps
bob@PARTNER.LOCALtoalice@CORP.LOCALand issues a local session. - The admin panel shows
aliceas the logged-in user.
Local login path:
- Enter
caroland press Enter. - A password field appears (no federation hint for carol).
- Enter the password printed at startup. Session is established at IdP A directly.
To initiate the federation flow from a client application, redirect to:
http://127.0.0.1:8080/auth/external/partner-idp
Append standard OAuth2 parameters (client_id, redirect_uri, response_type,
scope, state, code_challenge, code_challenge_method) as query parameters.
Upstream token store
The demo enables the upstream token store on IdP A (store_on_federation = true).
When alice performs a federated login (redirected to IdP B, logs in as bob), the
upstream tokens from IdP B are automatically encrypted and stored in the CRDT.
If Kerberos tools are installed (krb5-server, krb5-workstation), the script
starts an ephemeral MIT KDC for realm CORP.LOCAL. After a federated login,
retrieve the stored upstream token with SPNEGO:
# Get a Kerberos ticket for alice (password printed at startup)
KRB5_CONFIG=/tmp/ahdapa-demo-federation-kdc/krb5.conf \
kinit alice@CORP.LOCAL
# Retrieve the stored upstream token via SPNEGO.
# Use "localhost" (not 127.0.0.1) so SPNEGO targets HTTP/localhost@CORP.LOCAL.
curl --negotiate -u: -X POST \
http://localhost:8080/api/upstream-token/retrieve
The response contains the upstream IdP B access token:
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 842,
"scope": "openid profile email"
}
Without Kerberos tools, the token store is still enabled and tokens are stored on federated login, but SPNEGO retrieval is not available. The demo prints a note at startup if Kerberos tools are missing.
Workload federation
Step 8 of the demo exercises keyless CI/CD authentication via the
/federation/token endpoint:
- A ci-runner client is created at IdP A with
token-exchangeas its only grant type andallowed_token_exchange_audiencesset to IdP B’s issuer URL. - A federation policy maps
*@PARTNER.LOCAL(any IdP B user) to the ci-runner client_id with a scope ceiling ofopenid profile. - An ID token is minted at IdP B via a standard code flow (simulating a CI provider issuing a workload identity token).
- The ID token is exchanged at IdP A using Basic Auth:
curl -u "<ci-runner-client-id>:<id_token>" \ -X POST http://127.0.0.1:8080/federation/token - The resulting access token’s
subclaim is verified to match the ci-runner client_id, proving the federation policy resolved the upstream identity to the local service account. - The demo also extracts IdP B’s raw
access_tokenfrom the same token response and exchanges it directly at IdP A’s/tokenendpoint via RFC 8693 token exchange withsubject_token_type=urn:ietf:params:oauth:token-type:access_token(bypassing the/federation/tokenBasic Auth adapter used in step 4). IdP A verifies the token against IdP B’s JWKS because IdP B’s issuer is listed infederation.trusted_issuers, then maps itssubto the ci-runner client via the same federation policy used for the ID token exchange above. See Trusting upstream tokens for the full trust model.
In a real deployment, the CI provider (GitHub Actions, GitLab CI) issues the
OIDC JWT, and docker login sends it as the password in Basic Auth. No
long-lived secrets are stored in the CI configuration.
Configuration notes
| File | Description |
|---|---|
idpa.toml | IdP A base config; __IDPA_CLIENT_ID__ placeholder substituted at runtime |
idpb.toml | IdP B config; registration_token = "demo-federation-setup-token" enables POST /register |
users-idpa.toml.in | Users template for IdP A (alice, carol); passwords substituted at runtime |
users-idpb.toml.in | Users template for IdP B (bob, diana); passwords substituted at runtime |
clients-idpa.toml | Static OAuth2 clients for IdP A: token-retriever (upstream token retrieval) and demo-device (device authorization grant) |
The upstream IdP stanza in IdP A’s config:
[[federation.upstream_idps]]
id = "partner-idp"
issuer = "http://127.0.0.1:8081"
client_id = "<substituted at runtime>"
private_key_path = "/tmp/ahdapa-demo-idpa-upstream.pem"
scopes = ["openid", "profile", "email"]
callback_path = "/internal/callback/partner-idp"
IdP A trusts tokens issued by IdP B:
[federation]
trusted_issuers = ["http://127.0.0.1:8081"]
See also
- Federation — full federation configuration reference.
- GitHub federation demo — using GitHub as an upstream OAuth2 provider.