Authentication
The src/auth/ module handles all user authentication and the cryptographic encoding of stateful objects that cross HTTP boundaries.
User authentication flows
SPNEGO / Kerberos (primary)
Implemented in src/auth/gssapi.rs using ahdapa-gssapi.
- The
/authorizehandler checks for a session cookie. If absent, it returns401 UnauthorizedwithWWW-Authenticate: Negotiate. - Domain-joined browsers respond automatically with a Kerberos service ticket. The GSSAPI acceptor (
GssServerCred::accept_token) verifies it. - On success,
AuthResult::Authenticated { sub, acr, amr }carries the principal name (sub), ACR class ("urn:oasis:names:tc:SAML:2.0:ac:classes:Kerberos"), and AMR (["kerberos"]). - A session cookie is sealed (AES-256-GCM) and set. All subsequent requests in the flow use the cookie.
Non-domain browsers receive no Kerberos ticket. The /login endpoint detects the absence of a Negotiate token and redirects to /ui/auth/login, where the user enters credentials.
Password authentication (fallback chain)
Password authentication in the login_post handler (src/routes/oauth2/mod.rs) walks three backends in order:
- Static users file (
src/auth/static_users.rs) — checked first if[users]is configured. Returns immediately on match or miss. - PAM (
src/auth/pam.rs,crates/ahdapa-pam/) — consulted when[pam]is present in config and the binary was compiled with--features pam. Callspam::verify()viaspawn_blockinginside atokio::time::timeout. Returns one ofAuthenticated,BadCredentials, orPasswordExpired. OnPasswordExpired(PAM_NEW_AUTHTOK_REQD), the handler redirects to/login/change-passwordinstead of completing the session. On timeout or panic, falls through to LDAP. - LDAP simple bind (
src/auth/ipa.rs,ahdapa-ldap) — fallback when PAM is absent or timed out. Constructs the full DNuid={username},{ipa.paths.user_base}(whereuser_baseiscn=users,cn=accounts,<discovered-suffix>, pre-computed inIpaState) and performs a simple bind.
On success from any backend, the handler returns AuthResult::Authenticated with ACR "urn:oasis:names:tc:SAML:2.0:ac:classes:Password" and AMR ["pwd"].
Expired password flow
When PAM returns PasswordExpired, the user is redirected to /login/change-password?username=…&next=…. The GET /login/change-password handler renders an HTML form collecting the old password and new password (entered twice). POST /login/change-password calls pam::change() (src/auth/pam.rs), which runs a pam_start / pam_authenticate / pam_chauthtok / pam_end sequence via spawn_blocking. On success a session cookie is issued identically to a normal login. This handler is compiled in unconditionally but returns a redirect to the login page when the pam feature is disabled.
Passkey (WebAuthn) authentication
Passkey authentication is a first-class flow alongside SPNEGO and password. It runs entirely via the login page JavaScript — no server-side redirect is involved — and is tried automatically before the password form is shown.
Login page flow
webui/src/auth/LoginPage.tsx runs this sequence when the user submits their username:
- Federated hint check — if the username matches a federation upstream, redirect to the external IdP and stop.
- Passkey probe — if the browser exposes
window.PublicKeyCredential, callPOST /api/auth/passkey/beginwith the username.- Returns a challenge, RP ID,
allow_credentialslist, anduser_verificationrequirement. - If the server returns 401/404 (no passkeys enrolled for this user), catch the error and fall through to step 3.
- Returns a challenge, RP ID,
- Authenticator prompt —
navigator.credentials.get()asks the platform or security key for an assertion. If the user dismisses it (NotAllowedError), fall through to step 4 silently. - Complete —
POST /api/auth/passkey/completewith the assertion. On{"ok":true}, redirect toreturnTo. The response also carries aSet-Cookie: session=…header. - Password form — shown only if step 2 or 3 indicated no passkeys or the user cancelled.
Server: passkey_begin (POST /api/auth/passkey/begin)
Implemented in src/routes/oauth2/passkey.rs. Rate-limited via the shared IP rate limiter. Requires ipa.passkey_rp_id to be set in config; returns 501 otherwise.
Credential lookup (building the allowCredentials list):
- Static users file —
ipapasskey-format strings from thepasskeysfield. - FreeIPA —
ipapasskeyattributes viaget_user(cached; sourced from IPA API or LDAP). - Local DB —
SELECT credential_id FROM user_passkeys WHERE sub = ?merged with the above (deduplicating by credential_id bytes).
The user_verification field in the response is "required" when get_passkey_uv_required returns true (from IPA API config_show or LDAP cn=passkeyconfig), "preferred" otherwise.
A row is inserted into passkey_challenges with a 300-second TTL.
Server: passkey_complete (POST /api/auth/passkey/complete)
Looks up and atomically deletes the challenge row. Resolves the full credential set identically to passkey_begin. Finds the matching credential by credential_id. Verifies the assertion via crate::auth::passkey::verify_assertion (P-256 ECDSA over the client data + authenticator data hash; RP ID hash and origin checked; sign count monotonicity enforced for local DB credentials).
On success:
- Updates
sign_countinuser_passkeysfor local DB credentials (LDAP credentials do not have a stored sign count — the field is unused for them). - Looks up the user’s groups via
get_userfor RBAC. - Issues a session cookie with ACR
"urn:oasis:names:tc:SAML:2.0:ac:classes:MobileOneFactorUnregistered"and AMR["hwk"]. - Writes an audit record (
passkey_login_success).
OTP authentication
Endpoint: POST /api/auth/otp (handled by src/routes/oauth2/otp.rs)
Flow:
- The JSON body
{"username":"…","password":"…","otp_code":"…"}is parsed. The@REALMsuffix is stripped from the username to obtain the bare uid. - The shared IP rate limiter is checked; exceeding the limit returns
429 Too Many Requests. auth::otp::verify(&state.ipa, username, password, otp_code)(src/auth/otp.rs) concatenatespassword + otp_codeinto a single bind credential, opens a fresh LDAP connection viaopen_conn(&state.ipa), and callsbind_otp_requiredin theahdapa-ldapcrate.bind_otp_requiredperforms a simple bind and attaches theOTP_REQUIRED_OIDclient control (2.16.840.1.113730.3.8.10.7) to the bind request. This tells FreeIPA’sipa-pwd-extopSLAPI plugin to reject the bind if no valid OTP code is appended to the password, even if the password alone would be correct.- The raw OTP secret (
ipatokenOTPkey) is never read by ahdapa. FreeIPA validates the credential entirely at bind time. - An LDAP error
invalidCredentials(code 49) returnsOk(false), which the route translates to401 Unauthorized. All other LDAP errors propagate as server errors. - On a successful bind, the concatenated credential is zeroed (filled with 0 bytes) before the blocking thread returns.
- Groups are fetched via GSSAPI and a session cookie is issued with:
acr = "urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken"amr = ["pwd", "otp"]
Token management: src/routes/me/mod.rs mounts self-service CRUD for OTP tokens at /api/me/:
list_otp_tokensbinds as the user via S4U2Self+S4U2Proxy, looks up the user’s actual LDAP DN, and searchescn=otp,<suffix>(wheresuffixis the domain base, e.g.dc=ipa,dc=test) with filter(&(objectClass=ipaToken)(ipatokenOwner=<user_dn>)). TheipatokenOTPkeyattribute is excluded from the result set.create_otp_tokengenerates a 160-bit random secret via OpenSSL, writes anipatokenTOTPentry tocn=otp,<suffix>withipatokenOwnerandmanagedByset to the user’s actual DN, returns theotpauth://URI once, then zeros the secret buffer.delete_otp_tokenverifies ownership before calling LDAP delete; returnsOk(false)for not-found or not-owned, which the route translates to404 Not Found.
FreeIPA ACIs: ahdapa uses S4U2Self+S4U2Proxy for all LDAP operations, so the LDAP server sees the connection as the authenticated user rather than the ahdapa service account. FreeIPA’s built-in self-service ACIs therefore apply directly: listing is permitted by ipatokenOwner#USERDN; creation is permitted by the ipatokenOwner#SELFDN + managedBy#SELFDN rule (both attributes are set to the owning user’s DN when the token is created); deletion is permitted by managedBy#USERDN. No custom ACI is required.
Session cookie
After SPNEGO, password, OTP, or passkey authentication succeeds, the handler creates a SessionClaims struct and seals it:
#![allow(unused)]
fn main() {
pub struct SessionClaims {
pub sub: String,
pub auth_time: i64,
pub acr: String,
pub amr: Vec<String>,
pub exp: i64,
}
}
The sealed value is set as the session cookie (HttpOnly; SameSite=Lax). Its lifetime is controlled by tokens.session_ttl (default 3600 s).
The sealing / unsealing functions are in src/auth/cookie.rs:
#![allow(unused)]
fn main() {
pub fn seal(key: &[u8; 32], claims: &SessionClaims) -> String;
pub fn unseal(key: &[u8; 32], encoded: &str) -> Option<SessionClaims>;
pub fn extract_from_header(cookie_header: &str) -> Option<&str>;
}
AEAD encoding
All stateful objects that survive HTTP round-trips (auth codes, refresh tokens, session cookies, consent cookies) use the same AES-256-GCM scheme, implemented in src/auth/aead.rs:
wire = base64url( nonce[12] || AES-256-GCM-ciphertext || GCM-tag[16] )
native-ossl provides the cipher and the RNG:
#![allow(unused)]
fn main() {
// src/auth/aead.rs
pub fn seal(key: &[u8; 32], plaintext: &[u8]) -> Vec<u8>;
pub fn open(key: &[u8; 32], blob: &[u8]) -> Result<Vec<u8>, AeadError>;
}
The 12-byte nonce is generated fresh for every seal operation. The GCM tag is appended to the ciphertext by OpenSSL; open verifies it and returns an error on any tampering.
Authorization code
Implemented in src/auth/code.rs.
The authorization code is a base64url-encoded AEAD blob containing AuthCodePayload:
#![allow(unused)]
fn main() {
pub struct AuthCodePayload {
pub sub: String,
pub auth_time: i64,
pub acr: String,
pub amr: Vec<String>,
pub client_id: String,
pub redirect_uri: String,
pub scope: String,
pub code_challenge: String,
pub code_challenge_method: String,
pub nonce: Option<String>,
pub resource: Option<String>,
pub iat: i64,
pub exp: i64,
pub issuer: String,
}
}
The AEAD key is AppState::wrapping_key (the cluster wrapping key). Any node holding that key can decrypt and validate a code issued by any other node — this is the statelessness property.
AuthCodePayload::encode(key) → base64url string passed as the code query parameter.AuthCodePayload::decode(encoded, key) → Option<Self>, returning None on any AEAD failure.AuthCodePayload::is_expired() → now >= exp.
Consent cookie
Implemented in src/auth/consent.rs.
When /authorize determines that the user is authenticated and PKCE is valid, it builds a ConsentPayload containing all validated authorization-request parameters, seals it, and redirects to /ui/auth/consent. The Preact SPA fetches /api/auth/consent (GET) to display the consent screen, then posts /api/auth/consent (POST) with {allow: bool}.
#![allow(unused)]
fn main() {
pub struct ConsentPayload {
pub sub, auth_time, acr, amr,
pub client_id, client_name, redirect_uri, scope,
pub state, code_challenge, code_challenge_method,
pub nonce, iat, exp, issuer
}
}
TTL: 120 seconds (CONSENT_TTL). The cookie is cleared (Max-Age=0) after the user acts on it (allow or deny).
Refresh token
Implemented in src/auth/refresh.rs.
Refresh tokens use the same AEAD scheme as auth codes, but with a separate HKDF-derived key (AppState::refresh_key). This means a refresh token blob cannot be decrypted using the wrapping key, and vice versa.
#![allow(unused)]
fn main() {
pub struct RefreshTokenPayload {
pub sub: String,
pub client_id: String,
pub scope: String,
pub iat: i64,
pub exp: i64,
pub family_id: String, // UUIDv4 — ties rotation chain together
pub token_index: u64, // monotonically increasing within a family
pub issuer: String,
}
}
Rotation: every redemption issues a new refresh token with token_index + 1. The CRDT refresh_families LWW-Map tracks the highest token_index seen per family across all nodes. A redemption is rejected if token_index < max_seen_index, detecting replay of stolen tokens.
Revocation: setting max_index = u64::MAX in the CRDT (via admin API) permanently invalidates the family. This value propagates to all nodes via gossip.
PKCE
Implemented in src/auth/pkce.rs. Only S256 (code_challenge = BASE64URL(SHA256(verifier))) is supported. The plain method is rejected because it provides no security beyond a client secret.
#![allow(unused)]
fn main() {
pub fn verify_s256(code_verifier: &str, code_challenge: &str) -> bool;
}
User attribute and group lookup
FullUserEntry
All three attribute backends (static users file, varlink, and LDAP) return a FullUserEntry struct:
#![allow(unused)]
fn main() {
// src/auth/ldap.rs
pub struct FullUserEntry {
pub name: Option<String>,
pub given_name: Option<String>,
pub family_name: Option<String>,
pub email: Option<String>,
pub groups: Vec<String>,
pub passkey_credentials: Vec<PasskeyCredential>,
}
}
Having a single type for all backends means the caller (UserInfo, ID token issuance, RBAC group resolution) does not need to know which backend supplied the data.
Lookup chain
Profile attributes (name, given_name, family_name, email) and group memberships are resolved through a three-step fallback chain during UserInfo requests and ID token issuance when the profile or email scope is present:
- Static users file (
src/auth/static_users.rs) — checked first if[users]is configured. Returns attributes and groups directly from the TOML file. - Varlink system userdb (
src/auth/varlink.rs,crates/ahdapa-varlink/) — consulted when[varlink]is present in config and the binary was compiled with--features varlink. Queries theio.systemd.UserDatabasevarlink interface (via thekirmescrate bridged from smol to tokio withspawn_blocking). Password authentication is not performed here; onlylookup_userandlookup_groupsare called. - FreeIPA (
src/auth/ipa.rs) — fallback when neither of the above returns a result. Uses the IPA JSON-RPC API by default for non-on-server deployments, or direct LDAP whenuse_ldap = trueorldapi://is configured.
Password authentication uses the static users file, then PAM (if [pam] is configured and the binary was built with --features pam), then LDAP simple bind — varlink is not in the password verification path.
Attribute lookup: lookup_user_full and get_user
lookup_user_full (src/auth/ipa.rs) performs all attribute fetching in one backend call. In LDAP mode: one connection and at most two searches:
- Opens one connection, performs one GSSAPI bind (optionally S4U2Self-impersonating the target user when
gssapi.initiator_principalis configured). - Fetches
cn,givenName,sn,mail,memberOf, andipapasskeyin a singleposixAccountsearch. - If
memberOfis empty (the user has no group backlinks), issues a second search forposixGroupentries wherememberUidmatches — this handles IPA configurations that do not emitmemberOffor posix groups. - Returns a
FullUserEntrywith sorted, deduplicated groups.
get_user wraps lookup_user_full (or the IPA API equivalent) with a short-lived in-memory cache:
- Cache key: the bare uid (part before
@), soalice@REALMandaliceshare one entry. - TTL:
config.ipa.cache_ttl_secs(default 60 s). Set to0to disable caching. - Eviction: when the cache exceeds 200 entries, entries older than
2 × TTLare evicted.
The cache is stored in IpaState::attr_cache (inside state.ipa), a Mutex<HashMap<String, (FullUserEntry, Instant)>>.
Passkey UV policy cache: get_passkey_uv_required
get_passkey_uv_required (src/auth/ipa.rs) reads the ipaRequireUserVerification attribute (from the IPA API config_show call, or from LDAP cn=passkeyconfig,cn=etc,<suffix> when in LDAP mode, where suffix is stored in IpaState::paths.passkey_cfg) and caches the result for 300 seconds in IpaState::uv_cache (inside state.ipa). Returns true (require UV) on any lookup failure or when the attribute is absent.
IPA passkey self-service write path
When a user registered via FreeIPA LDAP enrolls or removes a passkey through the self-service UI, the credential must be written to the ipapasskey attribute on their LDAP entry rather than the local user_passkeys database table. This keeps FreeIPA as the authoritative store for IPA users’ credentials, making passkeys visible to other FreeIPA-aware services and surviving local DB rebuilds.
IPA user detection (is_ipa gate)
All three passkey management handlers (passkey_register_complete, list_passkeys, delete_passkey) in src/routes/oauth2/passkey.rs evaluate the same gate before deciding where to read or write:
#![allow(unused)]
fn main() {
let is_ipa = !state.ipa.uri.is_empty()
&& state.ipa.use_gssapi
&& crate::auth::ipa::get_user(&state.ipa, &claims.sub).await.is_some();
}
All three conditions must hold: LDAP must be configured, a GSS initiator (used for S4U2Self impersonation) must be available, and the user must actually exist in LDAP. Static users and PAM-only users fall through to the local DB path.
IPA write helpers
src/auth/ipa.rs provides two public async functions that dispatch to either the IPA API or LDAP:
add_ipapasskey(ipa: &IpaState, sub: &str, credential_id, public_key_cose)— in IPA API mode: callsuser_mod_ipapasskey(uid, "addattr", val); in LDAP mode: usesconnect_as_user+conn.modify(dn, [(ModOp::Add, "ipapasskey", [attr_val])]).delete_ipapasskey(ipa: &IpaState, sub: &str, attr_val)— in IPA API mode: callsuser_mod_ipapasskey(uid, "delattr", val); in LDAP mode: usesconnect_as_user+conn.modify(dn, [(ModOp::Delete, "ipapasskey", [attr_val])]).attr_valmust be the exact string stored in IPA.
On delete, the route handler reconstructs the exact passkey:...,… string from the cached FullUserEntry (to find the matching public_key_cose from the given credential_id) before calling delete_ipapasskey.
LDAP modify support (ahdapa-ldap)
The ahdapa-ldap crate now exposes synchronous and async LDAP modify:
ffi::LDAPMod/ffi::ldap_modify_ext_s/ffi::LDAP_MOD_{ADD,DELETE,BVALUES}— raw FFI incrates/ahdapa-ldap/src/ffi.rs.LdapConnection::modify_s(&mut self, dn, mods)(crates/ahdapa-ldap/src/conn.rs) — synchronous method;modsis&[(ModOp, &str, Vec<&[u8]>)]. Builds stack-localberval/LDAPModarrays with a NULL-terminated pointer array and callsldap_modify_ext_s.AsyncLdapConnection::modify(dn, mods)(crates/ahdapa-ldap/src/async_conn.rs) — async wrapper dispatching tospawn_blocking, mirroring the existingsearchpattern.
Cache invalidation after writes
After add_ipapasskey or delete_ipapasskey succeeds, the passkey route handlers remove the user’s entry from state.ipa.attr_cache so the next get_user call re-fetches from the backend and reflects the change immediately.
Passkey credential identifier
The GET /api/auth/passkeys response (StoredPasskey) uses id: String — a base64url-encoded (no padding) raw credential ID — for both IPA users (sourced from LDAP) and local DB users. The DELETE /api/auth/passkeys/{id} path parameter carries the same base64url string.
Kerberos machine client authentication (kerberos_client_auth)
Clients registered with token_endpoint_auth_method = "kerberos_client_auth" authenticate at
the token endpoint by presenting a Kerberos AP-REQ in Authorization: Negotiate. The handler
in src/routes/oauth2/mod.rs calls crate::auth::gssapi::try_spnego() to verify the token and
extract the authenticated principal string. Multi-round GSSAPI exchanges are not supported on
the token endpoint — the AP-REQ must complete in a single round trip.
After SPNEGO, the handler checks the extracted principal against the registered value:
- Single-machine clients (
kerberos_principalset): exact case-insensitive string match. - Template clients (
kerberos_principal_patternset): glob match viakerberos_glob_match().*matches any characters except@; the realm suffix must match exactly.
If kerberos_hbac_service is set on the client, the HBAC rule set is evaluated for the machine
principal before issuing a token. An empty rule set is treated as deny (fail-closed). Current
limitation: hostgroup membership is not resolved for machine principals — only individual hostname
rules in the HBAC service work.
For template clients the effective_sub in the issued token is set to the actual authenticated
machine principal (e.g. host/node1.example.com@REALM) rather than the template client_id,
making individual machines distinguishable in audit logs.
kerberos_client_auth requires state.ipa.use_gssapi to be true (derived from [ipa] gssapi
in the configuration). The method is conditionally advertised in discovery documents; it is
excluded from POST /register dynamic registration.
mTLS client authentication (RFC 8705)
Clients may authenticate at the token endpoint using a mutual-TLS certificate (tls_client_auth or self_signed_tls_client_auth). Authentication is at the application layer: the TLS handshake only proves possession of the private key; chain and CA validation are intentionally skipped. The token endpoint computes the SHA-256 thumbprint of the presented certificate and compares it against the thumbprint registered for the client.
Certificate delivery paths
Two paths supply the client certificate DER to the token endpoint handler:
Native TLS (ahdapa terminates TLS): the serve function in src/tls.rs extracts the peer’s leaf certificate from tls_stream.get_ref().1.peer_certificates() after the handshake and injects it as a PeerCertificate request extension. The AcceptAnyClientCert verifier requests but does not require a client certificate (client_auth_mandatory() = false) and accepts any certificate without chain validation.
Reverse proxy: extract_peer_cert in src/routes/oauth2/mod.rs reads the header named by config.tls.client_cert_header (e.g. X-Client-Cert) from requests originating within config.tls.trusted_proxy_cidrs. The header value may be URL-encoded (%xx escaping, as produced by nginx $ssl_client_escaped_cert). If the request IP is outside the trusted CIDRs, the header is ignored.
Client registration
The admin API POST /api/admin/clients and PUT /api/admin/clients/{id} accept a PEM-encoded certificate in the tls_client_certificate field. compute_tls_thumbprint (src/routes/admin.rs) converts it to a lowercase hex SHA-256 thumbprint, which is the only value stored in the CRDT and the database. The original PEM is never persisted.
Atomic key rotation
AppState::key_pair_rw is an Arc<RwLock<([u8;32],[u8;32])>> holding the cluster wrapping key and the derived refresh-token sub-key as a pair:
#![allow(unused)]
fn main() {
pub key_pair_rw: Arc<RwLock<([u8; 32], [u8; 32])>>,
}
The refresh key is always derived from the wrapping key via HKDF (derive_refresh_key in src/routes/mod.rs). Both keys are read or written under the same lock, so a concurrent request can never observe a wrapping key from one rotation epoch paired with a refresh key from another. wrapping_key() and refresh_key() are cheap helpers that take a read lock and copy the relevant 32 bytes.
When PUT /api/admin/keys/cluster is called, set_cluster_key in src/routes/admin.rs:
- Derives the new refresh key from the supplied wrapping key.
- Acquires the write lock and atomically replaces the pair.
- Updates the CRDT LWW register and persists to the database.
Because the pair is written atomically, any request that begins after the write sees the consistent new pair, and any request that began before it completes with the previous pair. There is no window where the two keys are mismatched.