//! Authentication and authorization for holger-server.
//!
//! Two separable concerns. AuthN (who are you): `validate_request` walks the
//! configured `AuthMethodConfig`s — OIDC Bearer (verified OFFLINE against a
//! cached/pre-provisioned JWKS, or ONLINE against `/userinfo` for opaque tokens)
//! or mTLS (leaf-cert CN) — and yields an `AuthIdentity { subject, method }`, or
//! `Ok(None)` when no method is configured (open access), or `Err` on invalid
//! creds. AuthZ (what may you do): declarative RBAC policy from the RON config
//! (`roles` map + `default_role`) resolved by `role_for` into a `Role`
//! (Reader < Writer < Admin, `Ord` follows declaration order). This is the seam
//! both call sites — `grpc.rs` and `exposed/http.rs` — hit before a write.
//!
//! RBAC is off unless any policy is set (`rbac_enabled`), preserving the
//! pre-RBAC authN-only behaviour; when on, an unmapped identity fails closed to
//! least-privilege `Reader`. OIDC validation is TTL-cached keyed by
//! `(issuer, blake3(token))` on a shared pooled `reqwest::Client`, so the raw
//! token is never held and a burst of writes costs one `/userinfo` round-trip.
//!
//! The one gotcha (M12): the mTLS method's `ca_cert` is NOT path-validated here
//! — client-cert trust is delegated entirely to the transport `client_ca`
//! (rustls handshake, see `exposed/tls.rs`); we only receive the extracted CN.
//! A broader `client_ca` therefore admits certs a narrower `ca_cert` meant to
//! exclude. `warn_unenforced_mtls_ca` shouts about this at boot.
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use std::time::Instant;
// `Duration` is used only by the online TTL helpers (OIDC/JWKS caches + the
// reqwest client timeouts); the airgap `mini` build compiles none of them.
#[cfg(feature = "online")]
use std::time::Duration;
use serde::{Deserialize, Serialize};
/// A coarse authorization role. Ordered least → most privileged (the derived
/// `Ord` follows declaration order: `Reader < Writer < Admin`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Role {
/// Download + list only.
Reader,
/// + upload / create repos (everything `Reader` can, plus writes).
Writer,
/// + promote / delete / manage config.
Admin,
}
impl Role {
/// May this role write (upload/put) artifacts?
pub fn can_write(self) -> bool {
matches!(self, Role::Writer | Role::Admin)
}
/// May this role perform admin actions (promote/delete/manage)?
pub fn can_admin(self) -> bool {
matches!(self, Role::Admin)
}
}
/// Auth configuration for holger-server.
///
/// `methods` is authentication (who are you); `roles`/`default_role` are
/// authorization (what may you do). Authorization is **declarative policy**:
/// it lives here in the RON config (git-tracked, reviewable, immutable in the
/// static/drift flavor), never in a mutable store.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Default)]
pub struct AuthConfig {
/// Which auth methods to accept. Empty = no auth (open access).
pub methods: Vec<AuthMethodConfig>,
/// Identity → role map. Identity is the OIDC `sub` claim or the mTLS client
/// cert CN (the same string `AuthIdentity::subject` carries). Absent/empty
/// (with no `default_role`) means **RBAC is off** — the server is
/// authentication-only and any valid identity may write, preserving the
/// pre-RBAC behaviour for existing configs.
#[serde(default)]
pub roles: HashMap<String, Role>,
/// Role granted to an authenticated identity that has no explicit mapping.
/// `None` + an empty `roles` map = RBAC off. When RBAC is on, an unmapped
/// identity falls back to this (or `Reader` — least privilege — if unset).
#[serde(default)]
pub default_role: Option<Role>,
/// **Per-repository** role overrides (ACL). Outer key = repository name; inner
/// map = identity → role FOR THAT REPO. A repo-scoped mapping WINS over the
/// global `roles`/`default_role`, so an identity can be a `Writer` on
/// `team-a-dev` yet only a `Reader` everywhere else — the airgap "who may push
/// to which shelf" control. Absent/empty ⇒ the global policy applies to every
/// repo (unchanged behaviour). Configuring any per-repo ACL turns RBAC on.
#[serde(default)]
pub repo_roles: HashMap<String, HashMap<String, Role>>,
}
impl AuthConfig {
/// RBAC is active iff any role policy is configured (a global mapping, a
/// default role, or any per-repo ACL). When inactive the write path is
/// authN-only.
pub fn rbac_enabled(&self) -> bool {
!self.roles.is_empty() || self.default_role.is_some() || !self.repo_roles.is_empty()
}
/// Resolve the role for an authenticated `subject`. An explicit mapping wins;
/// otherwise the configured `default_role`; otherwise `Reader` (least
/// privilege — fail closed).
pub fn role_for(&self, subject: &str) -> Role {
self.roles
.get(subject)
.copied()
.or(self.default_role)
.unwrap_or(Role::Reader)
}
/// Resolve the role for `subject` **scoped to `repo`**. A per-repo explicit
/// mapping (`repo_roles[repo][subject]`) wins; otherwise falls back to the
/// global [`role_for`](Self::role_for) (global explicit → `default_role` →
/// least-privilege `Reader`). This is the seam the write/admin gates call once
/// the target repository is known, so authorization can differ per repo.
pub fn role_for_repo(&self, repo: &str, subject: &str) -> Role {
if let Some(role) = self.repo_roles.get(repo).and_then(|m| m.get(subject)) {
return *role;
}
self.role_for(subject)
}
/// Make the mTLS trust decision EXPLICIT (M12 / P18): the transport's
/// `client_ca` (rustls handshake) is authoritative for client-cert trust;
/// the per-method `ca_cert` is NOT independently path-validated here (we only
/// receive the already-extracted leaf CN). If a method declares a non-empty
/// `ca_cert`, warn at startup so the operator knows it must equal the
/// transport `client_ca` — a broader `client_ca` would admit certs the
/// method's narrower `ca_cert` was meant to exclude. Called once at boot.
pub fn warn_unenforced_mtls_ca(&self) {
for method in &self.methods {
if let AuthMethodConfig::Mtls { ca_cert } = method {
if !ca_cert.trim().is_empty() {
log::warn!(
"SECURITY (mTLS): the per-method `ca_cert` is NOT independently \
enforced — the transport `client_ca` (TLS handshake) is authoritative \
for client-cert trust. Ensure `client_ca` equals this method's intended \
CA; a broader `client_ca` admits any cert valid under it."
);
}
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AuthMethodConfig {
/// Validate OIDC Bearer tokens. Two verification paths (see
/// `validate_oidc_bearer`): the **offline** path verifies a JWT's signature +
/// standard claims (`exp`/`iss`/`aud`) against a cached or pre-provisioned
/// JWKS with no network — the airgap path; the **online** path falls back to
/// the issuer's `/userinfo` for opaque (non-JWT) tokens when connected.
Oidc {
/// OIDC issuer URL (e.g. http://mannequin:9999). Also the expected `iss`
/// claim on the offline JWT path.
issuer_url: String,
/// Expected `aud` (audience) claim on the offline JWT path. `None` skips
/// the audience check (the `/userinfo` path never checked it either), so
/// existing configs are unaffected.
#[serde(default)]
audience: Option<String>,
/// Inline JWKS — the JSON `{"keys":[…]}` embedded **in the RON config
/// itself**. The fully-airgapped pre-provisioning path: no file, no
/// fetch, the keys travel with the sealed config.
#[serde(default)]
jwks: Option<String>,
/// Filesystem path to a pre-provisioned JWKS JSON file. Read + cached on
/// first use (airgap pre-provisioning by path), and the write-through
/// target when JWKS is fetched online so a later disconnected restart
/// still verifies.
#[serde(default)]
jwks_path: Option<String>,
/// Where to fetch the JWKS when ONLINE. Unset ⇒ `{issuer_url}/jwks` (the
/// OIDC discovery `jwks_uri`). Never contacted on the airgap path.
#[serde(default)]
jwks_uri: Option<String>,
},
/// Validate client certificates (mTLS). Requires TLS with client auth.
Mtls {
/// Trusted CA PEM for client certs.
ca_cert: String,
},
}
/// Resolved identity from auth validation.
#[derive(Debug, Clone)]
pub struct AuthIdentity {
pub subject: String,
pub method: String,
}
/// Validate an incoming request's auth.
/// Returns Some(identity) if valid, None if no auth configured (open access).
/// Returns Err if auth is configured but credentials are invalid.
pub async fn validate_request(
config: &AuthConfig,
bearer_token: Option<&str>,
client_cert_cn: Option<&str>,
) -> Result<Option<AuthIdentity>, AuthError> {
if config.methods.is_empty() {
return Ok(None); // No auth configured — open access
}
// Try each configured method
for method in &config.methods {
match method {
AuthMethodConfig::Oidc { .. } => {
if let Some(token) = bearer_token {
match validate_oidc_bearer(method, token).await {
Ok(subject) => return Ok(Some(AuthIdentity {
subject,
method: "oidc".into(),
})),
Err(_) => continue,
}
}
}
AuthMethodConfig::Mtls { ca_cert: _ } => {
// FLAGGED FOR REVIEW (M12): the method's `ca_cert` is NOT
// verified here. Client-cert trust is currently delegated
// entirely to the TLS layer's `client_ca` (see
// `exposed/tls.rs`): rustls validates the chain during the
// handshake and we only receive the already-extracted leaf CN.
// If `client_ca` is broader than this method's intended
// `ca_cert`, a cert valid under the broader CA is accepted with
// its CN as identity.
//
// Properly enforcing the method's `ca_cert` is a structural
// change, not a local fix: `validate_request` would need the
// full peer certificate chain (DER) — not just the CN string —
// plus a path-validation step (e.g. webpki) against the
// method's `ca_cert`, threaded through both call sites
// (`grpc.rs`, `exposed/http.rs`). Left unimplemented pending a
// review decision (verify-the-chain vs. drop the unused field /
// make `client_ca` authoritative). Not faked here.
if let Some(cn) = client_cert_cn {
return Ok(Some(AuthIdentity {
subject: cn.to_string(),
method: "mtls".into(),
}));
}
}
}
}
Err(AuthError::Unauthorized)
}
#[derive(Debug)]
pub enum AuthError {
Unauthorized,
}
/// How long a validated `(issuer, token)` → `subject` result is trusted before
/// the `/userinfo` endpoint is consulted again. Short enough that a revoked
/// token stops working quickly; long enough that a burst of writes with one
/// token costs a single round-trip. Override with `HOLGER_OIDC_CACHE_TTL_SECS`.
/// `online`-only: the token cache serves the outbound `/userinfo` path.
#[cfg(feature = "online")]
const OIDC_CACHE_TTL: Duration = Duration::from_secs(120);
#[cfg(feature = "online")]
fn oidc_cache_ttl() -> Duration {
std::env::var("HOLGER_OIDC_CACHE_TTL_SECS")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or(OIDC_CACHE_TTL)
}
/// One shared `reqwest::Client` for all OIDC validations: a connection pool +
/// reused TLS sessions instead of a fresh client (and fresh TCP+TLS handshake)
/// per write. Built once, lazily. Compiled ONLY under the `online` feature — the
/// airgap `mini` build links no HTTP client at all (see `Cargo.toml`).
#[cfg(feature = "online")]
fn oidc_client() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::Client::builder()
.pool_idle_timeout(Duration::from_secs(90))
.timeout(Duration::from_secs(10))
.build()
.unwrap_or_else(|_| reqwest::Client::new())
})
}
/// Cache of validated tokens. Key = `(issuer, blake3(token))` so the raw bearer
/// token is never held and keys can't collide into an auth bypass. Value =
/// `(subject, inserted_at)`; entries past the TTL are ignored and purged.
/// `online`-only (the `/userinfo` result cache).
#[cfg(feature = "online")]
type OidcCacheKey = (String, [u8; 32]);
#[cfg(feature = "online")]
fn oidc_cache() -> &'static Mutex<HashMap<OidcCacheKey, (String, Instant)>> {
static CACHE: OnceLock<Mutex<HashMap<OidcCacheKey, (String, Instant)>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
#[cfg(feature = "online")]
fn token_digest(token: &str) -> [u8; 32] {
*blake3::hash(token.as_bytes()).as_bytes()
}
/// Validate an OIDC Bearer token, returning its `subject` (`sub` claim).
///
/// Fast path: a cached, unexpired result for this `(issuer, token)` returns
/// without any network call. Slow path (cache miss/expiry): one `/userinfo`
/// round-trip on the shared pooled client, then the result is cached for the TTL.
///
/// Compiled ONLY under the `online` feature: this is the outbound-HTTP path, so
/// the airgap `mini` build does not carry it (opaque-token validation is
/// unavailable there and fails closed — see `validate_oidc_bearer`).
#[cfg(feature = "online")]
async fn validate_oidc_token(issuer_url: &str, token: &str) -> Result<String, AuthError> {
let ttl = oidc_cache_ttl();
let key: OidcCacheKey = (issuer_url.to_string(), token_digest(token));
// Fast path — cache hit within the TTL skips the round-trip.
if let Ok(cache) = oidc_cache().lock() {
if let Some((subject, inserted)) = cache.get(&key) {
if inserted.elapsed() < ttl {
return Ok(subject.clone());
}
}
}
let userinfo_url = format!("{}/userinfo", issuer_url.trim_end_matches('/'));
let resp = oidc_client()
.get(&userinfo_url)
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|_| AuthError::Unauthorized)?;
if !resp.status().is_success() {
return Err(AuthError::Unauthorized);
}
let body: serde_json::Value = resp.json().await.map_err(|_| AuthError::Unauthorized)?;
let subject = body
.get("sub")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or(AuthError::Unauthorized)?;
// Cache the fresh result; opportunistically purge expired entries so the map
// can't grow without bound under a churn of distinct tokens.
if let Ok(mut cache) = oidc_cache().lock() {
cache.retain(|_, (_, inserted)| inserted.elapsed() < ttl);
cache.insert(key, (subject.clone(), Instant::now()));
}
Ok(subject)
}
// ─── Offline / airgap OIDC (JWT-signature verification against a JWKS) ────────
//
// The `/userinfo` path above is ONLINE-only: it makes an outbound call, so it
// cannot serve a disconnected/airgapped deploy. This path verifies a bearer JWT
// like a PKI signature — validate the signature + `exp`/`iss`/`aud` against a
// **JWKS** — with zero network once the keys are in hand. Keys are resolved in
// order: (1) a fresh in-memory cache entry, (2) inline `jwks` in the RON config,
// (3) a pre-provisioned `jwks_path` file, (4) an online fetch from `jwks_uri`
// (write-through-persisted to `jwks_path` when set, so the next disconnected boot
// still verifies). (1)-(3) touch NO network — that is the airgap path.
/// How long a JWKS FETCHED ONLINE is trusted before it is refetched (to pick up
/// key rotation). Pre-provisioned keys (inline / file) never expire — they are
/// the operator's declared trust anchor. Override `HOLGER_JWKS_CACHE_TTL_SECS`.
/// `online`-only: only a fetched JWKS carries an expiry (pre-provisioned keys
/// never expire), so the TTL is dead code without the fetch path.
#[cfg(feature = "online")]
const JWKS_CACHE_TTL: Duration = Duration::from_secs(3600);
#[cfg(feature = "online")]
fn jwks_cache_ttl() -> Duration {
std::env::var("HOLGER_JWKS_CACHE_TTL_SECS")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or(JWKS_CACHE_TTL)
}
/// A cached JWKS for one issuer. `expires` is `None` for pre-provisioned keys
/// (inline / file — never auto-expire) and `Some(instant)` for an online fetch.
struct CachedJwks {
set: jsonwebtoken::jwk::JwkSet,
expires: Option<Instant>,
}
impl CachedJwks {
fn fresh(&self) -> bool {
match self.expires {
None => true,
Some(deadline) => Instant::now() < deadline,
}
}
}
/// Process-global JWKS cache, keyed by issuer URL. Keys inside are looked up by
/// `kid` at verify time (`JwkSet::find`).
fn jwks_cache() -> &'static Mutex<HashMap<String, CachedJwks>> {
static CACHE: OnceLock<Mutex<HashMap<String, CachedJwks>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
fn cache_jwks(issuer: &str, set: jsonwebtoken::jwk::JwkSet, expires: Option<Instant>) {
if let Ok(mut cache) = jwks_cache().lock() {
cache.insert(issuer.to_string(), CachedJwks { set, expires });
}
}
/// Test-only: drop every cached JWKS so a test starts from a clean slate.
#[cfg(test)]
fn clear_jwks_cache() {
if let Ok(mut c) = jwks_cache().lock() {
c.clear();
}
}
/// Parse a JWKS JSON document (`{"keys":[…]}`) into a `JwkSet`.
fn parse_jwks(json: &str) -> Result<jsonwebtoken::jwk::JwkSet, AuthError> {
serde_json::from_str(json).map_err(|_| AuthError::Unauthorized)
}
/// Resolve the JWKS for an OIDC method, honouring the offline-first order above.
/// The online branch write-through-persists the fetched set to `jwks_path` (when
/// configured) so a later disconnected boot verifies against the same keys.
async fn resolve_jwks(method: &AuthMethodConfig) -> Result<jsonwebtoken::jwk::JwkSet, AuthError> {
let AuthMethodConfig::Oidc { issuer_url, jwks, jwks_path, jwks_uri, .. } = method else {
return Err(AuthError::Unauthorized);
};
// (1) fresh in-memory cache entry — the common hit; no work.
if let Ok(cache) = jwks_cache().lock() {
if let Some(entry) = cache.get(issuer_url) {
if entry.fresh() {
return Ok(entry.set.clone());
}
}
}
// (2) inline JWKS in the RON config — fully airgapped, no file, no fetch.
if let Some(inline) = jwks.as_deref().filter(|s| !s.trim().is_empty()) {
let set = parse_jwks(inline)?;
cache_jwks(issuer_url, set.clone(), None);
return Ok(set);
}
// (3) pre-provisioned JWKS file — airgap by path.
if let Some(path) = jwks_path.as_deref().filter(|s| !s.trim().is_empty()) {
if let Ok(bytes) = std::fs::read_to_string(path) {
let set = parse_jwks(&bytes)?;
cache_jwks(issuer_url, set.clone(), None);
return Ok(set);
}
// file absent/unreadable → fall through to the online fetch, which then
// write-through-persists to this path for next time.
}
// (4) online fetch — the ONLY branch that touches the network. Compiled ONLY
// under the `online` feature; the airgap `mini` build stops after (1)-(3) and
// fails closed if no pre-provisioned key resolved.
#[cfg(feature = "online")]
{
let url = jwks_uri
.as_deref()
.filter(|s| !s.trim().is_empty())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("{}/jwks", issuer_url.trim_end_matches('/')));
let resp = oidc_client()
.get(&url)
.send()
.await
.map_err(|_| AuthError::Unauthorized)?;
if !resp.status().is_success() {
return Err(AuthError::Unauthorized);
}
let body = resp.text().await.map_err(|_| AuthError::Unauthorized)?;
let set = parse_jwks(&body)?;
// Write-through-persist so a later disconnected boot still verifies.
if let Some(path) = jwks_path.as_deref().filter(|s| !s.trim().is_empty()) {
if let Err(e) = std::fs::write(path, &body) {
log::warn!("holger auth: failed to persist fetched JWKS to {path}: {e}");
}
}
cache_jwks(issuer_url, set.clone(), Some(Instant::now() + jwks_cache_ttl()));
Ok(set)
}
// Airgap (`mini`) build: no HTTP client linked. Only pre-provisioned keys
// (in-memory cache / inline RON / file) verify — an unresolvable JWKS fails
// closed rather than reaching for the network.
#[cfg(not(feature = "online"))]
{
let _ = jwks_uri;
Err(AuthError::Unauthorized)
}
}
/// Minimal claims: only `sub` (the identity) is read out. `exp`/`iss`/`aud` are
/// validated by `jsonwebtoken` from the raw payload via the `Validation`, so
/// they need not appear here.
#[derive(serde::Deserialize)]
struct OidcClaims {
sub: String,
}
/// Does this bearer token look like a JWT (three base64url `.`-separated
/// segments)? Opaque OIDC access tokens (the mannequin's UUIDs) are not JWTs and
/// go to `/userinfo`; only JWTs can be verified offline.
fn looks_like_jwt(token: &str) -> bool {
token.split('.').count() == 3 && !token.starts_with('.')
}
/// Verify a bearer JWT OFFLINE against `method`'s JWKS. Checks the signature (key
/// selected by the header `kid`), `exp`, `iss` (must equal `issuer_url`), and
/// `aud` (when `audience` is configured). No network beyond the one-time JWKS
/// resolution — and that too is offline when the keys are pre-provisioned.
async fn verify_jwt_offline(method: &AuthMethodConfig, token: &str) -> Result<String, AuthError> {
use jsonwebtoken::{decode, decode_header, DecodingKey, Validation};
let AuthMethodConfig::Oidc { issuer_url, audience, .. } = method else {
return Err(AuthError::Unauthorized);
};
let header = decode_header(token).map_err(|_| AuthError::Unauthorized)?;
let jwks = resolve_jwks(method).await?;
// Select the key by `kid`; if the token carries none, accept the sole key.
let jwk = match header.kid.as_deref() {
Some(kid) => jwks.find(kid).ok_or(AuthError::Unauthorized)?,
None => {
if jwks.keys.len() == 1 {
&jwks.keys[0]
} else {
return Err(AuthError::Unauthorized);
}
}
};
let key = DecodingKey::from_jwk(jwk).map_err(|_| AuthError::Unauthorized)?;
let mut validation = Validation::new(header.alg);
validation.set_issuer(&[issuer_url.as_str()]);
match audience.as_deref() {
Some(aud) => validation.set_audience(&[aud]),
// No audience configured → don't require the claim (userinfo never did).
None => validation.validate_aud = false,
}
// `exp` is validated by default (validation.validate_exp == true).
let data =
decode::<OidcClaims>(token, &key, &validation).map_err(|_| AuthError::Unauthorized)?;
Ok(data.claims.sub)
}
/// Whether a JWKS is resolvable for this method WITHOUT a network call: a fresh
/// cache entry, inline config JWKS, or a readable `jwks_path` file. Decides
/// whether an offline-verify failure is authoritative (reject) or merely
/// "no keys — try `/userinfo`".
fn jwks_resolvable(method: &AuthMethodConfig) -> bool {
let AuthMethodConfig::Oidc { issuer_url, jwks, jwks_path, .. } = method else {
return false;
};
if let Ok(cache) = jwks_cache().lock() {
if cache.get(issuer_url).map(|e| e.fresh()).unwrap_or(false) {
return true;
}
}
if jwks.as_deref().map(|s| !s.trim().is_empty()).unwrap_or(false) {
return true;
}
if let Some(p) = jwks_path.as_deref().filter(|s| !s.trim().is_empty()) {
return std::path::Path::new(p).is_file();
}
false
}
/// Validate an OIDC bearer token, **offline-first**.
///
/// A JWT is verified OFFLINE against the JWKS (the airgap path). An opaque token
/// — or a JWT for which no JWKS is resolvable at all — falls back to the ONLINE
/// `/userinfo` round-trip. This keeps the connected behaviour intact while making
/// disconnected token auth work.
async fn validate_oidc_bearer(method: &AuthMethodConfig, token: &str) -> Result<String, AuthError> {
let AuthMethodConfig::Oidc { issuer_url, .. } = method else {
return Err(AuthError::Unauthorized);
};
if looks_like_jwt(token) {
match verify_jwt_offline(method, token).await {
Ok(sub) => return Ok(sub),
// A well-formed JWT that fails verification (bad signature / expired /
// wrong iss/aud) is REJECTED — we do NOT fall back to `/userinfo` for
// it (offline is authoritative once keys are resolvable). Only when NO
// JWKS is resolvable (e.g. none configured and offline) do we fall
// through to the online path.
Err(_) if jwks_resolvable(method) => return Err(AuthError::Unauthorized),
Err(_) => { /* no keys available — try the online userinfo path */ }
}
}
// Opaque token, or a JWT with no resolvable JWKS: fall back to `/userinfo`.
// That path is `online`-only; the airgap `mini` build has no HTTP client, so
// it fails closed here (only offline JWT verification is available there).
#[cfg(feature = "online")]
{
validate_oidc_token(issuer_url, token).await
}
#[cfg(not(feature = "online"))]
{
let _ = (issuer_url, token);
Err(AuthError::Unauthorized)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
/// A throwaway `/userinfo` that always says `sub=alice` and counts how many
/// TCP connections (i.e. real validations) it served. Returns the issuer URL.
#[cfg(feature = "online")]
async fn spawn_userinfo(hits: Arc<AtomicUsize>) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((mut sock, _)) = listener.accept().await {
hits.fetch_add(1, Ordering::SeqCst);
let mut buf = [0u8; 1024];
let _ = sock.read(&mut buf).await; // drain request (best-effort)
let body = br#"{"sub":"alice"}"#;
let head = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = sock.write_all(head.as_bytes()).await;
let _ = sock.write_all(body).await;
let _ = sock.flush().await;
}
});
format!("http://{addr}")
}
/// W1: the second validation of the same `(issuer, token)` is served from the
/// cache — the `/userinfo` endpoint is hit exactly once, not per write.
#[cfg(feature = "online")]
#[tokio::test]
async fn oidc_validation_caches_and_skips_repeat_userinfo_call() {
let hits = Arc::new(AtomicUsize::new(0));
// Unique token so this test's cache entry can't be touched by others
// sharing the process-global cache.
let token = "tok-w1-cache-test-7f3a9c";
let issuer = spawn_userinfo(hits.clone()).await;
let s1 = validate_oidc_token(&issuer, token).await.expect("first validate ok");
let s2 = validate_oidc_token(&issuer, token).await.expect("second validate ok");
assert_eq!(s1, "alice");
assert_eq!(s2, "alice");
assert_eq!(
hits.load(Ordering::SeqCst),
1,
"second validation must come from cache — /userinfo should be hit once, not twice"
);
}
/// A token the cache has never seen still triggers a real `/userinfo` call
/// (the cache is keyed per-token, so a different token is not a hit).
#[cfg(feature = "online")]
#[tokio::test]
async fn oidc_distinct_token_is_not_a_cache_hit() {
let hits = Arc::new(AtomicUsize::new(0));
let issuer = spawn_userinfo(hits.clone()).await;
let _ = validate_oidc_token(&issuer, "tok-w1-distinct-A-11").await.expect("A ok");
let _ = validate_oidc_token(&issuer, "tok-w1-distinct-B-22").await.expect("B ok");
assert_eq!(
hits.load(Ordering::SeqCst),
2,
"two distinct tokens must each validate against /userinfo"
);
}
// ── W2: role policy (authorization) ──────────────────────────────────────
#[test]
fn role_privilege_ordering_and_capabilities() {
assert!(Role::Reader < Role::Writer && Role::Writer < Role::Admin);
assert!(!Role::Reader.can_write());
assert!(Role::Writer.can_write());
assert!(Role::Admin.can_write());
assert!(!Role::Writer.can_admin());
assert!(Role::Admin.can_admin());
}
#[test]
fn rbac_is_off_until_policy_is_configured() {
// No roles, no default → RBAC inactive (authN-only, back-compat).
let open = AuthConfig::default();
assert!(!open.rbac_enabled());
// A single mapping turns it on.
let mut roles = HashMap::new();
roles.insert("alice".to_string(), Role::Writer);
let cfg = AuthConfig { methods: vec![], roles, default_role: None, ..Default::default() };
assert!(cfg.rbac_enabled());
// A default role alone also turns it on.
let cfg2 = AuthConfig { methods: vec![], roles: HashMap::new(), default_role: Some(Role::Reader), ..Default::default() };
assert!(cfg2.rbac_enabled());
// A per-repo ACL alone also turns it on.
let mut repo_roles = HashMap::new();
repo_roles.insert(
"team-a-dev".to_string(),
HashMap::from([("alice".to_string(), Role::Writer)]),
);
let cfg3 = AuthConfig { repo_roles, ..Default::default() };
assert!(cfg3.rbac_enabled());
}
#[test]
fn role_resolution_explicit_then_default_then_least_privilege() {
let mut roles = HashMap::new();
roles.insert("alice".to_string(), Role::Admin);
roles.insert("bot".to_string(), Role::Writer);
let cfg = AuthConfig { methods: vec![], roles, default_role: Some(Role::Reader), ..Default::default() };
// Explicit mapping wins.
assert_eq!(cfg.role_for("alice"), Role::Admin);
assert_eq!(cfg.role_for("bot"), Role::Writer);
// Unmapped identity falls back to default_role.
assert_eq!(cfg.role_for("stranger"), Role::Reader);
// With no default_role, an unmapped identity is least-privilege Reader.
let cfg_nodef = AuthConfig {
methods: vec![],
roles: HashMap::from([("bot".to_string(), Role::Writer)]),
default_role: None,
..Default::default()
};
assert_eq!(cfg_nodef.role_for("stranger"), Role::Reader);
assert!(!cfg_nodef.role_for("stranger").can_write(), "unmapped identity cannot write");
}
#[test]
fn per_repo_acl_overrides_global_role() {
// Global: alice=Reader, bob=Writer, default Reader.
let roles = HashMap::from([
("alice".to_string(), Role::Reader),
("bob".to_string(), Role::Writer),
]);
// Per-repo: on `team-a-dev`, alice is a Writer and bob is only a Reader.
let repo_roles = HashMap::from([(
"team-a-dev".to_string(),
HashMap::from([
("alice".to_string(), Role::Writer),
("bob".to_string(), Role::Reader),
]),
)]);
let cfg = AuthConfig {
methods: vec![],
roles,
default_role: Some(Role::Reader),
repo_roles,
};
// Per-repo mapping WINS on the scoped repo…
assert_eq!(cfg.role_for_repo("team-a-dev", "alice"), Role::Writer);
assert!(cfg.role_for_repo("team-a-dev", "alice").can_write());
assert_eq!(cfg.role_for_repo("team-a-dev", "bob"), Role::Reader);
assert!(!cfg.role_for_repo("team-a-dev", "bob").can_write(), "per-repo demotion holds");
// …and the GLOBAL role applies on any other repo.
assert_eq!(cfg.role_for_repo("other-repo", "alice"), Role::Reader);
assert_eq!(cfg.role_for_repo("other-repo", "bob"), Role::Writer);
// A subject with no per-repo entry falls back to global on the scoped repo.
assert_eq!(cfg.role_for_repo("team-a-dev", "stranger"), Role::Reader);
}
}
/// Shared test helpers for the offline-OIDC path — a throwaway RSA keypair, its
/// matching JWKS, and JWT minting — used by this crate's auth tests AND the gRPC
/// promotion tests (which need a signed admin/writer token to exercise the
/// RBAC-gated handler offline). `pub(crate)` + `#[cfg(test)]` so it's shared
/// within the crate's test build only, never in a release binary.
#[cfg(test)]
pub(crate) mod test_support {
use super::*;
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use std::time::{SystemTime, UNIX_EPOCH};
// A throwaway RSA-2048 test keypair. The private PEM signs test JWTs; the
// matching public modulus/exponent (`N`/`E`) go into the test JWKS. Generated
// once with openssl; used only in tests — never a real credential.
pub(crate) const TEST_KID: &str = "holger-test-key-1";
pub(crate) const TEST_RSA_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCfm1N85WUhzBGy\nLW66oLq+FgMfJb4tmHyxBZAs4jn/f4s5PiEIOe0stDtJZdZGK55ZC/5KxLAOnFtO\nBdSnmWpGhCSPmRiYHXlTNV0MJfPkXseje6toPtA3tZx2TYEYKJjthIUGeh0fWNqa\ndmuLiTLTZ/yC9XOxJ8p9lcWyxiYe2afSQt8/a7fMZHlporbcao7BFbZ4MmfCFTmI\n6PWb4ctiJQF0RYlIl7hn3DyKSoAtZAudTO0vc+maaqXcd7yDAUOMMCAU+FHS3snE\n84jY7vs3Jo70yq1qvXq32Mkg1j0OQhKycPnTmc9/lRRbDI5T08TtPxkAyxy33bJN\nqZA2RhDhAgMBAAECggEAAreegYEXSm7Cu12WJcPAQiW2SmS2WujecJYXaQg19BFc\nEa6q2czAqrLr0nlTH1E1Si9P1j6If8suK8MiJnNQcaBkzfQNjONtkhrVuXP49cGe\nVPxxQ95T5aroHcR+huaECgEoUZ6pGcRFvnz3IkJE9P/vdtxOaqzyf9ZZrgw9EGfd\n0VYwj0rlH+gECY6V/3tXzaxcsSOqe0yIX2nCV09WOLR+HQmGwHTeJ3zNbkG5RKJK\nUoluDfyLzHFf8BWEx/gWJjYoKP5exSAUkLMuUMvunZA37Hh+4w1nY5zu4AcNmJBS\nPLbcfFWkdtghwmJJ/wr/KlYz4dQJ2/xb9BR5HpxeUQKBgQDbgpGJQJuo0Uqg/lZE\nczcRbaAGjO6YTD2rxdjPT1EqyrXKiyYzLEqfzb7ZU8CDfm6R8G6FZ1RRHk4ZfNSc\nDotAyIT0+7fBpwofDusdIokQK5OLmb9FJiXuPBonbLoFlInIL5PtmPbx+fU6JfJp\nmng7KzQhDM5hPLPmGg/OPGgJOQKBgQC6I4X+wQu7gnCHsjmta1wma8zKl2EJWFr8\nFCP+0bcb6DTORzgMwBhp6kHjOmdzR8EAHFHZ5BedTj2diKjzfLGjejv5ypqdtZZx\nvsCov85G8BROgwsYhprGPdYJl60GD63lqcnmnOw33irBez4xfyNkSEv3FefPYS+p\nkdxdZ18M6QKBgD1KcM6NDqmNfrQDMB0+umKQZH1PvpMhvpqGzKnd8MDtBuN+BPWG\n6XNDZIWUIA3XMJJpOsLe/ikEODRfqZHFcdZt5snsuvtEY8wWQ0nISdMNB86T6Fcr\ndq3VdEwLzMkgF3Sg7EIMUu9vpqxMyin4apfC0XRk+f8mjuix4DXqofXpAoGBAJVq\nNIJFdeSDlU7nurLBTkwtFku9bhAxU3+rryU6NHD7WYAH3S8m97H1cAQ1epsoCv3K\nvc1Y8be8+Wq+K1igTHu8f+5IOwrDm8scYsxSgwOgbbyEJmD+k7j0JB65GROXfHCx\ndTdf2aIS/WEoS8kxlDhIbGoftF9cVQUZ57k2ZC8ZAoGBALH1TvYihhjyPXffs6nV\nS4s36ZmPPnbI0qPq4sgp6iWuSeK4hnSHbxI6tLrV/4On5szLOAAzjXIE5Sq2xkcC\n8mkWFtlPWHKqTWIeYU1pwii2zFMhLh1yI5fZnXPza1FUVzbHsYk9k231T8R713R5\ns2E2/CtglGHTEyXP/WIIiWWV\n-----END PRIVATE KEY-----\n";
pub(crate) const TEST_N: &str = "n5tTfOVlIcwRsi1uuqC6vhYDHyW-LZh8sQWQLOI5_3-LOT4hCDntLLQ7SWXWRiueWQv-SsSwDpxbTgXUp5lqRoQkj5kYmB15UzVdDCXz5F7Ho3uraD7QN7Wcdk2BGCiY7YSFBnodH1jamnZri4ky02f8gvVzsSfKfZXFssYmHtmn0kLfP2u3zGR5aaK23GqOwRW2eDJnwhU5iOj1m-HLYiUBdEWJSJe4Z9w8ikqALWQLnUztL3Ppmmql3He8gwFDjDAgFPhR0t7JxPOI2O77NyaO9Mqtar16t9jJINY9DkISsnD505nPf5UUWwyOU9PE7T8ZAMsct92yTamQNkYQ4Q";
pub(crate) const TEST_E: &str = "AQAB";
pub(crate) fn now() -> i64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64
}
pub(crate) fn jwks_json() -> String {
format!(
r#"{{"keys":[{{"kty":"RSA","use":"sig","alg":"RS256","kid":"{kid}","n":"{n}","e":"{e}"}}]}}"#,
kid = TEST_KID,
n = TEST_N,
e = TEST_E,
)
}
/// Sign a JWT (RS256, header `kid` = the test key) with arbitrary claims.
pub(crate) fn sign(claims: serde_json::Value) -> String {
let mut header = Header::new(Algorithm::RS256);
header.kid = Some(TEST_KID.to_string());
let key = EncodingKey::from_rsa_pem(TEST_RSA_PEM.as_bytes()).expect("valid rsa pem");
encode(&header, &claims, &key).expect("sign ok")
}
/// A standard-claims token for `issuer`/`aud`, expiring `exp` seconds from now
/// (negative ⇒ already expired).
pub(crate) fn token_for(issuer: &str, aud: &str, sub: &str, exp_offset: i64) -> String {
sign(serde_json::json!({
"sub": sub,
"iss": issuer,
"aud": aud,
"exp": now() + exp_offset,
"iat": now(),
}))
}
/// An `AuthMethodConfig::Oidc` that verifies fully offline against the test
/// JWKS (inline in the config) for `issuer`, audience `"holger"`.
pub(crate) fn oidc_offline_method(issuer: &str) -> AuthMethodConfig {
AuthMethodConfig::Oidc {
issuer_url: issuer.to_string(),
audience: Some("holger".into()),
jwks: Some(jwks_json()),
jwks_path: None,
jwks_uri: None,
}
}
}
// ── W1-airgap: OFFLINE OIDC (JWT-signature verify against a cached JWKS) ──────
#[cfg(test)]
mod offline_oidc_tests {
use super::test_support::*;
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
fn oidc_inline(issuer: &str) -> AuthMethodConfig {
AuthMethodConfig::Oidc {
issuer_url: issuer.to_string(),
audience: Some("holger".into()),
jwks: Some(jwks_json()),
jwks_path: None,
jwks_uri: None,
}
}
/// A valid JWT verifies fully OFFLINE against an inline (RON-provisioned) JWKS
/// — no network — and yields the `sub` as the identity.
#[tokio::test]
async fn valid_jwt_verifies_offline_against_inline_jwks() {
let issuer = "https://idp.example/valid";
let method = oidc_inline(issuer);
let token = token_for(issuer, "holger", "alice@corp", 3600);
let sub = validate_oidc_bearer(&method, &token).await.expect("valid token verifies");
assert_eq!(sub, "alice@corp");
}
/// An expired token is rejected (offline `exp` check).
#[tokio::test]
async fn expired_jwt_rejected() {
let issuer = "https://idp.example/expired";
let method = oidc_inline(issuer);
let token = token_for(issuer, "holger", "alice", -3600);
assert!(validate_oidc_bearer(&method, &token).await.is_err(), "expired token must be rejected");
}
/// A token whose signature does not match the JWKS key is rejected.
#[tokio::test]
async fn wrong_signature_jwt_rejected() {
let issuer = "https://idp.example/badsig";
let method = oidc_inline(issuer);
let good = token_for(issuer, "holger", "alice", 3600);
// Tamper the signature segment: flip its last character.
let mut parts: Vec<&str> = good.split('.').collect();
let sig = parts[2].to_string();
let last = sig.chars().last().unwrap();
let repl = if last == 'A' { 'B' } else { 'A' };
let tampered_sig = format!("{}{}", &sig[..sig.len() - 1], repl);
parts[2] = &tampered_sig;
let tampered = parts.join(".");
assert!(validate_oidc_bearer(&method, &tampered).await.is_err(), "bad signature must be rejected");
}
/// A token from the wrong issuer is rejected (offline `iss` check).
#[tokio::test]
async fn wrong_issuer_jwt_rejected() {
let issuer = "https://idp.example/goodiss";
let method = oidc_inline(issuer);
let token = token_for("https://evil.example/realm", "holger", "alice", 3600);
assert!(validate_oidc_bearer(&method, &token).await.is_err(), "wrong issuer must be rejected");
}
/// A token for the wrong audience is rejected (offline `aud` check).
#[tokio::test]
async fn wrong_audience_jwt_rejected() {
let issuer = "https://idp.example/goodaud";
let method = oidc_inline(issuer);
let token = token_for(issuer, "some-other-service", "alice", 3600);
assert!(validate_oidc_bearer(&method, &token).await.is_err(), "wrong audience must be rejected");
}
/// FULLY OFFLINE: a JWKS pre-provisioned as a file verifies a token with no
/// network at all. `jwks_uri` points at an unroutable address to prove the
/// file path wins and no fetch is attempted.
#[tokio::test]
async fn fully_offline_preprovisioned_jwks_file_succeeds() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("jwks.json");
std::fs::write(&path, jwks_json()).unwrap();
let issuer = "https://idp.example/airgap";
let method = AuthMethodConfig::Oidc {
issuer_url: issuer.to_string(),
audience: Some("holger".into()),
jwks: None,
jwks_path: Some(path.to_string_lossy().to_string()),
// Would fail if ever contacted — but the file path is used first.
jwks_uri: Some("http://127.0.0.1:1/never".into()),
};
let token = token_for(issuer, "holger", "airgap-bot", 3600);
let sub = validate_oidc_bearer(&method, &token).await.expect("airgap file JWKS verifies");
assert_eq!(sub, "airgap-bot");
}
/// A tiny JWKS HTTP endpoint that serves the test JWKS and counts how many
/// times it is hit — to prove the JWKS is fetched ONCE then cached.
#[cfg(feature = "online")]
async fn spawn_jwks(hits: Arc<AtomicUsize>) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((mut sock, _)) = listener.accept().await {
hits.fetch_add(1, Ordering::SeqCst);
let mut buf = [0u8; 1024];
let _ = sock.read(&mut buf).await;
let body = jwks_json();
let head = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = sock.write_all(head.as_bytes()).await;
let _ = sock.write_all(body.as_bytes()).await;
let _ = sock.flush().await;
}
});
format!("http://{addr}/jwks")
}
/// Cache miss → one fetch; subsequent verifies are cache HITS (zero further
/// network). The online branch is exercised, and the cache proven.
#[cfg(feature = "online")]
#[tokio::test]
async fn jwks_fetched_once_then_cached() {
let hits = Arc::new(AtomicUsize::new(0));
let jwks_uri = spawn_jwks(hits.clone()).await;
// Unique issuer so this test owns its own process-global cache entry.
let issuer = "https://idp.example/cache-test-unique";
clear_jwks_cache();
let method = AuthMethodConfig::Oidc {
issuer_url: issuer.to_string(),
audience: Some("holger".into()),
jwks: None,
jwks_path: None,
jwks_uri: Some(jwks_uri),
};
// Miss: no cache entry yet.
assert!(!jwks_resolvable(&method), "no keys resolvable before first fetch");
let t1 = token_for(issuer, "holger", "u1", 3600);
let t2 = token_for(issuer, "holger", "u2", 3600);
assert_eq!(validate_oidc_bearer(&method, &t1).await.unwrap(), "u1");
assert_eq!(validate_oidc_bearer(&method, &t2).await.unwrap(), "u2");
// Hit: the second verify used the cache — the endpoint saw exactly one fetch.
assert_eq!(hits.load(Ordering::SeqCst), 1, "JWKS must be fetched once then served from cache");
assert!(jwks_resolvable(&method), "keys resolvable from cache after fetch");
}
}