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

Tokens and Cryptography

This chapter covers the cryptographic properties of every token type Ahdapa issues and the library calls used to produce them.

Crypto library

All cryptographic primitives are provided by native-ossl, a safe Rust wrapper around OpenSSL 3.x. synta-certificate provides certificate building and key management traits on top of native-ossl.

Do not use ring, aws-lc-rs, jsonwebtoken, or hmac crates directly. All symmetric and asymmetric crypto must go through native-ossl.

JWT access tokens (RFC 9068)

JWT access tokens are signed JWTs. Each node signs tokens with its own key pair stored in node_keys.jwt_signing_priv_der. The algorithm is determined by [server] jwt_signing_algorithm (default: ES256 / ECDSA P-256). The private key never leaves the originating node. The kid in the JWT header identifies which node’s key was used. The signature is produced via synta_certificate::BackendPrivateKey. Resource servers verify tokens using the JWKS endpoint, which serves all nodes’ public keys (gossiped via SigningKeyEntry).

Typical claims (password login example):

{
  "iss": "https://idp.example.com",
  "sub": "alice@EXAMPLE.COM",
  "aud": ["https://api.example.com"],
  "iat": 1715000000,
  "exp": 1715000900,
  "jti": "<unique>",
  "client_id": "...",
  "scope": "openid profile",
  "acr": "urn:oasis:names:tc:SAML:2.0:ac:classes:Password",
  "amr": ["pwd"]
}

acr and amr reflect the authentication method used when the session was established. See Authentication context claims below.

cnf claim — token binding (RFC 8705 §3, RFC 9449)

When the client authenticated using DPoP (Authorization: DPoP) the token contains a cnf object with a jkt member — the JWK thumbprint (SHA-256) of the DPoP public key:

{
  "cnf": {
    "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"
  }
}

When the client authenticated using tls_client_auth or self_signed_tls_client_auth (RFC 8705 §2), the token contains a cnf object with an x5t#S256 member — the base64url-encoded SHA-256 of the DER-encoded client certificate:

{
  "cnf": {
    "x5t#S256": "bwcK0esc3ACC3DB2Y5_lESsXE8o9ltc05O89jdN-dg2"
  }
}

When both DPoP and mTLS are used simultaneously, both members appear in the same cnf object:

{
  "cnf": {
    "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I",
    "x5t#S256": "bwcK0esc3ACC3DB2Y5_lESsXE8o9ltc05O89jdN-dg2"
  }
}

The cnf claim is absent when neither DPoP nor mTLS client authentication is used. Resource servers that enforce sender-constrained tokens must reject access tokens whose cnf.x5t#S256 value does not match the client certificate on the incoming mTLS connection, or whose cnf.jkt value does not match the DPoP public key proved in the DPoP header.

The x5t#S256 thumbprint is computed as base64url(SHA-256(DER)) where DER is the raw DER encoding of the client certificate (cert_x5t_s256 in src/routes/oauth2/mod.rs). In cluster forwarding and strict modes, the thumbprint is carried in InternalAuthCodeRequest.x5t so that the origin node — which issues the final token — can embed it even though it never directly observed the client certificate.

Access tokens are never stored. Any node can validate them by fetching the JWKS once and caching the public key locally.

Cross-node iss validation: verify_bearer_jwt (used by /userinfo and /introspect) accepts a token whose iss claim equals the local [server] issuer, any URL in gossip.peers, or any URL in state.dynamic_peers (IPA topology peers). The cryptographic guarantee is provided by the gossiped signing key — the iss check confirms the token came from a known cluster member. This means introspecting a token issued by any peer node succeeds on any other node without session forwarding.

ID tokens (OIDC Core §2)

ID tokens follow the same signing scheme as access tokens. Additional claims:

{
  "nonce": "<client-supplied nonce>",
  "auth_time": 1715000000,
  "at_hash": "<access-token hash>",
  "name": "Alice Smith",
  "given_name": "Alice",
  "family_name": "Smith",
  "email": "alice@example.com"
}

name, given_name, family_name, and email are populated from FreeIPA LDAP when the profile or email scope is granted. If the LDAP lookup fails, those claims are omitted rather than returning an error.

JWKS endpoint (/jwks)

Serves all non-expired signing keys as a JWK Set. Only the public key components are exported. Key kid values are base64url(SHA256(SPKI-DER)[..8]) — 8 bytes gives ~64-bit collision resistance, sufficient for a small key set.

Resource servers should cache the JWKS using the Cache-Control header (max-age=3600). When a token arrives with an unknown kid, the resource server should re-fetch the JWKS once before rejecting the token.

Authorization codes

Wire format:

code = base64url( nonce[12] || AES-256-GCM(wrapping_key, json_payload) || tag[16] )

The json_payload is AuthCodePayload serialised to JSON. The 12-byte nonce is unique per code. The GCM tag authenticates both nonce and ciphertext.

The code is passed as a URL query parameter (redirect_uri?code=...&iss=...). Its maximum size is ~300 bytes (12 + ~250 + 16 bytes, base64url-encoded), well within URL limits.

No server-side storage. Codes expire after tokens.auth_code_ttl seconds (default 60). The is_expired() check runs at /token redemption.

Refresh tokens

Wire format: identical to authorization codes but keyed by refresh_key (HKDF-derived from wrapping_key with info "ahdapa-refresh-v1"). The key derivation ensures the two token types cannot be cross-decoded:

#![allow(unused)]
fn main() {
// src/routes/mod.rs
fn derive_refresh_key(wrapping_key: &[u8; 32]) -> Result<Arc<[u8; 32]>, DbError> {
    let sha256 = DigestAlg::fetch(c"SHA256", None)?;
    let mut k = [0u8; 32];
    HkdfBuilder::new(&sha256)
        .key(wrapping_key)
        .info(b"ahdapa-refresh-v1")
        .derive(&mut k)?;
    Ok(Arc::new(k))
}
}

Refresh tokens carry a family_id and token_index. The rotation protocol:

  1. Client presents a refresh token with {family_id, token_index: N}.
  2. Server looks up max_seen_index for family_id in the CRDT.
  3. If N < max_seen_index: reject (replay of a previously used token — possible theft). Revoke the family by setting max_index = u64::MAX.
  4. If N >= max_seen_index: accept. Issue new access token + new refresh token with token_index: N+1. Update CRDT max_seen_index = N+1.

Session cookies

Session cookies are sealed SessionClaims blobs using the cluster wrapping key:

session = base64url( nonce[12] || AES-256-GCM(wrapping_key, json_claims) || tag[16] )

Set with HttpOnly; SameSite=Lax; Max-Age={session_ttl}. The browser cannot read or modify the contents. Any node in the cluster can validate a session cookie because all nodes share the wrapping key via CRDT.

Same wire format as session cookies but a distinct name (consent) and a 120-second TTL. Carry ConsentPayload (all validated /authorize parameters). Cleared after the user clicks Allow or Deny.

Cluster wrapping key

The wrapping key is stored node-locally in node_keys.wrapping_key_cms_der as a CMS EnvelopedData blob sealed to the node’s own ML-KEM-768 public key. The 32-byte key never appears in gossip payloads. Only a UUID (wrapping_key_id) is gossiped; when a node sees a new UUID it pulls the actual key via GET /api/gossip/wrapping-key.

On bootstrap (empty database), a fresh 32-byte key is generated with native_ossl::rand::Rand::bytes(32), sealed to the node’s own KEM key with seal_raw(), and stored in node_keys. The UUID is published to the CRDT with timestamp=1 so that the established cluster’s UUID wins the LWW merge after the first gossip round.

See the Gossip chapter for the full CMS protocol and wrapping key pull flow.

Random number generation

All nonces and UUIDs use cryptographically secure randomness:

  • AEAD nonces: native_ossl::rand::Rand::bytes(12)
  • Client IDs, JTIs, family IDs: uuid::Uuid::new_v4() (calls getrandom internally)

Algorithm identifiers

Token typeAlgorithmKey source
JWT access tokenConfigured by jwt_signing_algorithm (default: ES256)node_keys.jwt_signing_priv_der (per-node; private key never gossiped)
ID tokenConfigured by jwt_signing_algorithm (default: ES256)node_keys.jwt_signing_priv_der (per-node; private key never gossiped)
Authorization codeAES-256-GCMAppState::wrapping_key_rw
Refresh tokenAES-256-GCMAppState::refresh_key_rw (HKDF)
Session cookieAES-256-GCMAppState::wrapping_key_rw
Consent cookieAES-256-GCMAppState::wrapping_key_rw
Gossip body (outer)Ed25519 SignedDataPer-node gossip signing key (node_keys)
Gossip body (inner)AES-256-GCM EnvelopedDataEphemeral CEK, ML-KEM-768 encapsulated
Cluster wrapping key (at rest)AES-256-GCM EnvelopedDataEphemeral CEK, ML-KEM-768 per-node

Authentication context claims (acr and amr)

Access tokens and ID tokens include acr (SAML 2.0 Authentication Context Class) and amr (RFC 8176 Authentication Method Reference) claims that describe how the user authenticated. They are set at session creation and carried unchanged into every renewed token for the lifetime of the refresh token family.

Authentication methodacramr
SPNEGO / Kerberosurn:oasis:names:tc:SAML:2.0:ac:classes:Kerberos["kerberos"]
Password (static, PAM, LDAP)urn:oasis:names:tc:SAML:2.0:ac:classes:Password["pwd"]
Password + OTP (TOTP/HOTP)urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken["pwd", "otp"]
Passkey / WebAuthnurn:oasis:names:tc:SAML:2.0:ac:classes:MobileOneFactorContract["hwk"]
Federated upstream IdPForwarded from upstream ID tokenForwarded from upstream ID token

Machine-to-machine grant types (client_credentials, token_exchange, jwt_bearer, device_code) do not set acr or amr.

All four user-facing ACR values are advertised in the OIDC discovery document under acr_values_supported. The set of supported values lives in src/routes/discovery.rs:acr_values_supported; keep it in sync with any new auth method that sets a different ACR.

MobileOneFactorContract is the SAML AC class for authentication with a registered, hardware-bound credential (SAML AC §3.4). It is the correct class for FIDO2/WebAuthn passkeys — MobileOneFactorUnregistered would imply an unregistered or disposable device such as a one-time SMS token. hwk (RFC 8176 §2) indicates a hardware-protected cryptographic key.

TimeSyncToken is the SAML AC class for authentication using a time-synchronized one-time password (TOTP) or counter-based one-time password (HOTP), as defined in SAML 2.0 Authentication Context §3.4. The otp AMR value (RFC 8176 §2) indicates an OTP factor was used. The combined ["pwd", "otp"] AMR indicates password-plus-OTP multi-factor authentication.