Upstream Token Store
When Ahdapa federates authentication to an upstream OAuth2/OIDC IdP (Entra ID, Keycloak, Okta, etc.), the upstream tokens are normally discarded after validation. The upstream token store preserves these tokens so that users can retrieve them later for cloud resource access, service delegation, or silent re-authentication.
Architecture
graph TD
subgraph "Authentication (Deposit)"
U1[User] --> AB[Auth broker]
AB --> IDP[Upstream IdP]
AB -- "tokens" --> DEP["Ahdapa<br/>/deposit"]
end
subgraph "Token Use (Retrieve)"
U2["User (with TGT)"] -- "SPNEGO" --> RET["Ahdapa<br/>/retrieve"]
end
DEP -- "encrypt + store" --> DB[("crdt_upstream_tokens<br/>AES-256-GCM, gossip-replicated")]
RET -- "lookup + auto-refresh" --> DB
Two deposit paths:
-
External deposit (
POST /api/upstream-token): a trusted system component (PAM hook, auth broker, ipa-otpd bridge) deposits tokens after authenticating a user against an upstream IdP. -
Federation callback (opt-in): when
store_on_federation = true, the existing browser-based federation redirect flow stores upstream tokens automatically after a successful login.
Deposit Flows
There are two distinct paths that deposit tokens into the upstream token store.
Both paths encrypt the token payload identically and write to the same CRDT
slot (keyed by "{local_sub}\0{upstream_iss}"), so they are interchangeable
from the retrieval side.
sequenceDiagram
participant User
participant Broker as Auth Broker /<br/>PAM Hook
participant Ahdapa
participant IdP as Upstream IdP
note over User,IdP: Flow 1 — External Deposit (POST /api/upstream-token)
User->>Broker: authenticate (password, device code, …)
Broker->>IdP: authorization code / device grant
IdP-->>Broker: access_token, refresh_token, id_token
Broker->>Ahdapa: POST /api/upstream-token<br/>(Bearer with upstream:deposit scope)
Ahdapa->>Ahdapa: resolve local subject,<br/>encrypt + store in CRDT
note over User,IdP: Flow 2 — Federation Callback (store_on_federation = true)
User->>Ahdapa: browser login → redirect to upstream IdP
Ahdapa->>IdP: authorization code exchange
IdP-->>Ahdapa: access_token, refresh_token, id_token
Ahdapa->>Ahdapa: store_on_federation?<br/>encrypt + store in CRDT
Ahdapa-->>User: session cookie + redirect
Flow 1: External deposit
A trusted system component — a PAM module, an auth broker, or an ipa-otpd
bridge — authenticates the user against the upstream IdP out-of-band, then
deposits the resulting tokens into Ahdapa via POST /api/upstream-token.
This flow is the primary integration point for non-browser authentication. For example, a user logs in at the Linux console with a password that is verified against Entra ID; the PAM helper obtains tokens during that verification and deposits them so the user can later retrieve an Azure access token via SPNEGO without re-authenticating.
The caller must present a bearer token that carries the upstream:deposit
scope. When localhost_only = true (the default), only connections from
the loopback interface are accepted, which limits the deposit endpoint
to processes running on the same host.
Flow 2: Federation callback
When a user performs a browser-based federated login and store_on_federation
is enabled, Ahdapa stores the upstream tokens automatically as a side effect
of the federation callback — no external component is needed.
This path is triggered whenever the federation callback runs and three
conditions are met: upstream_token_store.enabled is true,
store_on_federation is true, and the upstream IdP is listed in
store_for_idps.
The federation callback is reached from two entry points:
-
Direct browser login. The user clicks “Sign in with …” on the Ahdapa login page, is redirected to the upstream IdP, authenticates, and returns via the OAuth2 callback. The upstream tokens are stored as part of the callback processing, then the user receives a session cookie.
-
Device authorization grant with
return_to. A CLI or headless client starts a device authorization flow (POST /device_authorization), obtaining a device code and user code. The user opens the verification URL in a browser. If that login requires federation (the user chooses an upstream IdP), the same federation redirect → callback sequence occurs — depositing upstream tokens along the way — and afterwards the browser is directed to the device consent page via thereturn_toparameter. The device flow itself does not interact with the token store; the deposit happens inside the federation callback that the device flow triggers.
In both cases the token deposit is identical: the callback encrypts the token payload and writes it to the CRDT under the same composite key. A retrieval via SPNEGO or bearer token sees no difference between tokens deposited by either entry point.
Token replacement across flows
Because both flows write to the same CRDT slot, a token deposited via
one flow is replaced by a subsequent deposit from either flow. For example,
a token stored during a browser federation login is overwritten if the
user later authenticates via a PAM module that calls the external deposit
endpoint. When revoke_on_replace is enabled, the old access token is
revoked at the upstream IdP before the replacement is stored, regardless
of which flow deposited the original.
Configuration
[upstream_token_store]
enabled = false
store_for_idps = ["entra-id"]
default_upstream_idp = "entra-id"
max_token_age_secs = 604800 # 7 days
localhost_only = true
store_on_federation = false
default_revoke_on_replace = false # global default for per-IdP flag
default_single_use = false # global default for per-IdP flag
| Key | Description |
|---|---|
enabled | Master switch for the token store feature |
store_for_idps | IdP ids eligible for token storage (matches upstream_idps[].id) |
default_upstream_idp | Default IdP for retrieval when the caller omits upstream_idp_id |
max_token_age_secs | Maximum record lifetime; entries are garbage-collected after this |
localhost_only | Restrict the deposit endpoint to local connections |
store_on_federation | Auto-store tokens from browser federation callbacks |
default_revoke_on_replace | Global default for per-IdP revoke_on_replace (applied to IPA-sourced IdPs) |
default_single_use | Global default for per-IdP single_use (applied to IPA-sourced IdPs) |
The store_for_idps list controls which IdPs get token storage regardless of
whether the IdP was defined in TOML or sourced from FreeIPA LDAP. This avoids
extending the IPA LDAP schema.
Encryption
Each token payload is encrypted as a single AES-256-GCM blob. The key is derived on-demand:
HKDF-SHA-256(wrapping_key, info="ahdapa-upstream-token-v1") → 32 bytes
The sealed blob format is nonce[12] || ciphertext || tag[16] (standard
aead::seal() layout). The entire UpstreamTokenPayload (access token,
refresh token, ID token, upstream subject, scope, refresh expiry) is serialized
as JSON and encrypted as a single unit.
CRDT Replication
Upstream tokens are replicated across cluster nodes via the gossip protocol
using an LwwMap<String, UpstreamTokenCrdtEntry>. The composite key is
"{local_sub}\0{upstream_iss}" — one slot per (user, upstream IdP) pair.
Multiple upstream IdPs each get their own slot for the same user.
Token replacement (last-writer-wins)
The LwwMap uses LWW (last-writer-wins) register semantics: when a user
re-authenticates via the same upstream IdP — for example, when a device
authorization flow triggers another federation login — the new token
replaces the previous one. The old encrypted blob is overwritten both
in memory and in the database. Only the most recent token set is retained.
By default, the previous access token issued by the upstream IdP remains
valid there until its natural expiry. When revoke_on_replace = true is
set on the upstream IdP (or as a global default in [upstream_token_store]),
ahdapa revokes the old access token at the upstream via RFC 7009 before
storing the replacement. When single_use = true, the token is deleted
from the CRDT after the first retrieval.
Expiry purging
Expired entries are purged by the gossip cleanup loop (both in-memory
via retain() and from the database). This periodic cleanup removes
entries whose expires_at timestamp has passed. Expiry purging is
independent of token replacement — it handles natural expiry, not
overwrites.
API Endpoints
Deposit: POST /api/upstream-token
Auth: Bearer token with upstream:deposit scope.
{
"upstream_idp_id": "entra-id",
"subject": "user@example.com",
"access_token": "eyJ...",
"refresh_token": "0.AVYA...",
"id_token": "eyJ...",
"scope": "openid profile User.Read",
"expires_in": 3600
}
Returns 204 No Content on success.
Retrieve: POST /api/upstream-token/retrieve
Auth: SPNEGO (Kerberos) or Bearer token with upstream:retrieve scope.
{
"upstream_idp_id": "entra-id"
}
If upstream_idp_id is omitted, default_upstream_idp from config is used.
The body may be empty.
Returns:
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 3542,
"scope": "openid profile User.Read"
}
If the stored access token has expired and a refresh token is available, the endpoint automatically refreshes against the upstream IdP’s token endpoint before returning. The refreshed token is stored back into the CRDT.
Token Lifecycle
- Deposit: tokens are encrypted and stored with a record expiry computed as
min(now + expires_in, now + max_token_age_secs). The CRDT key is"{local_sub}\0{upstream_iss}", so depositing tokens for the same (user, IdP) pair overwrites the previous entry (LWW semantics). - Retrieve: the encrypted blob is decrypted; if the access token is expired
and a refresh token exists, the upstream IdP is called with
grant_type=refresh_tokenand the result is stored back. - Replacement: when a user re-authenticates (e.g. via a new federation
login or device authorization flow), the new token replaces the old one
in the same slot. When
revoke_on_replaceis enabled, the old access token is revoked at the upstream IdP via RFC 7009 before the new one is stored; otherwise the old token remains valid until its natural expiry. - Expiry: the gossip cleanup loop removes entries past their
expires_at. - Replication: new/updated entries propagate to all cluster nodes via
gossip
LwwMapmerge semantics.
Audit Events
| Event | When |
|---|---|
upstream-token.deposited | Token successfully stored |
upstream-token.retrieved | Token returned to caller |
upstream-token.refreshed | Expired token refreshed from upstream |
upstream-token.refresh-failed | Refresh attempt failed |
upstream-token.revoked | Old token revoked at upstream IdP via RFC 7009 |
upstream-token.revocation-failed | Upstream revocation attempt failed |
Security Model
- Tokens are encrypted at rest (AES-256-GCM) with a key derived from the cluster wrapping key.
- The deposit endpoint requires a bearer token with explicit
upstream:depositscope and is optionally restricted to localhost. - The retrieval endpoint authenticates via SPNEGO (Kerberos) or bearer token
with
upstream:retrievescope. - Rate limiting applies to both endpoints.
- Refresh tokens are stored inside the encrypted blob and never exposed to callers; only the (refreshed) access token is returned.