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

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:

  1. 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.

  2. Federation callback (opt-in): when store_on_federation = true, the existing browser-based federation redirect flow stores upstream tokens automatically after a successful login.

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
KeyDescription
enabledMaster switch for the token store feature
store_for_idpsIdP ids eligible for token storage (matches upstream_idps[].id)
default_upstream_idpDefault IdP for retrieval when the caller omits upstream_idp_id
max_token_age_secsMaximum record lifetime; entries are garbage-collected after this
localhost_onlyRestrict the deposit endpoint to local connections
store_on_federationAuto-store tokens from browser federation callbacks
default_revoke_on_replaceGlobal default for per-IdP revoke_on_replace (applied to IPA-sourced IdPs)
default_single_useGlobal 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

  1. 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).
  2. 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_token and the result is stored back.
  3. 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_replace is 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.
  4. Expiry: the gossip cleanup loop removes entries past their expires_at.
  5. Replication: new/updated entries propagate to all cluster nodes via gossip LwwMap merge semantics.

Audit Events

EventWhen
upstream-token.depositedToken successfully stored
upstream-token.retrievedToken returned to caller
upstream-token.refreshedExpired token refreshed from upstream
upstream-token.refresh-failedRefresh attempt failed
upstream-token.revokedOld token revoked at upstream IdP via RFC 7009
upstream-token.revocation-failedUpstream 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:deposit scope and is optionally restricted to localhost.
  • The retrieval endpoint authenticates via SPNEGO (Kerberos) or bearer token with upstream:retrieve scope.
  • 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.