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:
- Fast path — looks up the username in the
federated_accountsdatabase table (accounts that have previously completed a federated login or were linked by an administrator). - 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 ownipaidpconfiglinkattribute). Ifipauserauthtype=idpis 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 priorfederated_accountsrecord.
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.
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 withipa-(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 stableipa-ca.<domain>hostname:https://ipa-ca.ipa.test/idp/internal/callback/ipa-<idp-cn>- Authentication method:
client_secret_postwhenipaIdpClientSecretis present, otherwisenone(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:
| Field | Description |
|---|---|
default_acr | ACR value stamped when the upstream omits acr in its response. Example: "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport". |
default_amr | AMR 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:
| Component | Content |
|---|---|
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:
| Category | Range / pattern |
|---|---|
| Non-HTTPS | Any http:// URL |
| Loopback | 127.0.0.0/8, ::1, localhost, *.localhost |
| Private IPv4 | 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 |
| Link-local IPv4 | 169.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 / broadcast | 0.0.0.0, 255.255.255.255 |
| IPv6 unique-local | fc00::/7 |
| IPv6 link-local | fe80::/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.
OIDC Federation 1.0
Ahdapa publishes a spec-compliant Entity Statement at
/.well-known/openid-federation. Incoming federated assertions are validated
against the trusted_issuers list only. Full trust-chain resolution
(intermediaries, metadata_policy merging) is not yet implemented.
See Standards for the known-limitations entry.