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.

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:

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

  2. 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 the return_to parameter. 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
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.