Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Federation

Ahdapa can delegate authentication to an external OIDC or OAuth2 identity provider (upstream IdP). When a user’s identity is managed by an upstream IdP — a corporate SSO, a cloud provider, or a FreeIPA-registered external directory — Ahdapa redirects the browser there, receives the callback, maps the returned claims to a local identity, and issues its own OAuth2/OIDC tokens.

The result is a seamless single sign-on flow: end users authenticate at the provider they already use, and applications receive standard Ahdapa tokens.


How the federated login flow works

When an [[federation.upstream_idps]] entry is configured (or auto-discovered from FreeIPA), the login page checks the entered username against GET /api/auth/federated-hint. If the response contains an upstream_id, the browser is immediately redirected to that provider’s authorization endpoint — no local password form is shown.

The hint endpoint resolves an upstream IdP via two steps:

  1. Fast path — looks up the username in the federated_accounts database table (accounts that have previously completed a federated login or were linked by an administrator).
  2. Slow path (IPA only) — when no entry is found and [ipa] gssapi = true, performs an IPA LDAP lookup using the service principal credential (not S4U2Self, because the user has not authenticated yet and cannot read their own ipaidpconfiglink attribute). If ipauserauthtype=idp is set on the IPA user, the endpoint returns {"upstream_id":"ipa-<slug>"} so that first-time IPA IdP users are correctly redirected without needing a prior federated_accounts record.

When configured, a “Sign in with …” button also appears on the login page for users who want to choose their provider explicitly rather than typing an email-matched username.

return_to parameter

The federated login start endpoint (GET /auth/external/{upstream_id}) accepts an optional return_to query parameter. When the upstream authentication completes and no pending OAuth2 authorization request was captured (i.e. the user navigated directly to the IdP login rather than being redirected from /authorize), the browser is sent to return_to instead of the default WebUI path (/ui/).

The value must be a relative path starting with / and must not start with // (to prevent open-redirect attacks). Invalid values are silently ignored and the user is redirected to /ui/.

Example:

/auth/external/corp-sso?return_to=/ui/admin

After authenticating at the upstream IdP, the browser returns to /ui/admin instead of the default /ui/ landing page.


Manual configuration

Add one [[federation.upstream_idps]] block per upstream IdP in ahdapa.toml:

[[federation.upstream_idps]]
id            = "corp-sso"
issuer        = "https://sso.partner.example.com"
client_id     = "ahdapa"
callback_path = "/internal/callback/corp-sso"
# client_secret = "…"   # omit for public clients (PKCE-only)

The callback_path value must be registered as a redirect URI at the upstream provider. Use the stable cluster hostname (e.g. ipa-ca.<domain>) rather than a per-node FQDN so the URI stays valid across node failures.

Cluster deployments and load balancers: Using a stable cluster hostname for the callback URI is recommended but not strictly required for correctness. Even if the upstream IdP sends the callback to a cluster node that did not start the flow, the handler will forward the browser to the correct originating node via a 302 redirect — see Cross-node callback routing below.

See Configuration Reference — [[federation.upstream_idps]] for the full list of fields including account linkage, scope mapping, and authentication method.


FreeIPA auto-discovery

When [ipa] gssapi = true (the default for IPA co-deployment), Ahdapa automatically reads all ipaIdP LDAP objects from cn=idp,<suffix> at startup and refreshes them every 300 seconds. Each discovered IdP becomes an upstream IdP registration without any TOML entry:

  • id: the CN lowercased with spaces replaced by -, prefixed with ipa- (e.g. "Google Workspace""ipa-google-workspace").
  • callback_path: /internal/callback/ipa-{slug} — register this URL as a redirect URI at the upstream provider. Use the stable ipa-ca.<domain> hostname: https://ipa-ca.ipa.test/idp/internal/callback/ipa-<idp-cn>
  • Authentication method: client_secret_post when ipaIdpClientSecret is present, otherwise none (public client, PKCE-only).
  • The IdP’s issuer is automatically added to the trusted issuers list for JWT validation.

If a static [[federation.upstream_idps]] entry has the same id as an IPA-sourced entry, the static entry takes precedence.

Federated user resolution — no local database row required

When a user whose ipaidpconfiglink points to an IPA-managed IdP completes the federated login flow, Ahdapa resolves the local uid directly via LDAP using the filter:

(&(objectClass=ipaIdpUser)(ipaIdpConfigLink=<dn>)(ipaIdpSub=<external-subject>))

No federated_accounts database row is needed for these users — the link between the IPA uid and the external identity is stored in the IPA directory (ipaIdpConfigLink + ipaIdpSub on the user entry). Only users whose IdP is not registered in FreeIPA require a federated_accounts row created at first login.

LDAP indexes (recommended)

The filter above triggers a full scan of cn=accounts,<suffix> unless equality indexes exist on ipaIdpConfigLink and ipaIdpSub. Add them once on the primary IPA server:

# Replace IPA-EXAMPLE-COM with your realm (dots → dashes).
dsconf slapd-IPA-EXAMPLE-COM backend index add \
    --attr ipaIdpConfigLink --index-type eq userRoot
dsconf slapd-IPA-EXAMPLE-COM backend index add \
    --attr ipaIdpSub --index-type eq userRoot
dsconf slapd-IPA-EXAMPLE-COM backend index reindex \
    --attr ipaIdpConfigLink --attr ipaIdpSub --wait userRoot

The Ansible playbook playbooks/ipa_permissions.yml creates these indexes automatically.


IPA upstream IdP ACR/AMR overrides

IPA-sourced IdPs often do not return acr or amr claims in their userinfo response. To stamp meaningful authentication context into tokens issued after an IPA-managed upstream IdP flow, set per-IdP defaults via the admin panel.

These overrides are stored in the CRDT and gossiped to all cluster nodes, so they survive restarts and take effect on every node without changing the TOML file.

Admin UI

The IPA Upstream IdPs page in the admin panel (/ui/admin/federation/ipa-idps) lists all IdPs currently discovered from cn=idp,<suffix>. Each entry shows the LDAP-sourced fields (issuer, client ID, scopes, callback path) as read-only and exposes two writable fields:

FieldDescription
default_acrACR value stamped when the upstream omits acr in its response. Example: "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport".
default_amrAMR values stamped when the upstream omits amr. Comma-separated in the UI; stored as a JSON array. Example: ["pwd", "fed"].

Requires federation:read to view, federation:write to edit.

Admin API

See Admin API — IPA Identity Providers for the full endpoint listing and request/response examples.


Trusted issuers

IPA-sourced IdP issuers are automatically trusted for JWT validation. For manually configured IdPs, list their issuers explicitly:

[federation]
trusted_issuers = [
    "https://sso.partner.example.com",
]

Federation state security

Each time a user is redirected to an upstream IdP, Ahdapa generates a structured state token that is round-tripped through the browser:

base64url(issuer) . base64url(rand32) . base64url(HMAC-SHA256(refresh_key, "ahdapa-fed-state-v1:" || issuer || "." || rand_b64))

The three dot-separated components are:

ComponentContent
base64url(issuer)The canonical issuer URL of the cluster node that started the flow.
base64url(rand32)32 bytes of cryptographically secure randomness (unique per flow).
base64url(MAC)HMAC-SHA256 of the issuer and random components, keyed by the cluster refresh key.

The refresh key is derived from the cluster wrapping key via HKDF (info = "ahdapa-refresh-v1") and is therefore identical on all cluster nodes that share a wrapping key.

When the upstream IdP returns the user to /internal/callback/{id}, the handler verifies the MAC in constant time (using subtle::ConstantTimeEq) before touching the database. A tampered or forged state value is rejected before any redirect or database lookup occurs, preventing open-redirect attacks.

upstream_id CSRF guard: After the MAC is verified and the state row is retrieved from the database, the callback handler confirms that the upstream_id recorded in the database row matches the upstream_id from the URL path. A mismatch (which would indicate tampering with the callback URL) causes the handler to restart the auth flow rather than proceed with the mismatched configuration.

Rolling-upgrade tolerance: state tokens from an older node that did not include the MAC (legacy single-component format, no dots) are detected and handled as an unknown state — the handler restarts the auth flow rather than returning an error, allowing zero-downtime upgrades.


Cross-node callback routing

In a cluster behind a load balancer, the upstream IdP may send the callback to a different node than the one that started the flow. The pending auth state (nonce, PKCE verifier, original request parameters) is stored in the originating node’s local database and is not gossiped.

When the callback handler cannot find the state in its local database, it extracts the originating node’s issuer URL from the verified MAC state token and checks it against the configured gossip.peers and dynamically discovered IPA topology peers. If the issuer belongs to a known cluster member, the handler issues a 302 redirect to forward the browser to the correct node. The originating node completes the normal DB lookup and finishes the flow.

sequenceDiagram
    participant B as Browser
    participant LB as Load Balancer
    participant NA as Node A (started flow)
    participant NB as Node B (received callback)

    B->>LB: GET /internal/callback/corp-sso?code=…&state=…
    LB->>NB: forward
    NB->>NB: verify state MAC — OK
    NB->>NB: DB lookup — not found (flow started on Node A)
    NB->>NB: extract issuer from state → Node A URL
    NB->>NB: check issuer ∈ gossip.peers — known peer
    NB-->>B: 302 → Node A /internal/callback/corp-sso?code=…&state=…
    B->>NA: GET /internal/callback/corp-sso?code=…&state=…
    NA->>NA: verify state MAC — OK
    NA->>NA: DB lookup — found
    NA->>NA: exchange code, create session
    NA-->>B: 302 → resume URL + Set-Cookie: session=…

Security: the forwarding step only occurs after the HMAC is verified. Only known cluster members (listed in gossip.peers or discovered via ipa_topology) are forwarding targets. An unknown or forged issuer in the state token is rejected, and the flow is restarted rather than forwarded.

This mechanism requires no CRDT gossip of pending auth state and adds only one browser round-trip of latency. The upstream IdP callback URI does not need to be node-specific, and using a stable cluster hostname for callback_path remains the recommended practice.


SSRF protection for external URLs

Any administrator-supplied URL that Ahdapa will fetch from — federation callback base URLs, upstream IdP issuer/discovery URLs, OAuth2 client jwks_uri, and SPIFFE bundle endpoint URLs — is validated before storage or use. URLs that could cause server-side request forgery (SSRF) are rejected with 400 Bad Request. The following address ranges are blocked:

CategoryRange / pattern
Non-HTTPSAny http:// URL
Loopback127.0.0.0/8, ::1, localhost, *.localhost
Private IPv410.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
Link-local IPv4169.254.0.0/16
CGNAT / shared address space (RFC 6598)100.64.0.0/10
Network benchmarking (RFC 2544)198.18.0.0/15
Unspecified / broadcast0.0.0.0, 255.255.255.255
IPv6 unique-localfc00::/7
IPv6 link-localfe80::/10
IPv4-mapped IPv6::ffff:0:0/96
IPv6 unspecified / loopback::, ::1

Public HTTPS URLs that do not resolve to any of the above are accepted.


Upstream token storage during federation

When [upstream_token_store] store_on_federation = true and the upstream IdP is listed in store_for_idps, the federation callback automatically stores the upstream access token, refresh token, and ID token for later retrieval. See Configuration Reference — [upstream_token_store] for the full set of keys.

Token replacement semantics

The upstream token store maintains one token slot per (user, upstream IdP) pair. The internal key is "{local_sub}\0{issuer}" — so each upstream IdP gets its own slot for each user, but a single user can only have one stored token per IdP.

When a user re-authenticates via federation — for example, when a device authorization flow triggers another upstream login, or the user signs in again through the browser — the new tokens replace the previous ones. The old encrypted blob is overwritten both in memory and in the database (LWW / last-writer-wins CRDT register semantics).

By default, the previous access token issued by the upstream IdP remains valid there until its natural expiry. When per-IdP lifecycle flags are configured, ahdapa can actively manage old tokens:

  • revoke_on_replace = true – revoke the old access token at the upstream IdP (via RFC 7009) whenever a new token replaces it (federation callback, explicit deposit, or token refresh). Requires the upstream to advertise a revocation_endpoint in OIDC discovery or a static override; if unavailable, revocation is skipped with a warning.
  • single_use = true – delete the token from the CRDT after the first retrieval. The consumer receives the token, but no second retrieval is possible.

These flags can be set per-IdP in [[federation.upstream_idps]] or as global defaults (default_revoke_on_replace, default_single_use) in [upstream_token_store]. IPA-sourced IdPs inherit the global defaults.

The four lifecycle modes formed by combining the two flags are:

revoke_on_replacesingle_useBehaviour
false (default)false (default)Fire-and-forget – old tokens remain valid at the upstream until natural expiry.
truefalseRevoke-on-replace – old access token is revoked at the upstream when a new one arrives.
falsetrueSingle-use retrieval – token is deleted from the store after the first retrieval; no upstream revocation.
truetrueRevoke-on-replace + single-use – old token revoked at upstream on replacement, and the new token is deleted after the first retrieval.

Automatic token refresh

When a stored access token has expired and a refresh token is available, the retrieval endpoint (POST /api/upstream-token/retrieve) automatically refreshes the access token against the upstream IdP’s token endpoint. The refreshed token is stored back into the CRDT, replacing the expired entry.

Token expiry purging

Stored tokens whose expires_at has passed are periodically purged by the gossip cleanup loop. This is independent of token replacement — it handles natural expiry of tokens that were never retrieved or refreshed.


SAML2 Federation (SP Role)

Ahdapa can also consume SAML2 assertions from upstream SAML Identity Providers. This is an alternative to the OIDC federation described above, for environments where the upstream IdP only supports SAML2 (e.g., ADFS, Shibboleth).

Unlike OIDC upstream IdPs (which can be configured in TOML via [[federation.upstream_idps]]), SAML2 upstream IdPs are registered via the admin API and stored in the CRDT. See Admin API – SAML2 Upstream Identity Providers for the endpoint reference.

Key differences from OIDC federation:

  • The SP auth flow starts at /saml2/auth/{local_id} (not /auth/external/{upstream_id}).
  • The assertion consumer is at /saml2/acs/{local_id} (not /internal/callback/{upstream_id}).
  • Per-upstream SP metadata is published at /saml2/sp/metadata/{local_id} — register this URL at the upstream IdP.
  • Session creation uses the same SessionClaims AEAD cookie as OIDC federation, so any pending OAuth2 authorization flow is resumed transparently.

See SAML2 Federation for the full setup guide, including known limitations (no remote metadata import, persistent NameID not pairwise).


OIDC Federation 1.0

Ahdapa publishes a spec-compliant Entity Statement at /.well-known/openid-federation (including authority_hints when trust anchors are configured). Incoming federated assertions are validated against pre-configured trusted_issuers (fast path) and via multi-hop trust-chain validation against configured trust_anchors. The chain walker follows authority_hints from the leaf upward through intermediaries until a configured trust anchor is reached, with configurable max_chain_depth (default 5). Metadata policy operators (value, add, default, one_of, subset_of, superset_of, essential) are applied top-down from anchor to leaf. Resolved chains are cached with TTL derived from the shortest exp in the chain.

Trust marks, subordinate listing (/list endpoint), and the /fetch endpoint for serving our own subordinate statements are not yet implemented.

See Standards for the known-limitations entry.


Workload Federation (/federation/token)

The POST /federation/token endpoint is a thin adapter that translates HTTP Basic Auth into an RFC 8693 token exchange. It is designed for tools that only support username/password authentication (e.g. docker login, podman login):

Authorization: Basic base64(client_id:oidc_jwt)

The endpoint decodes the Basic Auth credentials, looks up the client_id in the client registry, and performs a token exchange with the OIDC JWT as the subject_token (type urn:ietf:params:oauth:token-type:id_token). An optional scope parameter in the form body restricts the granted scopes.

The endpoint is advertised in the discovery metadata as federation_token_endpoint.

Federation policies

Federation policies provide pattern-based identity mapping for workload federation. When an incoming ID token’s iss and sub match a policy’s upstream_iss and subject_pattern (glob), the token exchange resolves the subject to the policy’s local_sub (typically a service client_id) rather than requiring a pre-existing federated_accounts database row.

Policies are managed via the admin API (/api/admin/federation-policies) or the ahdapactl federation-policies CLI, and appear in the admin WebUI under Federation Policies.

Each policy has:

FieldDescription
upstream_issUpstream issuer URI to match.
subject_patternGlob pattern matched against the incoming sub claim. * matches any characters.
local_subLocal subject (typically a client_id) assigned when the pattern matches.
scopesOptional scope ceiling applied on top of the standard scope intersection.
priorityHigher-priority policies are evaluated first.

GitHub Actions example

- uses: actions/github-script@v7
  id: get-token
  with:
    script: |
      return await core.getIDToken('https://idp.example.com/idp')
- run: |
    echo "${{ steps.get-token.outputs.result }}" | \
      podman login --username my-ci-client --password-stdin idp.example.com

Create a federation policy mapping repo:org/repo:ref:refs/heads/* to my-ci-client, and set the client’s allowed_token_exchange_audiences to the audience used in getIDToken().