Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-auth-oidc/src/lib.rs

//! # nornir-auth-oidc
//!
//! The **OIDC / OAuth2** auth provider. It covers the modern enterprise IdPs —
//! Microsoft Entra ID (the ADFS successor), Okta, Auth0, Google, Keycloak,
//! Zitadel — through the ONE OpenID-Connect protocol, so a single provider
//! authenticates them all. Two verification paths, mirroring holger's airgap
//! doctrine:
//!
//! * **offline** ([`verify_jwt_offline`]) — verify a bearer JWT's signature +
//!   standard claims (`exp`/`iss`/`aud`) against a cached / inline / file
//!   **JWKS** with ZERO network. This is the airgap path: the keys are
//!   pre-provisioned (inline in the RON config, or a file), so a disconnected
//!   red-zone holger still authenticates tokens.
//! * **online** (gated behind the `online` feature) — fetch the JWKS from
//!   `jwks_uri`, or from the `jwks_uri` the issuer's discovery document names
//!   (https, the issuer held exact). A JWT is NEVER sent anywhere else: when no key set
//!   can be had it is refused by name (A34-1). An OPAQUE (non-JWT) token is
//!   checked only when the operator opted in ([`OpaqueTokens`]: RFC 7662
//!   introspection at the DISCOVERED endpoint, bound to the audience, plus the
//!   DISCOVERED `userinfo_endpoint`); there is no guessed `{issuer}/userinfo`.
//!
//! For interactive CLI login it also carries the **device authorization grant**
//! (RFC 8628) client helper ([`device`]) — the flow a headless `holger` CLI uses
//! to obtain a token from the IdP (the user approves in a browser), after which
//! the resulting bearer is validated by exactly the paths above.
//!
//! It implements the shared [`nornir_service::AuthProvider`] seam and lives in
//! its OWN feature-gated sub-crate so the heavy `jsonwebtoken`/JWKS closure is
//! linked ONLY when a tier pins `oidc`; the embedded/mini bearer-only tier links
//! none of it. The trait's object-safety leaves room for future
//! `nornir-auth-saml` / `nornir-auth-webauthn` sibling crates without touching
//! the dispatch. holger is the reference full consumer; korp + dwarves consume
//! the same crate, and the shared [`nornir-auth-client`] login reuses the
//! [`device`] (RFC 8628) helper here.

/// The OIDC relying party: authorization code + PKCE + nonce, verified offline.
pub mod rp;

use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use std::time::Instant;
#[cfg(feature = "online")]
use std::time::Duration;

use nornir_service::provider::{AuthProvider, Principal, ProviderError};
use tonic::async_trait;

/// The OIDC provider. Holds one issuer's verification config.
///
/// **A JWT never falls through** (A34-1). A bearer that looks like a JWT is
/// verified with the relying party's own checks ([`rp::verify_access_token`]:
/// the algorithm allow-list, the key fitting the algorithm, the signature
/// against the JWKS, `iss` exact, `aud` containing [`OidcProvider::audience`],
/// `exp`) or it is refused, by name ([`BearerRefusal`]), and authoritatively
/// ([`ProviderError::Invalid`]): a cold cache, a wrong `jwks_uri`, an IdP
/// hiccup are refusals, never a retry against `/userinfo`.
///
/// An OPAQUE token is checked only when the operator opted in with
/// [`OidcProvider::opaque`] (the discovered introspection endpoint, which
/// binds the token to the audience, plus the discovered `userinfo_endpoint`);
/// without it the provider says "not mine" and asks nobody. There is no
/// guessed `{issuer}/userinfo` anywhere.
///
/// Clone-cheap; the JWKS cache is process-global, keyed by (issuer, key source).
#[derive(Debug, Clone, Default)]
pub struct OidcProvider {
    /// OIDC issuer URL (e.g. `https://keycloak.example/realms/holger`). Also the
    /// expected `iss` claim.
    pub issuer_url: String,
    /// The `aud` a bearer must carry. REQUIRED: `None` (or empty) refuses every
    /// JWT by name ([`BearerRefusal::NoAudience`]), because a token that is not
    /// bound to this server may have been minted for any app in the tenant.
    pub audience: Option<String>,
    /// Inline JWKS JSON (`{"keys":[…]}`) embedded in the config — the fully
    /// airgapped pre-provisioning path (no file, no fetch).
    pub jwks: Option<String>,
    /// Filesystem path to a pre-provisioned JWKS file (airgap by path); also the
    /// write-through target when a JWKS is fetched online. Re-read at most a
    /// minute after it changes.
    pub jwks_path: Option<String>,
    /// Where to fetch the JWKS when ONLINE (https). Unset ⇒ the `jwks_uri` of
    /// the issuer's DISCOVERY document (`{issuer_url}/.well-known/openid-configuration`,
    /// held to the configured issuer exactly), never a guessed path. It counts
    /// as a key source: when the fetch yields no key set, the JWT is refused
    /// ([`BearerRefusal::KeysUnavailable`]).
    pub jwks_uri: Option<String>,
    /// Opt-in: how an OPAQUE (non-JWT) bearer is checked. `None` ⇒ an opaque
    /// token is "not mine" and no IdP is asked.
    pub opaque: Option<OpaqueTokens>,
    /// Accept `http` on a loopback host for `jwks_uri` and the opaque-token
    /// endpoints — an in-process test IdP only. Every other URL must be https.
    pub allow_loopback_http: bool,
}

impl OidcProvider {
    /// A provider that verifies fully offline against an INLINE JWKS (no file, no
    /// fetch) — the common airgap constructor.
    pub fn inline(issuer_url: impl Into<String>, audience: Option<String>, jwks: impl Into<String>) -> Self {
        Self {
            issuer_url: issuer_url.into(),
            audience,
            jwks: Some(jwks.into()),
            ..Self::default()
        }
    }
}

/// **The opt-in check for OPAQUE bearers** (A34-1): RFC 7662 introspection at
/// the issuer's DISCOVERED `introspection_endpoint`, which must say the token
/// is `active`, from this issuer (when it says), for this audience, and not
/// expired; `sub` comes from the introspection answer, else from the
/// DISCOVERED `userinfo_endpoint`. Build it with [`OpaqueTokens::from_discovery`].
#[derive(Clone, Default)]
pub struct OpaqueTokens {
    /// The discovered RFC 7662 introspection endpoint (https).
    pub introspection_endpoint: String,
    /// The discovered `userinfo_endpoint` (https), asked for `sub` only when
    /// the introspection answer names none.
    pub userinfo_endpoint: Option<String>,
    /// This resource server's client id at the IdP (authenticates the
    /// introspection call, `client_secret_basic`).
    pub client_id: String,
    /// Its secret. Never logged (see the `Debug` impl).
    pub client_secret: String,
}

impl std::fmt::Debug for OpaqueTokens {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("OpaqueTokens")
            .field("introspection_endpoint", &self.introspection_endpoint)
            .field("userinfo_endpoint", &self.userinfo_endpoint)
            .field("client_id", &self.client_id)
            .finish_non_exhaustive()
    }
}

impl OpaqueTokens {
    /// The opaque-token check from a parsed discovery document: its
    /// `introspection_endpoint` is required (it is what binds a token to an
    /// audience), its `userinfo_endpoint` is taken when present. Nothing is
    /// guessed.
    pub fn from_discovery(
        discovery: &rp::Discovery,
        client_id: impl Into<String>,
        client_secret: impl Into<String>,
    ) -> Result<Self, rp::RpError> {
        let introspection_endpoint = discovery.introspection_endpoint.clone().ok_or_else(|| {
            rp::RpError::Config(
                "the identity provider names no introspection_endpoint, so an opaque token \
                 cannot be bound to an audience"
                    .to_owned(),
            )
        })?;
        Ok(OpaqueTokens {
            introspection_endpoint,
            userinfo_endpoint: discovery.userinfo_endpoint.clone(),
            client_id: client_id.into(),
            client_secret: client_secret.into(),
        })
    }
}

/// **Why a bearer was refused**, by name. Never holds the token.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BearerRefusal {
    /// No audience is configured, so no JWT can be bound to this server.
    NoAudience,
    /// No key set could be had for this JWT (none configured, the file is
    /// unreadable, the fetch failed, the build has no `online`).
    KeysUnavailable(String),
    /// An endpoint is not https (A34-28).
    Endpoint(String),
    /// The JWT does not hold: algorithm, key, signature or a claim.
    Token(rp::RpError),
    /// An opaque token, and the opaque check is not enabled ([`OidcProvider::opaque`]).
    OpaqueNotEnabled,
    /// The opaque check refused the token (inactive, wrong audience/issuer, expired).
    Opaque(String),
    /// The opaque check could not reach the IdP.
    OpaqueUnavailable(String),
}

impl BearerRefusal {
    /// A stable code for logs and audit lines.
    pub fn code(&self) -> &'static str {
        match self {
            BearerRefusal::NoAudience => "oidc.no_audience",
            BearerRefusal::KeysUnavailable(_) => "oidc.keys_unavailable",
            BearerRefusal::Endpoint(_) => "oidc.endpoint_not_https",
            BearerRefusal::Token(_) => "oidc.token_invalid",
            BearerRefusal::OpaqueNotEnabled => "oidc.opaque_not_enabled",
            BearerRefusal::Opaque(_) => "oidc.opaque_refused",
            BearerRefusal::OpaqueUnavailable(_) => "oidc.opaque_unavailable",
        }
    }
}

impl std::fmt::Display for BearerRefusal {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let code = self.code();
        match self {
            BearerRefusal::NoAudience => write!(
                f,
                "{code}: no audience is configured, so no token can be bound to this server"
            ),
            BearerRefusal::KeysUnavailable(m) => write!(f, "{code}: no key set: {m}"),
            BearerRefusal::Endpoint(m) => write!(f, "{code}: {m}"),
            BearerRefusal::Token(e) => write!(f, "{code}: {e}"),
            BearerRefusal::OpaqueNotEnabled => write!(
                f,
                "{code}: an opaque token, and opaque tokens are not enabled for this issuer"
            ),
            BearerRefusal::Opaque(m) => write!(f, "{code}: {m}"),
            BearerRefusal::OpaqueUnavailable(m) => write!(f, "{code}: {m}"),
        }
    }
}

impl std::error::Error for BearerRefusal {}

#[async_trait]
impl AuthProvider for OidcProvider {
    fn name(&self) -> &str {
        "oidc"
    }

    async fn verify_bearer(&self, token: &str) -> Result<Option<Principal>, ProviderError> {
        match self.check_bearer(token).await {
            Ok(principal) => Ok(Some(principal)),
            // Not this provider's shape and not opted in: the next provider
            // (a static bearer, introspection) may know it. Nobody was asked.
            Err(BearerRefusal::OpaqueNotEnabled) => Ok(None),
            Err(refusal @ BearerRefusal::OpaqueUnavailable(_)) => {
                log::warn!("nornir-auth-oidc: issuer {}: {refusal}", self.issuer_url);
                Err(ProviderError::Unavailable)
            }
            // Authoritative: a JWT that does not verify is refused here and
            // never re-tried by a laxer path.
            Err(refusal) => {
                log::warn!("nornir-auth-oidc: issuer {}: refused a bearer: {refusal}", self.issuer_url);
                Err(ProviderError::Invalid)
            }
        }
    }
}

// ─── Offline / airgap OIDC (JWT-signature verification against a JWKS) ────────

/// Minimal claims: `sub` (identity) + optional scope grants. `exp`/`iss`/`aud`
/// are validated by `jsonwebtoken` from the raw payload via `Validation`.
#[derive(serde::Deserialize)]
struct OidcClaims {
    sub: String,
    /// OAuth2 space-delimited `scope` string (the common form).
    #[serde(default)]
    scope: Option<String>,
    /// Some IdPs emit `scp` as a JSON array instead.
    #[serde(default)]
    scp: Option<Vec<String>>,
    /// **Directory group membership.** The claim every enterprise IdP can emit
    /// and the one an organisation's RBAC actually wants: Entra ID and Keycloak
    /// call it `groups`, and it is what says "this person is an investigator".
    #[serde(default)]
    groups: Option<Vec<String>>,
    /// **Application roles.** Entra ID's app-role assignment claim is `roles`,
    /// and Keycloak/Okta deployments commonly emit one too. Unioned with
    /// `groups` rather than preferred over it: a tenant may use either, both, or
    /// neither, and a provider that read only one of them would silently grant
    /// nothing on half of them.
    #[serde(default)]
    roles: Option<Vec<String>>,
    /// The human handle, for AUDIT lines only (never for authorization).
    #[serde(default)]
    preferred_username: Option<String>,
    /// Fallback human handle when the IdP emits no `preferred_username`.
    #[serde(default)]
    email: Option<String>,
}

impl OidcClaims {
    fn scopes(&self) -> Vec<String> {
        if let Some(arr) = &self.scp {
            return arr.clone();
        }
        self.scope
            .as_deref()
            .map(|s| s.split_whitespace().map(|x| x.to_string()).collect())
            .unwrap_or_default()
    }

    /// `groups` ∪ `roles`, order-preserving and de-duplicated.
    ///
    /// A UNION and not a preference: which of the two an IdP emits is a tenant
    /// configuration, not a protocol fact, so reading only one would leave half
    /// of all deployments granting nothing — and granting nothing looks exactly
    /// like a correct least-privilege result, which is why it would not be
    /// noticed until someone could not open a case.
    fn group_grants(&self) -> Vec<String> {
        let mut out: Vec<String> = Vec::new();
        for src in [self.groups.as_deref(), self.roles.as_deref()] {
            for g in src.unwrap_or(&[]) {
                let g = g.trim();
                if !g.is_empty() && !out.iter().any(|e| e == g) {
                    out.push(g.to_string());
                }
            }
        }
        out
    }

    /// The name to print in an audit line: `preferred_username`, else `email`,
    /// else `None`. **Never the identity anything authorizes on** — both claims
    /// are mutable at the IdP and an email can be reassigned, so a role map
    /// keyed on one would follow the address rather than the person.
    ///
    /// This is the same order korp's own client already used to show who was
    /// signed in (`login::subject_of`), which is the asymmetry that made the gap
    /// concrete: the human name was on the token, and only the client read it.
    fn display_name(&self) -> Option<String> {
        [self.preferred_username.as_deref(), self.email.as_deref()]
            .into_iter()
            .flatten()
            .map(str::trim)
            .find(|s| !s.is_empty())
            .map(str::to_string)
    }
}

/// Does this bearer token look like a JWT (three base64url `.`-separated
/// segments)? Opaque access tokens are not JWTs; they go to the opt-in
/// [`OpaqueTokens`] check, or nowhere.
fn looks_like_jwt(token: &str) -> bool {
    token.split('.').count() == 3 && !token.starts_with('.')
}

/// A cached JWKS. `expires` is `None` for pre-provisioned keys (inline / file —
/// never auto-expire) and `Some` 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.
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 });
    }
}

/// Drop every cached JWKS — test hook (also usable by an operator "rotate now").
pub fn clear_jwks_cache() {
    if let Ok(mut c) = jwks_cache().lock() {
        c.clear();
    }
    #[cfg(feature = "online")]
    if let Ok(mut c) = discovery_cache().lock() {
        c.clear();
    }
}

/// Process-global cache of DISCOVERED `jwks_uri`s, keyed like the JWKS cache,
/// trusted as long as a fetched key set is.
#[cfg(feature = "online")]
fn discovery_cache() -> &'static Mutex<HashMap<String, (String, Instant)>> {
    static CACHE: OnceLock<Mutex<HashMap<String, (String, Instant)>>> = OnceLock::new();
    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}

fn parse_jwks(json: &str) -> Result<jsonwebtoken::jwk::JwkSet, ()> {
    serde_json::from_str(json).map_err(|_| ())
}

/// **Resolve a duration knob, honouring RENAMED predecessors — and never
/// silently ignoring one.**
///
/// The one writer both TTL knobs below route through. It exists because of a
/// measured, security-relevant drift: when holger's auth layer was promoted into
/// this crate, `HOLGER_OIDC_CACHE_TTL_SECS` was renamed to
/// `NORNIR_OIDC_USERINFO_CACHE_TTL_SECS`, but holger's own operator docs kept
/// naming the old one. The userinfo TTL is *how long a REVOKED bearer token keeps
/// being accepted*, so an operator hardening a deployment set the documented
/// name, got no error and no warning, restarted, and still had the full 120 s
/// revocation window. A security control that fails silently OPEN.
///
/// Hence the two rules encoded here, both of which the previous one-liner broke:
///
/// 1. **A recognised legacy name is honoured, and says so loudly.** Ignoring it
///    silently is the defect; refusing it outright would break the deployments
///    the old docs created. It is honoured *and* announced, so the operator can
///    migrate rather than discover the gap during an incident.
/// 2. **An UNPARSEABLE value is announced, never quietly defaulted.** `TTL=oops`
///    previously fell through to the default with nothing on stderr — the same
///    silent-fail-open shape one layer down.
///
/// The lookup is injected rather than read from the process so the resolution
/// order can be asserted WITHOUT mutating `std::env`. Env-var tests are
/// process-global and race every sibling test in the binary; that race red-ed a
/// korp test on a clean tree and cost a session to diagnose. Keep the arithmetic
/// pure and the env read at the edge.
#[cfg(feature = "online")]
fn resolve_ttl(
    canonical: &str,
    legacy: &[&str],
    default: Duration,
    get: impl Fn(&str) -> Option<String>,
) -> Duration {
    if let Some(raw) = get(canonical) {
        match raw.trim().parse::<u64>() {
            Ok(secs) => return Duration::from_secs(secs),
            Err(_) => eprintln!(
                "[nornir-auth-oidc] WARNING: {canonical}={raw:?} is not a whole number of \
                 seconds — IGNORED, falling back to the {default:?} default."
            ),
        }
        return default;
    }
    for name in legacy {
        let Some(raw) = get(name) else { continue };
        match raw.trim().parse::<u64>() {
            Ok(secs) => {
                eprintln!(
                    "[nornir-auth-oidc] WARNING: {name} is a DEPRECATED alias of {canonical} \
                     and is honoured only for compatibility. Set {canonical} instead."
                );
                return Duration::from_secs(secs);
            }
            Err(_) => eprintln!(
                "[nornir-auth-oidc] WARNING: {name} (deprecated alias of {canonical}) is set to \
                 {raw:?}, which is not a whole number of seconds — IGNORED."
            ),
        }
        return default;
    }
    default
}

/// Env names this crate once answered to, per canonical knob. Additive: a rename
/// adds a row here rather than orphaning whatever the old docs still say.
#[cfg(feature = "online")]
const LEGACY_USERINFO_TTL_NAMES: &[&str] = &["HOLGER_OIDC_CACHE_TTL_SECS"];

#[cfg(feature = "online")]
fn env_get(name: &str) -> Option<String> {
    std::env::var(name).ok()
}

/// How long a JWKS FETCHED ONLINE is trusted before refetch (key rotation).
/// Pre-provisioned keys never expire. Override `NORNIR_OIDC_JWKS_CACHE_TTL_SECS`.
#[cfg(feature = "online")]
const JWKS_CACHE_TTL: Duration = Duration::from_secs(3600);
#[cfg(feature = "online")]
fn jwks_cache_ttl() -> Duration {
    resolve_ttl(
        "NORNIR_OIDC_JWKS_CACHE_TTL_SECS",
        &[],
        JWKS_CACHE_TTL,
        env_get,
    )
}

/// How long pre-provisioned keys (inline / file) are trusted before they are
/// read again, so a rotated `jwks_path` is picked up (A34-30).
const PINNED_JWKS_TTL: std::time::Duration = std::time::Duration::from_secs(60);

/// The tolerated clock skew for a bearer, as the relying party allows.
const BEARER_SKEW_SECS: i64 = 60;

/// Where a key set came from: pinned keys are authoritative as they are; a
/// fetched set is refetched once when a token names a key it lacks (rotation).
#[derive(Clone, Copy, PartialEq, Eq)]
enum KeySource {
    Pinned,
    #[cfg_attr(not(feature = "online"), allow(dead_code))]
    Fetched,
}

fn now_unix() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0)
}

impl OidcProvider {
    /// The one bearer check, with the refusal named. A JWT is verified with
    /// [`rp::verify_access_token`] or refused; an opaque token goes to the
    /// opt-in [`OpaqueTokens`] check or is [`BearerRefusal::OpaqueNotEnabled`].
    pub async fn check_bearer(&self, token: &str) -> Result<Principal, BearerRefusal> {
        if looks_like_jwt(token) {
            return self.check_jwt(token).await;
        }
        match &self.opaque {
            Some(opaque) => self.check_opaque(opaque, token).await,
            None => Err(BearerRefusal::OpaqueNotEnabled),
        }
    }

    fn audience(&self) -> Result<&str, BearerRefusal> {
        self.audience
            .as_deref()
            .map(str::trim)
            .filter(|a| !a.is_empty())
            .ok_or(BearerRefusal::NoAudience)
    }

    async fn check_jwt(&self, token: &str) -> Result<Principal, BearerRefusal> {
        let audience = self.audience()?;
        let expect = rp::AccessTokenExpect {
            issuer: &self.issuer_url,
            audience,
            now_unix: now_unix(),
            skew_secs: BEARER_SKEW_SECS,
            algorithms: rp::DEFAULT_ALGORITHMS,
        };
        let (set, source) = self.resolve_jwks(false).await?;
        let verified = match rp::verify_access_token(token, &set, &expect) {
            // A rotated key at the IdP: one refetch, then the answer stands.
            Err(rp::RpError::UnknownKey(_)) if source == KeySource::Fetched => {
                let (fresh, _) = self.resolve_jwks(true).await?;
                rp::verify_access_token(token, &fresh, &expect)
            }
            other => other,
        }
        .map_err(BearerRefusal::Token)?;
        let claims: OidcClaims = serde_json::from_value(serde_json::Value::Object(verified.claims))
            .map_err(|e| BearerRefusal::Token(rp::RpError::Claim(format!("the claims do not parse: {e}"))))?;
        Ok(Principal::new(claims.sub.clone(), "oidc")
            .with_scopes(claims.scopes())
            .with_groups(claims.group_grants())
            .with_display(claims.display_name()))
    }

    /// The cache key: the issuer AND the key source, so two configurations of
    /// one issuer never share keys (A34-30).
    fn cache_key(&self, source: &str) -> String {
        format!("{}\u{1f}{source}", self.issuer_url)
    }

    /// Resolve the JWKS, offline first: (1) inline config JWKS, (2) the
    /// pre-provisioned file, (3) the online fetch from `jwks_uri` (https; the
    /// issuer's DISCOVERED `jwks_uri` when unset), write-through-persisted to
    /// `jwks_path`. (1)-(2) touch no network. No key set is a refusal.
    async fn resolve_jwks(
        &self,
        refetch: bool,
    ) -> Result<(jsonwebtoken::jwk::JwkSet, KeySource), BearerRefusal> {
        let cached = |key: &str| -> Option<jsonwebtoken::jwk::JwkSet> {
            let cache = jwks_cache().lock().ok()?;
            cache.get(key).filter(|e| e.fresh()).map(|e| e.set.clone())
        };
        // (1) inline JWKS in the config — fully airgapped.
        if let Some(inline) = self.jwks.as_deref().filter(|s| !s.trim().is_empty()) {
            let set = parse_jwks(inline)
                .map_err(|()| BearerRefusal::KeysUnavailable("the inline `jwks` does not parse".to_owned()))?;
            return Ok((set, KeySource::Pinned));
        }
        // (2) pre-provisioned JWKS file — airgap by path.
        if let Some(path) = self.jwks_path.as_deref().filter(|s| !s.trim().is_empty()) {
            let key = self.cache_key(&format!("file:{path}"));
            if let Some(set) = cached(&key) {
                return Ok((set, KeySource::Pinned));
            }
            if let Ok(bytes) = std::fs::read_to_string(path) {
                let set = parse_jwks(&bytes).map_err(|()| {
                    BearerRefusal::KeysUnavailable(format!("the `jwks_path` file {path:?} does not parse"))
                })?;
                cache_jwks(&key, set.clone(), Some(Instant::now() + PINNED_JWKS_TTL));
                return Ok((set, KeySource::Pinned));
            }
        }
        // (3) online fetch — the ONLY networked branch.
        #[cfg(feature = "online")]
        {
            let url = match self.jwks_uri.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
                Some(uri) => uri.to_string(),
                None => self.discovered_jwks_uri().await?,
            };
            if !rp::endpoint_ok(&url, self.allow_loopback_http) {
                return Err(BearerRefusal::Endpoint(format!(
                    "the JWKS URL {url:?} is not https"
                )));
            }
            let key = self.cache_key(&format!("uri:{url}"));
            if !refetch {
                if let Some(set) = cached(&key) {
                    return Ok((set, KeySource::Fetched));
                }
            }
            let unavailable = |why: String| BearerRefusal::KeysUnavailable(format!("{url}: {why}"));
            let resp = oidc_client()
                .map_err(|e| unavailable(e))?
                .get(&url)
                .send()
                .await
                .map_err(|e| unavailable(format!("the fetch failed: {e}")))?;
            if !resp.status().is_success() {
                return Err(unavailable(format!("HTTP {}", resp.status())));
            }
            let body = capped_text(resp).await.map_err(|e| unavailable(format!("the body: {e}")))?;
            let set = parse_jwks(&body).map_err(|()| unavailable("the key set does not parse".to_owned()))?;
            if let Some(path) = self.jwks_path.as_deref().filter(|s| !s.trim().is_empty()) {
                if let Err(e) = std::fs::write(path, &body) {
                    log::warn!("nornir-auth-oidc: failed to persist fetched JWKS to {path}: {e}");
                }
            }
            cache_jwks(&key, set.clone(), Some(Instant::now() + jwks_cache_ttl()));
            Ok((set, KeySource::Fetched))
        }
        #[cfg(not(feature = "online"))]
        {
            let _ = (refetch, cached);
            Err(BearerRefusal::KeysUnavailable(
                "no inline `jwks` and no readable `jwks_path`, and this build has no outbound HTTP \
                 (`online` is off)"
                    .to_owned(),
            ))
        }
    }

    /// The issuer's `jwks_uri` from its discovery document (OIDC Discovery §4):
    /// the document must name the configured issuer EXACTLY (else its keys
    /// would verify somebody else's tokens) and https endpoints
    /// ([`rp::Discovery::parse`]). Cached as long as a fetched key set.
    #[cfg(feature = "online")]
    async fn discovered_jwks_uri(&self) -> Result<String, BearerRefusal> {
        let url = rp::discovery_url(&self.issuer_url);
        if !rp::endpoint_ok(&url, self.allow_loopback_http) {
            return Err(BearerRefusal::Endpoint(format!(
                "the issuer's discovery URL {url:?} is not https"
            )));
        }
        let key = self.cache_key("discovery");
        if let Ok(cache) = discovery_cache().lock() {
            if let Some((uri, deadline)) = cache.get(&key) {
                if Instant::now() < *deadline {
                    return Ok(uri.clone());
                }
            }
        }
        let unavailable = |why: String| BearerRefusal::KeysUnavailable(format!("{url}: {why}"));
        let resp = oidc_client()
            .map_err(|e| unavailable(e))?
            .get(&url)
            .send()
            .await
            .map_err(|e| unavailable(format!("the discovery fetch failed: {e}")))?;
        if !resp.status().is_success() {
            return Err(unavailable(format!("discovery answered HTTP {}", resp.status())));
        }
        let body = capped_text(resp)
            .await
            .map_err(|e| unavailable(format!("the discovery body: {e}")))?;
        let discovery = rp::Discovery::parse(&body, &self.issuer_url, self.allow_loopback_http)
            .map_err(|e| unavailable(e.to_string()))?;
        if let Ok(mut cache) = discovery_cache().lock() {
            cache.insert(key, (discovery.jwks_uri.clone(), Instant::now() + jwks_cache_ttl()));
        }
        Ok(discovery.jwks_uri)
    }

    #[cfg(not(feature = "online"))]
    async fn check_opaque(&self, _: &OpaqueTokens, _: &str) -> Result<Principal, BearerRefusal> {
        Err(BearerRefusal::OpaqueUnavailable(
            "opaque tokens need the `online` build (introspection is outbound HTTP)".to_owned(),
        ))
    }
}

// ─── Online opaque-token check (opt-in: introspection + the discovered userinfo) ─

/// TTL for a checked `(issuer, token)` → principal before the IdP is asked
/// again. Override `NORNIR_OIDC_USERINFO_CACHE_TTL_SECS`.
///
/// **This is the revocation window.** Until it expires, a token the IdP has
/// already revoked is still accepted, so it is the knob an operator hardens
/// first — and the reason the legacy name in
/// [`LEGACY_USERINFO_TTL_NAMES`] is honoured with a warning rather than ignored.
#[cfg(feature = "online")]
const OIDC_CACHE_TTL: Duration = Duration::from_secs(120);
#[cfg(feature = "online")]
fn oidc_cache_ttl() -> Duration {
    resolve_ttl(
        "NORNIR_OIDC_USERINFO_CACHE_TTL_SECS",
        LEGACY_USERINFO_TTL_NAMES,
        OIDC_CACHE_TTL,
        env_get,
    )
}

/// One shared pooled `reqwest::Client` for all OIDC egress (introspection,
/// userinfo, jwks_uri, device-code). Built once, lazily. No redirects: a
/// bearer is never carried to a host the operator did not name.
///
/// RA4-12: a client that cannot be built is an error by name, never a fallback
/// `Client::new()` (redirects ON, no timeout).
#[cfg(feature = "online")]
fn oidc_client() -> Result<&'static reqwest::Client, String> {
    static CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
    CLIENT
        .get_or_init(|| {
            tls_roots(reqwest::Client::builder())
                .pool_idle_timeout(Duration::from_secs(90))
                .timeout(Duration::from_secs(10))
                .redirect(reqwest::redirect::Policy::none())
                .build()
                .map_err(|e| format!("the OIDC client could not be built: {e}"))
        })
        .as_ref()
        .map_err(Clone::clone)
}

/// The most an IdP answer (discovery, key set, introspection, userinfo, device and token
/// answers) may be (RA4-12): 1 MiB. Bigger is refused by name, not read.
pub const MAX_IDP_BODY: usize = 1 << 20;

/// Pure: **add `chunk` to `body` unless that passes `cap`** (RA4-12).
pub fn take_capped(body: &mut Vec<u8>, chunk: &[u8], cap: usize) -> Result<(), String> {
    if body.len() + chunk.len() > cap {
        return Err(format!("the answer is larger than {cap} bytes: refused, not read"));
    }
    body.extend_from_slice(chunk);
    Ok(())
}

/// **An IdP answer's body as text, read at most [`MAX_IDP_BODY`]** (RA4-12).
#[cfg(feature = "online")]
async fn capped_text(mut resp: reqwest::Response) -> Result<String, String> {
    if resp.content_length().is_some_and(|n| n as usize > MAX_IDP_BODY) {
        return Err(format!("the answer is larger than {MAX_IDP_BODY} bytes: refused, not read"));
    }
    let mut body = Vec::new();
    while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? {
        take_capped(&mut body, &chunk, MAX_IDP_BODY)?;
    }
    String::from_utf8(body).map_err(|_| "the answer is not UTF-8".to_owned())
}

/// reqwest 0.13 with reqwest 0.12 `rustls-tls` behaviour: the ring provider and
/// the bundled Mozilla roots, not aws-lc-rs + the OS trust store (2026-10-02).
#[cfg(feature = "online")]
fn tls_roots(b: reqwest::ClientBuilder) -> reqwest::ClientBuilder {
    let _ = rustls::crypto::ring::default_provider().install_default();
    b.tls_certs_only(
        webpki_root_certs::TLS_SERVER_ROOT_CERTS
            .iter()
            .filter_map(|c| reqwest::Certificate::from_der(c).ok()),
    )
}

/// Cache of checked opaque tokens. Key = `(issuer, blake3(token))` so no raw
/// secret is held.
#[cfg(feature = "online")]
type OidcCacheKey = (String, [u8; 32]);
#[cfg(feature = "online")]
fn oidc_cache() -> &'static Mutex<HashMap<OidcCacheKey, (Principal, Instant)>> {
    static CACHE: OnceLock<Mutex<HashMap<OidcCacheKey, (Principal, 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()
}

/// The `aud` of an introspection answer (a string or an array), or `None`.
#[cfg(feature = "online")]
fn audiences(value: Option<&serde_json::Value>) -> Vec<String> {
    match value {
        Some(serde_json::Value::String(one)) => vec![one.clone()],
        Some(serde_json::Value::Array(many)) => {
            many.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()
        }
        _ => Vec::new(),
    }
}

#[cfg(feature = "online")]
impl OidcProvider {
    /// **The opaque-token check**: RFC 7662 introspection at the discovered
    /// endpoint must answer `active: true`, the configured audience in `aud`,
    /// this issuer in `iss` when it names one, and an `exp` (when given) in the
    /// future. `sub` comes from the answer, else from the DISCOVERED
    /// `userinfo_endpoint`. Cached for the revocation window.
    async fn check_opaque(&self, opaque: &OpaqueTokens, token: &str) -> Result<Principal, BearerRefusal> {
        let audience = self.audience()?;
        for (what, url) in [
            ("introspection_endpoint", Some(opaque.introspection_endpoint.as_str())),
            ("userinfo_endpoint", opaque.userinfo_endpoint.as_deref()),
        ] {
            if let Some(url) = url {
                if !rp::endpoint_ok(url, self.allow_loopback_http) {
                    return Err(BearerRefusal::Endpoint(format!("{what} {url:?} is not https")));
                }
            }
        }
        let ttl = oidc_cache_ttl();
        let key: OidcCacheKey = (self.issuer_url.clone(), token_digest(token));
        if let Ok(cache) = oidc_cache().lock() {
            if let Some((principal, inserted)) = cache.get(&key) {
                if inserted.elapsed() < ttl {
                    return Ok(principal.clone());
                }
            }
        }
        let unavailable = |why: String| BearerRefusal::OpaqueUnavailable(why);
        let form = rp::form_body(&[
            ("token".to_owned(), token.to_owned()),
            ("token_type_hint".to_owned(), "access_token".to_owned()),
        ]);
        let resp = oidc_client()
            .map_err(|e| unavailable(e))?
            .post(&opaque.introspection_endpoint)
            .header("Authorization", rp::basic_auth_header(&opaque.client_id, &opaque.client_secret))
            .header("Content-Type", "application/x-www-form-urlencoded")
            .body(form)
            .send()
            .await
            .map_err(|e| unavailable(format!("the introspection call failed: {e}")))?;
        if !resp.status().is_success() {
            return Err(unavailable(format!("the introspection endpoint answered HTTP {}", resp.status())));
        }
        let body: serde_json::Value = capped_text(resp)
            .await
            .and_then(|t| serde_json::from_str(&t).map_err(|e| e.to_string()))
            .map_err(|e| unavailable(format!("the introspection answer does not parse: {e}")))?;
        if body.get("active").and_then(|v| v.as_bool()) != Some(true) {
            return Err(BearerRefusal::Opaque("the token is not active".to_owned()));
        }
        if !audiences(body.get("aud")).iter().any(|a| a == audience) {
            return Err(BearerRefusal::Opaque(
                "the token is not for this audience (wrong audience)".to_owned(),
            ));
        }
        if let Some(iss) = body.get("iss").and_then(|v| v.as_str()) {
            if iss.trim_end_matches('/') != self.issuer_url.trim().trim_end_matches('/') {
                return Err(BearerRefusal::Opaque(format!("the token's issuer is {iss:?}")));
            }
        }
        if let Some(exp) = body.get("exp").and_then(|v| v.as_i64()) {
            if exp <= now_unix() - BEARER_SKEW_SECS {
                return Err(BearerRefusal::Opaque("the token has expired".to_owned()));
            }
        }
        let sub = match body.get("sub").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) {
            Some(sub) => sub.to_owned(),
            None => {
                let url = opaque.userinfo_endpoint.as_deref().ok_or_else(|| {
                    BearerRefusal::Opaque(
                        "the introspection answer names no subject and the IdP names no userinfo_endpoint"
                            .to_owned(),
                    )
                })?;
                let resp = oidc_client()
                    .map_err(|e| unavailable(e))?
                    .get(url)
                    .header("Authorization", format!("Bearer {token}"))
                    .send()
                    .await
                    .map_err(|e| unavailable(format!("the userinfo call failed: {e}")))?;
                if !resp.status().is_success() {
                    return Err(BearerRefusal::Opaque(format!("userinfo answered HTTP {}", resp.status())));
                }
                let info: serde_json::Value = capped_text(resp)
                    .await
                    .and_then(|t| serde_json::from_str(&t).map_err(|e| e.to_string()))
                    .map_err(|e| unavailable(format!("the userinfo answer does not parse: {e}")))?;
                info.get("sub")
                    .and_then(|v| v.as_str())
                    .filter(|s| !s.is_empty())
                    .map(str::to_owned)
                    .ok_or_else(|| BearerRefusal::Opaque("userinfo names no subject".to_owned()))?
            }
        };
        let scopes: Vec<String> = body
            .get("scope")
            .and_then(|v| v.as_str())
            .map(|s| s.split_whitespace().map(str::to_owned).collect())
            .unwrap_or_default();
        let display = ["preferred_username", "username", "email"]
            .iter()
            .find_map(|k| body.get(*k).and_then(|v| v.as_str()).map(str::trim).filter(|s| !s.is_empty()))
            .map(str::to_owned);
        let principal = Principal::new(sub, "oidc").with_scopes(scopes).with_display(display);
        if let Ok(mut cache) = oidc_cache().lock() {
            cache.retain(|_, (_, inserted)| inserted.elapsed() < ttl);
            cache.insert(key, (principal.clone(), Instant::now()));
        }
        Ok(principal)
    }
}

// ─── Device authorization grant (RFC 8628) — interactive CLI login ───────────

/// The device-authorization-grant client helper: the flow a headless `holger`
/// CLI uses to obtain a token from an OIDC IdP without a local browser redirect.
/// The offline-testable request/response shapes live here; the networked
/// round-trips are `online`-gated.
pub mod device {
    use serde::Deserialize;

    /// The IdP's response to a device-authorization request (RFC 8628 §3.2).
    #[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
    pub struct DeviceCode {
        pub device_code: String,
        pub user_code: String,
        pub verification_uri: String,
        /// Some IdPs return a pre-filled URI (`verification_uri_complete`).
        #[serde(default)]
        pub verification_uri_complete: Option<String>,
        #[serde(default = "default_interval")]
        pub interval: u64,
        #[serde(default)]
        pub expires_in: u64,
    }
    fn default_interval() -> u64 {
        5
    }

    impl DeviceCode {
        /// The URI to show the user — the pre-filled one if the IdP gave it.
        pub fn user_uri(&self) -> &str {
            self.verification_uri_complete.as_deref().unwrap_or(&self.verification_uri)
        }
    }

    /// Parse a device-authorization JSON body (the shape Keycloak / Entra emit).
    pub fn parse_device_code(json: &str) -> Result<DeviceCode, String> {
        serde_json::from_str(json).map_err(|e| format!("device_code parse: {e}"))
    }

    /// Request a device code from the IdP's device-authorization endpoint.
    /// `online`-only (a networked POST).
    #[cfg(feature = "online")]
    pub async fn request_device_code(
        device_auth_url: &str,
        client_id: &str,
        scope: &str,
    ) -> Result<DeviceCode, String> {
        let resp = super::oidc_client()?
            .post(device_auth_url)
            .form(&[("client_id", client_id), ("scope", scope)])
            .send()
            .await
            .map_err(|e| format!("device_authorization request: {e}"))?;
        let body = super::capped_text(resp).await.map_err(|e| format!("device_authorization body: {e}"))?;
        parse_device_code(&body)
    }

    /// Poll the token endpoint ONCE with the device code. Returns
    /// `Ok(Some(access_token))` when the user has approved, `Ok(None)` while
    /// still `authorization_pending`, or `Err` on a terminal error. The caller
    /// loops on `Ok(None)` honouring `interval`. `online`-only.
    #[cfg(feature = "online")]
    pub async fn poll_token(
        token_url: &str,
        client_id: &str,
        device_code: &str,
    ) -> Result<Option<String>, String> {
        let resp = super::oidc_client()?
            .post(token_url)
            .form(&[
                ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
                ("device_code", device_code),
                ("client_id", client_id),
            ])
            .send()
            .await
            .map_err(|e| format!("token poll: {e}"))?;
        let body: serde_json::Value = super::capped_text(resp)
            .await
            .and_then(|t| serde_json::from_str(&t).map_err(|e| e.to_string()))
            .map_err(|e| format!("token poll body: {e}"))?;
        if let Some(tok) = body.get("access_token").and_then(|v| v.as_str()) {
            return Ok(Some(tok.to_string()));
        }
        match body.get("error").and_then(|v| v.as_str()) {
            Some("authorization_pending") | Some("slow_down") => Ok(None),
            Some(other) => Err(other.to_string()),
            None => Err("token poll: no access_token and no error".into()),
        }
    }

    /// A token-endpoint success response (RFC 6749 §5.1). The shared
    /// `nornir-auth-client` login reuses this to build its cached bundle: the
    /// access token, an optional refresh token, and the access-token lifetime.
    #[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
    pub struct TokenResponse {
        pub access_token: String,
        #[serde(default)]
        pub refresh_token: Option<String>,
        /// Access-token lifetime in seconds, when the IdP returns it.
        #[serde(default)]
        pub expires_in: Option<i64>,
        #[serde(default)]
        pub token_type: Option<String>,
    }

    /// Parse a raw token-endpoint JSON body into either a full [`TokenResponse`]
    /// (`Ok(Some)`), the still-pending signal (`Ok(None)` on
    /// `authorization_pending`/`slow_down`), or a terminal error. The OFFLINE,
    /// network-free core of [`poll_token_full`]/[`refresh`] — red-when-broken
    /// tested by the client crate against captured IdP bodies.
    pub fn parse_token_response(json: &str) -> Result<Option<TokenResponse>, String> {
        let body: serde_json::Value =
            serde_json::from_str(json).map_err(|e| format!("token response parse: {e}"))?;
        if body.get("access_token").and_then(|v| v.as_str()).is_some() {
            let tr: TokenResponse =
                serde_json::from_value(body).map_err(|e| format!("token response shape: {e}"))?;
            return Ok(Some(tr));
        }
        match body.get("error").and_then(|v| v.as_str()) {
            Some("authorization_pending") | Some("slow_down") => Ok(None),
            Some(other) => Err(other.to_string()),
            None => Err("token response: no access_token and no error".into()),
        }
    }

    /// Poll the token endpoint ONCE with the device code, returning the FULL
    /// [`TokenResponse`] (access + refresh + lifetime) on approval, `Ok(None)`
    /// while `authorization_pending`, or `Err` on a terminal error. The richer
    /// sibling of [`poll_token`] the login flow uses so it can cache a refresh
    /// token. `online`-only.
    #[cfg(feature = "online")]
    pub async fn poll_token_full(
        token_url: &str,
        client_id: &str,
        device_code: &str,
    ) -> Result<Option<TokenResponse>, String> {
        let resp = super::oidc_client()?
            .post(token_url)
            .form(&[
                ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
                ("device_code", device_code),
                ("client_id", client_id),
            ])
            .send()
            .await
            .map_err(|e| format!("token poll: {e}"))?;
        let body = super::capped_text(resp).await.map_err(|e| format!("token poll body: {e}"))?;
        parse_token_response(&body)
    }

    /// Exchange a refresh token for a fresh access token (RFC 6749 §6). Returns
    /// the new [`TokenResponse`] (which MAY carry a rotated refresh token). Used
    /// by the shared client login to silently renew an expired cached token.
    /// `online`-only.
    #[cfg(feature = "online")]
    pub async fn refresh(
        token_url: &str,
        client_id: &str,
        refresh_token: &str,
    ) -> Result<TokenResponse, String> {
        let resp = super::oidc_client()?
            .post(token_url)
            .form(&[
                ("grant_type", "refresh_token"),
                ("refresh_token", refresh_token),
                ("client_id", client_id),
            ])
            .send()
            .await
            .map_err(|e| format!("token refresh: {e}"))?;
        let body = super::capped_text(resp).await.map_err(|e| format!("token refresh body: {e}"))?;
        parse_token_response(&body)?.ok_or_else(|| "token refresh: no access_token returned".to_string())
    }
}

// ─── Test support (shared throwaway keypair + JWT minting) ────────────────────

/// Throwaway-RSA test keypair, its matching JWKS, and JWT minting — used by this
/// crate's tests AND, behind the `test-support` feature, by downstream crates
/// (holger-server-lib's gRPC promotion tests need a signed admin/writer token
/// verified offline). Never a real credential; not in a release build unless a
/// downstream explicitly turns the feature on for its own tests.
/// A fake OpenID provider over HTTP, in process (tests only).
#[cfg(any(test, feature = "test-support"))]
pub mod fake_op;

#[cfg(any(test, feature = "test-support"))]
pub mod test_support {
    use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
    use std::time::{SystemTime, UNIX_EPOCH};

    pub const TEST_KID: &str = "holger-test-key-1";
    pub 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 const TEST_N: &str = "n5tTfOVlIcwRsi1uuqC6vhYDHyW-LZh8sQWQLOI5_3-LOT4hCDntLLQ7SWXWRiueWQv-SsSwDpxbTgXUp5lqRoQkj5kYmB15UzVdDCXz5F7Ho3uraD7QN7Wcdk2BGCiY7YSFBnodH1jamnZri4ky02f8gvVzsSfKfZXFssYmHtmn0kLfP2u3zGR5aaK23GqOwRW2eDJnwhU5iOj1m-HLYiUBdEWJSJe4Z9w8ikqALWQLnUztL3Ppmmql3He8gwFDjDAgFPhR0t7JxPOI2O77NyaO9Mqtar16t9jJINY9DkISsnD505nPf5UUWwyOU9PE7T8ZAMsct92yTamQNkYQ4Q";
    pub const TEST_E: &str = "AQAB";

    fn now() -> i64 {
        SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64
    }

    /// The test JWKS JSON matching [`TEST_RSA_PEM`] — usable as an inline JWKS.
    pub 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 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_offset` seconds
    /// from now (negative ⇒ already expired).
    pub 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 [`super::OidcProvider`] that verifies fully offline against the test
    /// JWKS (inline) for `issuer`, audience `"holger"`.
    pub fn oidc_offline_provider(issuer: &str) -> super::OidcProvider {
        super::OidcProvider::inline(issuer, Some("holger".into()), jwks_json())
    }
}

#[cfg(test)]
mod tests {
    use super::test_support::*;
    use super::*;

    /// ★ RA4-12: **an IdP answer past the cap is refused by name**, chunk by chunk, and one
    /// within it is kept whole.
    #[test]
    fn an_idp_answer_past_the_cap_is_refused_by_name() {
        let mut body = Vec::new();
        take_capped(&mut body, &[b'x'; 10], 16).expect("within the cap");
        let why = take_capped(&mut body, &[b'x'; 10], 16).expect_err("past the cap");
        assert!(why.contains("larger than 16 bytes"), "{why}");
        assert_eq!(body.len(), 10, "nothing past the cap is kept");
        assert_eq!(MAX_IDP_BODY, 1 << 20);
    }
    use nornir_service::provider::ProviderError;

    fn provider(issuer: &str) -> OidcProvider {
        OidcProvider::inline(issuer, Some("holger".into()), jwks_json())
    }

    /// A fake environment. Pure, so these four tests cannot race each other or
    /// any sibling in this binary the way `std::env::set_var` would.
    #[cfg(feature = "online")]
    fn envs<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
        move |k| {
            pairs
                .iter()
                .find(|(n, _)| *n == k)
                .map(|(_, v)| (*v).to_string())
        }
    }

    /// **The revocation window must not be silently wider than the operator
    /// asked for.** holger's docs named `HOLGER_OIDC_CACHE_TTL_SECS` in six
    /// places for the whole life of this crate, while the only name read was
    /// `NORNIR_OIDC_USERINFO_CACHE_TTL_SECS`. An operator setting the documented
    /// name got no error, no warning, and kept the 120 s default — a security
    /// control failing OPEN. RED before [`resolve_ttl`]: this returned the 120 s
    /// default, i.e. exactly the bug.
    #[cfg(feature = "online")]
    #[test]
    fn the_legacy_holger_ttl_name_is_honoured_not_silently_ignored() {
        let ttl = resolve_ttl(
            "NORNIR_OIDC_USERINFO_CACHE_TTL_SECS",
            LEGACY_USERINFO_TTL_NAMES,
            OIDC_CACHE_TTL,
            envs(&[("HOLGER_OIDC_CACHE_TTL_SECS", "10")]),
        );
        assert_eq!(
            ttl,
            Duration::from_secs(10),
            "the deprecated name an operator was TOLD to set must still shorten the \
             revocation window, not fall through to the {OIDC_CACHE_TTL:?} default"
        );
        assert_ne!(
            ttl, OIDC_CACHE_TTL,
            "identity-value trap: a test sitting on the default cannot tell a working \
             alias from a dead one"
        );
    }

    /// The canonical name OUTRANKS the legacy one — a migrated deployment that
    /// still carries the old var must behave as its new configuration says.
    #[cfg(feature = "online")]
    #[test]
    fn the_canonical_ttl_name_outranks_the_legacy_alias() {
        assert_eq!(
            resolve_ttl(
                "NORNIR_OIDC_USERINFO_CACHE_TTL_SECS",
                LEGACY_USERINFO_TTL_NAMES,
                OIDC_CACHE_TTL,
                envs(&[
                    ("NORNIR_OIDC_USERINFO_CACHE_TTL_SECS", "7"),
                    ("HOLGER_OIDC_CACHE_TTL_SECS", "999"),
                ]),
            ),
            Duration::from_secs(7)
        );
    }

    /// An unset knob is the default — the resolver must not invent a value.
    #[cfg(feature = "online")]
    #[test]
    fn an_unset_ttl_is_the_documented_default() {
        assert_eq!(
            resolve_ttl(
                "NORNIR_OIDC_USERINFO_CACHE_TTL_SECS",
                LEGACY_USERINFO_TTL_NAMES,
                OIDC_CACHE_TTL,
                envs(&[]),
            ),
            OIDC_CACHE_TTL
        );
    }

    /// A garbage value falls back to the default (it must never be read as 0,
    /// which would disable the cache, nor as "no expiry"). The WARNING that this
    /// happened is the part that was missing; the value itself was already safe.
    #[cfg(feature = "online")]
    #[test]
    fn an_unparseable_ttl_falls_back_to_the_default_and_never_to_zero() {
        let ttl = resolve_ttl(
            "NORNIR_OIDC_USERINFO_CACHE_TTL_SECS",
            LEGACY_USERINFO_TTL_NAMES,
            OIDC_CACHE_TTL,
            envs(&[("NORNIR_OIDC_USERINFO_CACHE_TTL_SECS", "not-a-number")]),
        );
        assert_eq!(ttl, OIDC_CACHE_TTL);
        assert_ne!(ttl, Duration::ZERO);
    }

    /// The JWKS knob shares the one resolver but has NO legacy alias — proving
    /// the alias list is per-knob rather than global, so a future rename cannot
    /// accidentally make an unrelated var meaningful.
    #[cfg(feature = "online")]
    #[test]
    fn the_jwks_knob_does_not_answer_to_the_userinfo_alias() {
        assert_eq!(
            resolve_ttl(
                "NORNIR_OIDC_JWKS_CACHE_TTL_SECS",
                &[],
                JWKS_CACHE_TTL,
                envs(&[("HOLGER_OIDC_CACHE_TTL_SECS", "10")]),
            ),
            JWKS_CACHE_TTL
        );
    }

    /// A valid JWT verifies fully OFFLINE against an inline JWKS — no network —
    /// and yields the `sub` as the principal, method `oidc`.
    #[tokio::test]
    async fn valid_jwt_verifies_offline_against_inline_jwks() {
        let issuer = "https://idp.example/valid";
        let p = provider(issuer);
        let token = token_for(issuer, "holger", "alice@corp", 3600);
        let principal = p.verify_bearer(&token).await.unwrap().expect("valid token verifies");
        assert_eq!(principal.subject, "alice@corp");
        assert_eq!(principal.method, "oidc");
    }

    /// A JWT carrying an OAuth2 `scope` string surfaces its scopes on the Principal.
    #[tokio::test]
    async fn jwt_scopes_are_surfaced() {
        use std::time::{SystemTime, UNIX_EPOCH};
        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
        let issuer = "https://idp.example/scoped";
        let p = provider(issuer);
        let token = sign(serde_json::json!({
            "sub": "svc", "iss": issuer, "aud": "holger",
            "exp": now + 3600, "iat": now,
            "scope": "read write",
        }));
        let principal = p.verify_bearer(&token).await.unwrap().expect("verifies");
        assert!(principal.has_scope("read") && principal.has_scope("write"));
    }

    /// **A REAL SIGNED JWT carrying `groups` / `roles` / `preferred_username`
    /// surfaces all three on the Principal.**
    ///
    /// The policy-side tests (`nornir-auth-policy::claims_to_roles_tests`) build
    /// an `AuthIdentity` by hand, so they would stay green with this parse
    /// completely broken — a role map that resolves perfectly over groups
    /// nothing ever fills. This is the arm that proves the claims survive an
    /// actual `decode`.
    #[tokio::test]
    async fn jwt_group_role_and_name_claims_are_surfaced() {
        use std::time::{SystemTime, UNIX_EPOCH};
        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
        let issuer = "https://idp.example/groups";
        let p = provider(issuer);

        // Entra ID's two shapes at once: directory `groups` AND app `roles`.
        let token = sign(serde_json::json!({
            "sub": "8f3a1c22-0b7e-4c19-9d5e-6a2f4b8c1d03",
            "iss": issuer, "aud": "holger",
            "exp": now + 3600, "iat": now,
            "groups": ["korp-investigators", "all-staff"],
            "roles": ["korp-admins", "all-staff"],
            "preferred_username": "alice@myndighet.se",
            "email": "alice.andersson@myndighet.se",
        }));
        let principal = p.verify_bearer(&token).await.unwrap().expect("verifies");

        // The subject is STILL the `sub` — every role map ever written keys on it.
        assert_eq!(principal.subject, "8f3a1c22-0b7e-4c19-9d5e-6a2f4b8c1d03");
        // groups ∪ roles, de-duplicated: `all-staff` appears in both and once here.
        assert!(principal.in_group("korp-investigators"), "{:?}", principal.groups);
        assert!(principal.in_group("korp-admins"), "{:?}", principal.groups);
        assert_eq!(
            principal.groups.iter().filter(|g| *g == "all-staff").count(),
            1,
            "a group in BOTH claims must appear once: {:?}",
            principal.groups
        );
        // preferred_username wins over email for the audit line.
        assert_eq!(principal.display.as_deref(), Some("alice@myndighet.se"));
    }

    /// `email` is the fallback, and a token with neither name claim yields
    /// `None` rather than a placeholder — an audit line must not invent a
    /// human where the token named none.
    #[tokio::test]
    async fn the_audit_name_falls_back_to_email_and_then_to_nothing() {
        use std::time::{SystemTime, UNIX_EPOCH};
        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
        let issuer = "https://idp.example/names";
        let p = provider(issuer);

        let with_email = sign(serde_json::json!({
            "sub": "guid-1", "iss": issuer, "aud": "holger",
            "exp": now + 3600, "iat": now,
            "email": "bo@myndighet.se",
        }));
        assert_eq!(
            p.verify_bearer(&with_email).await.unwrap().unwrap().display.as_deref(),
            Some("bo@myndighet.se")
        );

        let bare = sign(serde_json::json!({
            "sub": "guid-2", "iss": issuer, "aud": "holger",
            "exp": now + 3600, "iat": now,
        }));
        let principal = p.verify_bearer(&bare).await.unwrap().unwrap();
        assert_eq!(principal.display, None, "no name claim ⇒ no invented name");
        assert!(principal.groups.is_empty(), "no group claim ⇒ no grants");
    }

    /// Expired / wrong-signature / wrong-issuer / wrong-audience JWTs are
    /// authoritatively REJECTED (Err::Invalid) — offline is authoritative once
    /// keys are resolvable, so the dispatcher must not re-accept them.
    #[tokio::test]
    async fn bad_jwts_are_authoritatively_rejected() {
        let issuer = "https://idp.example/bad";
        let p = provider(issuer);

        // Expired.
        let expired = token_for(issuer, "holger", "alice", -3600);
        assert_eq!(p.verify_bearer(&expired).await.unwrap_err(), ProviderError::Invalid);

        // Wrong issuer.
        let wrong_iss = token_for("https://evil.example/realm", "holger", "alice", 3600);
        assert_eq!(p.verify_bearer(&wrong_iss).await.unwrap_err(), ProviderError::Invalid);

        // Wrong audience.
        let wrong_aud = token_for(issuer, "some-other-service", "alice", 3600);
        assert_eq!(p.verify_bearer(&wrong_aud).await.unwrap_err(), ProviderError::Invalid);

        // Tampered signature.
        let good = token_for(issuer, "holger", "alice", 3600);
        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_eq!(p.verify_bearer(&tampered).await.unwrap_err(), ProviderError::Invalid);
    }

    /// FULLY OFFLINE: a JWKS pre-provisioned as a FILE verifies a token with no
    /// network — `jwks_uri` points at an unroutable address to prove no fetch.
    #[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-file";
        let p = OidcProvider {
            issuer_url: issuer.into(),
            audience: Some("holger".into()),
            jwks_path: Some(path.to_string_lossy().to_string()),
            jwks_uri: Some("http://127.0.0.1:1/never".into()),
            ..OidcProvider::default()
        };
        let token = token_for(issuer, "holger", "airgap-bot", 3600);
        let principal = p.verify_bearer(&token).await.unwrap().expect("airgap file JWKS verifies");
        assert_eq!(principal.subject, "airgap-bot");
    }

    /// An opaque (non-JWT) token with no resolvable JWKS and no online path is
    /// "not mine" (Ok(None)) — the dispatcher tries the next provider.
    #[tokio::test]
    async fn opaque_token_is_not_mine_when_offline() {
        let p = OidcProvider {
            issuer_url: "https://idp.example/opaque".into(),
            ..OidcProvider::default()
        };
        // A UUID-shaped opaque token (not a JWT).
        assert!(p.verify_bearer("2c9a...-opaque").await.unwrap().is_none());
    }

    /// ★ A34-1/A34-4: **no configured audience refuses every JWT, by name.**
    /// Before, a missing audience turned the audience check off, so a token
    /// minted for any app of the tenant was a bearer here.
    #[tokio::test]
    async fn a34_no_audience_refuses_a_jwt_by_name() {
        let issuer = "https://idp.example/no-aud";
        let p = OidcProvider::inline(issuer, None, jwks_json());
        let token = token_for(issuer, "anything", "alice", 3600);
        assert_eq!(p.check_bearer(&token).await.unwrap_err(), BearerRefusal::NoAudience);
        assert_eq!(p.verify_bearer(&token).await.unwrap_err(), ProviderError::Invalid);
    }

    /// ★ A34-3: a signed token with NO `aud` or NO `iss` claim is refused.
    #[tokio::test]
    async fn a34_3_a_token_without_aud_or_iss_is_refused() {
        use std::time::{SystemTime, UNIX_EPOCH};
        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
        let issuer = "https://idp.example/claims";
        let p = provider(issuer);
        let no_aud = sign(serde_json::json!({"sub": "a", "iss": issuer, "exp": now + 600}));
        let no_iss = sign(serde_json::json!({"sub": "a", "aud": "holger", "exp": now + 600}));
        let no_exp = sign(serde_json::json!({"sub": "a", "iss": issuer, "aud": "holger"}));
        for (what, token) in [("no aud", no_aud), ("no iss", no_iss), ("no exp", no_exp)] {
            let refusal = p.check_bearer(&token).await.unwrap_err();
            assert_eq!(refusal.code(), "oidc.token_invalid", "{what}: {refusal}");
            assert_eq!(p.verify_bearer(&token).await.unwrap_err(), ProviderError::Invalid, "{what}");
        }
    }

    /// ★ A34-29/A34-13: the bearer path uses the relying party's algorithm
    /// allow-list: an HS256 token (keyed with anything) is refused.
    #[tokio::test]
    async fn a34_29_an_hmac_bearer_is_refused() {
        use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
        use std::time::{SystemTime, UNIX_EPOCH};
        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
        let issuer = "https://idp.example/hmac";
        let p = provider(issuer);
        let mut header = Header::new(Algorithm::HS256);
        header.kid = Some(TEST_KID.to_string());
        let token = encode(
            &header,
            &serde_json::json!({"sub": "a", "iss": issuer, "aud": "holger", "exp": now + 600}),
            &EncodingKey::from_secret(TEST_N.as_bytes()),
        )
        .unwrap();
        let refusal = p.check_bearer(&token).await.unwrap_err();
        assert!(
            matches!(&refusal, BearerRefusal::Token(rp::RpError::Signature(m)) if m.contains("not allowed")),
            "{refusal}"
        );
    }

    /// ★ A34-29: an `alg: none` token (no signature at all) is refused.
    #[tokio::test]
    async fn a34_29_an_alg_none_token_is_refused() {
        use base64::Engine;
        use std::time::{SystemTime, UNIX_EPOCH};
        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
        let issuer = "https://idp.example/alg-none";
        let p = provider(issuer);
        let b64 = |v: serde_json::Value| {
            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(v.to_string())
        };
        let token = format!(
            "{}.{}.",
            b64(serde_json::json!({"alg": "none", "typ": "JWT", "kid": TEST_KID})),
            b64(serde_json::json!({"sub": "a", "iss": issuer, "aud": "holger", "exp": now + 600})),
        );
        let refusal = p.check_bearer(&token).await.unwrap_err();
        assert_eq!(refusal.code(), "oidc.token_invalid", "{refusal}");
        assert_eq!(p.verify_bearer(&token).await.unwrap_err(), ProviderError::Invalid);
    }

    /// ★ A34-29: a JWKS that holds an `oct` (HMAC) key does not make an HS256
    /// token a bearer: the algorithm comes from the allow-list and must fit
    /// the KEY, never from the token header alone. Before the fix the header's
    /// HS256 was taken and the `oct` key verified it.
    #[tokio::test]
    async fn a34_29_an_oct_key_never_verifies_an_hmac_bearer() {
        use base64::Engine;
        use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
        use std::time::{SystemTime, UNIX_EPOCH};
        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
        let issuer = "https://idp.example/oct";
        let secret = b"a-shared-secret-of-thirty-two-by";
        let k = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(secret);
        let jwks = format!(r#"{{"keys":[{{"kty":"oct","kid":"hmac-1","alg":"HS256","k":"{k}"}}]}}"#);
        let p = OidcProvider::inline(issuer, Some("holger".into()), jwks);
        let mut header = Header::new(Algorithm::HS256);
        header.kid = Some("hmac-1".to_string());
        let token = encode(
            &header,
            &serde_json::json!({"sub": "mallory", "iss": issuer, "aud": "holger", "exp": now + 600}),
            &EncodingKey::from_secret(secret),
        )
        .unwrap();
        assert_eq!(p.verify_bearer(&token).await.unwrap_err(), ProviderError::Invalid);
    }

    /// A JWT with no key source at all is refused by name, offline.
    #[tokio::test]
    async fn a_jwt_with_no_key_source_is_refused_by_name() {
        let issuer = "https://idp.example/nokeys";
        let p = OidcProvider {
            issuer_url: issuer.into(),
            audience: Some("holger".into()),
            jwks_path: Some("/nonexistent/a34/jwks.json".into()),
            ..OidcProvider::default()
        };
        let token = token_for(issuer, "holger", "alice", 3600);
        let refusal = p.check_bearer(&token).await.unwrap_err();
        assert!(
            matches!(refusal, BearerRefusal::KeysUnavailable(_) | BearerRefusal::Endpoint(_)),
            "{refusal}"
        );
        assert_eq!(p.verify_bearer(&token).await.unwrap_err(), ProviderError::Invalid);
    }

    /// An opaque token with no opt-in is named `OpaqueNotEnabled`.
    #[tokio::test]
    async fn an_opaque_token_without_the_opt_in_is_named() {
        let p = provider("https://idp.example/opaque-named");
        assert_eq!(p.check_bearer("opaque").await.unwrap_err(), BearerRefusal::OpaqueNotEnabled);
    }

    /// The discovery document's userinfo and introspection endpoints are kept,
    /// and must be https like the others; `from_discovery` needs introspection.
    #[test]
    fn discovery_keeps_userinfo_and_introspection_and_opaque_needs_introspection() {
        let iss = "https://idp.example/realm";
        let doc = |extra: &str| {
            format!(
                r#"{{"issuer":"{iss}","authorization_endpoint":"{iss}/auth","token_endpoint":"{iss}/token","jwks_uri":"{iss}/certs"{extra}}}"#
            )
        };
        let d = rp::Discovery::parse(
            &doc(&format!(r#","userinfo_endpoint":"{iss}/me","introspection_endpoint":"{iss}/introspect""#)),
            iss,
            false,
        )
        .unwrap();
        assert_eq!(d.userinfo_endpoint.as_deref(), Some("https://idp.example/realm/me"));
        let o = OpaqueTokens::from_discovery(&d, "holger", "s3cret").unwrap();
        assert_eq!(o.introspection_endpoint, "https://idp.example/realm/introspect");
        assert!(!format!("{o:?}").contains("s3cret"), "the secret is never in Debug");
        let bare = rp::Discovery::parse(&doc(""), iss, false).unwrap();
        assert!(bare.userinfo_endpoint.is_none());
        assert!(OpaqueTokens::from_discovery(&bare, "holger", "").is_err());
        assert!(rp::Discovery::parse(&doc(r#","userinfo_endpoint":"http://idp.example/me""#), iss, false).is_err());
    }

    /// Device-code response parsing (offline): the user-facing URI prefers the
    /// pre-filled `verification_uri_complete`.
    #[test]
    fn device_code_parse_and_user_uri() {
        let json = r#"{"device_code":"dc","user_code":"WDJB-MJHT","verification_uri":"https://kc/device","verification_uri_complete":"https://kc/device?user_code=WDJB-MJHT","interval":5,"expires_in":600}"#;
        let dc = device::parse_device_code(json).expect("parses");
        assert_eq!(dc.user_code, "WDJB-MJHT");
        assert_eq!(dc.user_uri(), "https://kc/device?user_code=WDJB-MJHT");
        assert_eq!(dc.interval, 5);
    }

    /// Token-endpoint body parsing (offline): a success carries access + refresh +
    /// lifetime; `authorization_pending` is the still-waiting `Ok(None)`; a
    /// terminal error surfaces the error code. RED-when-broken for the shared
    /// login's poll/refresh core.
    #[test]
    fn token_response_parse_success_pending_and_error() {
        let ok = r#"{"access_token":"at-123","refresh_token":"rt-456","expires_in":3600,"token_type":"Bearer"}"#;
        let tr = device::parse_token_response(ok).expect("ok parses").expect("has token");
        assert_eq!(tr.access_token, "at-123");
        assert_eq!(tr.refresh_token.as_deref(), Some("rt-456"));
        assert_eq!(tr.expires_in, Some(3600));

        let pending = r#"{"error":"authorization_pending"}"#;
        assert!(device::parse_token_response(pending).unwrap().is_none(), "pending ⇒ Ok(None)");
        let slow = r#"{"error":"slow_down"}"#;
        assert!(device::parse_token_response(slow).unwrap().is_none(), "slow_down ⇒ Ok(None)");

        let denied = r#"{"error":"access_denied"}"#;
        assert_eq!(device::parse_token_response(denied).unwrap_err(), "access_denied");
    }
}

// ── Online userinfo + JWKS-fetch caching tests ───────────────────────────────
#[cfg(all(test, feature = "online"))]
mod online_tests {
    use super::test_support::*;
    use super::*;
    use nornir_service::provider::ProviderError;
    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`, counting hits.
    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;
                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}")
    }

    /// A throwaway JSON endpoint answering `body`, counting hits.
    async fn spawn_json(body: String, 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; 4096];
                let _ = sock.read(&mut buf).await;
                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}")
    }

    fn opaque_provider(issuer: &str, introspect: &str) -> OidcProvider {
        OidcProvider {
            issuer_url: issuer.into(),
            audience: Some("holger".into()),
            opaque: Some(OpaqueTokens {
                introspection_endpoint: format!("{introspect}/introspect"),
                userinfo_endpoint: None,
                client_id: "holger".into(),
                client_secret: "secret".into(),
            }),
            allow_loopback_http: true,
            ..OidcProvider::default()
        }
    }

    /// The OPT-IN opaque path: introspection says active and for this
    /// audience ⇒ the token's `sub`; cached (one call for two checks).
    #[tokio::test]
    async fn the_opt_in_opaque_check_accepts_an_active_token_for_this_audience() {
        let hits = Arc::new(AtomicUsize::new(0));
        let idp = spawn_json(r#"{"active":true,"aud":["holger"],"sub":"svc-1","scope":"read"}"#.into(), hits.clone()).await;
        let p = opaque_provider(&idp, &idp);
        let token = "opaque-a34-accepted";
        let a = p.verify_bearer(token).await.unwrap().expect("introspected");
        let b = p.verify_bearer(token).await.unwrap().expect("cached");
        assert_eq!((a.subject.as_str(), b.subject.as_str()), ("svc-1", "svc-1"));
        assert!(a.has_scope("read"));
        assert_eq!(hits.load(Ordering::SeqCst), 1, "the second check is served from the cache");
    }

    /// The opt-in opaque path refuses a token for ANOTHER audience, an
    /// inactive one, and one from another issuer — authoritatively.
    #[tokio::test]
    async fn the_opt_in_opaque_check_refuses_another_audience_inactive_and_another_issuer() {
        for (what, body) in [
            ("another audience", r#"{"active":true,"aud":"another-app","sub":"x"}"#),
            ("inactive", r#"{"active":false}"#),
            ("another issuer", r#"{"active":true,"aud":"holger","sub":"x","iss":"https://evil.example"}"#),
            ("no audience", r#"{"active":true,"sub":"x"}"#),
        ] {
            let hits = Arc::new(AtomicUsize::new(0));
            let idp = spawn_json(body.into(), hits).await;
            let p = opaque_provider(&idp, &idp);
            let token = format!("opaque-a34-{}", what.replace(' ', "-"));
            let refusal = p.check_bearer(&token).await.unwrap_err();
            assert_eq!(refusal.code(), "oidc.opaque_refused", "{what}: {refusal}");
            assert_eq!(p.verify_bearer(&token).await.unwrap_err(), ProviderError::Invalid, "{what}");
        }
    }

    /// ★ A34-28: an http `jwks_uri` is refused unless it is a loopback test
    /// IdP the caller allowed; the bearer is never sent in cleartext.
    #[tokio::test]
    async fn a34_28_an_http_jwks_uri_is_refused() {
        let hits = Arc::new(AtomicUsize::new(0));
        let jwks_uri = spawn_jwks(hits.clone()).await;
        let issuer = "https://idp.example/http-jwks";
        clear_jwks_cache();
        let p = OidcProvider {
            issuer_url: issuer.into(),
            audience: Some("holger".into()),
            jwks_uri: Some(jwks_uri),
            ..OidcProvider::default()
        };
        let token = token_for(issuer, "holger", "u1", 3600);
        assert_eq!(p.check_bearer(&token).await.unwrap_err().code(), "oidc.endpoint_not_https");
        assert_eq!(hits.load(Ordering::SeqCst), 0, "nothing was fetched over http");
    }

    /// A34-30: a key the cached set lacks (rotation) gets ONE refetch.
    #[tokio::test]
    async fn an_unknown_kid_gets_one_refetch() {
        let hits = Arc::new(AtomicUsize::new(0));
        let old_set = r#"{"keys":[{"kty":"RSA","use":"sig","alg":"RS256","kid":"old-key","n":"AQAB","e":"AQAB"}]}"#;
        let issuer = "https://idp.example/rotation";
        let jwks_uri = spawn_jwks(hits.clone()).await;
        clear_jwks_cache();
        let p = OidcProvider {
            issuer_url: issuer.into(),
            audience: Some("holger".into()),
            jwks_uri: Some(jwks_uri.clone()),
            allow_loopback_http: true,
            ..OidcProvider::default()
        };
        // Seed the cache with a set that lacks the token's key.
        cache_jwks(
            &p.cache_key(&format!("uri:{jwks_uri}")),
            serde_json::from_str(old_set).unwrap(),
            Some(Instant::now() + Duration::from_secs(600)),
        );
        let token = token_for(issuer, "holger", "rotated", 3600);
        assert_eq!(p.verify_bearer(&token).await.unwrap().unwrap().subject, "rotated");
        assert_eq!(hits.load(Ordering::SeqCst), 1, "one refetch for the unknown kid");
    }

    /// A tiny JWKS endpoint counting fetches — proves fetch-once-then-cache.
    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")
    }

    /// A provider whose only key source is `jwks_uri` (Holger's marketplace
    /// shape), audience `holger`, on an `issuer` that also answers `/userinfo`.
    fn uri_only(issuer: &str, jwks_uri: &str) -> OidcProvider {
        OidcProvider {
            issuer_url: issuer.into(),
            audience: Some("holger".into()),
            jwks_uri: Some(jwks_uri.into()),
            // So the dead endpoint is really FETCHED (and fails), rather than
            // refused for being http.
            allow_loopback_http: true,
            ..OidcProvider::default()
        }
    }

    /// Nothing listens here: the JWKS fetch fails (wrong endpoint, IdP hiccup).
    const DEAD_JWKS: &str = "http://127.0.0.1:1/jwks";

    /// ★ A34-1: **a JWT minted for ANOTHER application is refused**, even when
    /// the JWKS fetch fails. Before the fix it fell through to a guessed
    /// `{issuer}/userinfo`, which checks no audience, and authenticated.
    #[tokio::test]
    async fn a34_1_a_jwt_for_another_audience_never_falls_through_to_userinfo() {
        let hits = Arc::new(AtomicUsize::new(0));
        let issuer = spawn_userinfo(hits.clone()).await;
        clear_jwks_cache();
        let p = uri_only(&issuer, DEAD_JWKS);
        let token = token_for(&issuer, "another-app", "alice", 3600);
        assert_eq!(
            p.verify_bearer(&token).await.map(|o| o.map(|p| p.subject)),
            Err(ProviderError::Invalid),
            "a token for another audience must be refused"
        );
        assert_eq!(hits.load(Ordering::SeqCst), 0, "no /userinfo call for a JWT");
    }

    /// ★ A34-1: **a JWT from ANOTHER issuer is refused**, the same way.
    #[tokio::test]
    async fn a34_1_a_jwt_from_another_issuer_never_falls_through_to_userinfo() {
        let hits = Arc::new(AtomicUsize::new(0));
        let issuer = spawn_userinfo(hits.clone()).await;
        clear_jwks_cache();
        let p = uri_only(&issuer, DEAD_JWKS);
        let token = token_for("https://evil.example/realm", "holger", "alice", 3600);
        assert_eq!(
            p.verify_bearer(&token).await.map(|o| o.map(|p| p.subject)),
            Err(ProviderError::Invalid),
            "a token from another issuer must be refused"
        );
        assert_eq!(hits.load(Ordering::SeqCst), 0, "no /userinfo call for a JWT");
    }

    /// ★ A34-1: **a cold JWKS cache refuses**: a good token whose keys cannot
    /// be fetched right now is refused by name, never sent to `/userinfo`.
    #[tokio::test]
    async fn a34_1_a_cold_jwks_cache_refuses_and_never_falls_through() {
        let hits = Arc::new(AtomicUsize::new(0));
        let issuer = spawn_userinfo(hits.clone()).await;
        clear_jwks_cache();
        let p = uri_only(&issuer, DEAD_JWKS);
        let token = token_for(&issuer, "holger", "alice", 3600);
        assert_eq!(
            p.verify_bearer(&token).await.map(|o| o.map(|p| p.subject)),
            Err(ProviderError::Invalid),
            "no keys means no verification: refused"
        );
        assert_eq!(hits.load(Ordering::SeqCst), 0, "no /userinfo call for a JWT");
    }

    /// ★ A34-1: **an opaque token is not authenticated by a guessed
    /// `/userinfo`.** Without the explicit opt-in it is "not mine" (the next
    /// provider may know it), and the IdP is never asked.
    #[tokio::test]
    async fn a34_1_an_opaque_token_is_never_sent_to_a_guessed_userinfo() {
        let hits = Arc::new(AtomicUsize::new(0));
        let issuer = spawn_userinfo(hits.clone()).await;
        let p = uri_only(&issuer, DEAD_JWKS);
        let got = p.verify_bearer("opaque-token-a34-1").await;
        assert!(
            matches!(got, Ok(None)),
            "an opaque token must not authenticate: {:?}",
            got.map(|o| o.map(|p| p.subject))
        );
        assert_eq!(hits.load(Ordering::SeqCst), 0, "the guessed /userinfo was called");
    }

    /// A throwaway IdP answering its discovery document at
    /// `/.well-known/openid-configuration` (naming `doc_issuer` and the key set
    /// at `/keys`, Keycloak-style, NOT the guessable `/jwks`) and the test key
    /// set at `/keys`. Counts discovery, key and any other hits.
    async fn spawn_discovering_idp(
        doc_issuer: Option<String>,
        hits: Arc<[AtomicUsize; 3]>,
    ) -> String {
        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
        let base = format!("http://{}", listener.local_addr().unwrap());
        let issuer = doc_issuer.unwrap_or_else(|| base.clone());
        let keys_at = format!("{base}/keys");
        tokio::spawn(async move {
            while let Ok((mut sock, _)) = listener.accept().await {
                let mut buf = [0u8; 2048];
                let n = sock.read(&mut buf).await.unwrap_or(0);
                let request = String::from_utf8_lossy(&buf[..n]).to_string();
                let path = request.split_whitespace().nth(1).unwrap_or_default().to_string();
                let (status, body) = match path.as_str() {
                    "/.well-known/openid-configuration" => {
                        hits[0].fetch_add(1, Ordering::SeqCst);
                        (
                            "200 OK",
                            serde_json::json!({
                                "issuer": issuer,
                                "authorization_endpoint": format!("{issuer}/auth"),
                                "token_endpoint": format!("{issuer}/token"),
                                "jwks_uri": keys_at,
                            })
                            .to_string(),
                        )
                    }
                    "/keys" => {
                        hits[1].fetch_add(1, Ordering::SeqCst);
                        ("200 OK", jwks_json())
                    }
                    _ => {
                        hits[2].fetch_add(1, Ordering::SeqCst);
                        ("404 Not Found", "{}".to_string())
                    }
                };
                let head = format!(
                    "HTTP/1.1 {status}\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;
            }
        });
        base
    }

    /// ★ A34-1: with no `jwks_uri` the key set is the one the issuer's
    /// DISCOVERY document names, not a guessed `{issuer}/jwks` (which no major
    /// IdP serves). Discovery and keys are fetched once, then cached.
    #[tokio::test]
    async fn a34_1_the_key_set_comes_from_discovery_not_a_guess() {
        let hits = Arc::new([AtomicUsize::new(0), AtomicUsize::new(0), AtomicUsize::new(0)]);
        let issuer = spawn_discovering_idp(None, hits.clone()).await;
        let p = OidcProvider {
            issuer_url: issuer.clone(),
            audience: Some("holger".into()),
            allow_loopback_http: true,
            ..OidcProvider::default()
        };
        let t1 = token_for(&issuer, "holger", "via-discovery", 3600);
        let t2 = token_for(&issuer, "holger", "via-cache", 3600);
        assert_eq!(p.verify_bearer(&t1).await.unwrap().unwrap().subject, "via-discovery");
        assert_eq!(p.verify_bearer(&t2).await.unwrap().unwrap().subject, "via-cache");
        assert_eq!(hits[0].load(Ordering::SeqCst), 1, "discovery fetched once");
        assert_eq!(hits[1].load(Ordering::SeqCst), 1, "the discovered key set fetched once");
        assert_eq!(hits[2].load(Ordering::SeqCst), 0, "no guessed path was asked");
        // A token for another audience is still refused with discovered keys.
        let other = token_for(&issuer, "another-app", "alice", 3600);
        assert_eq!(p.verify_bearer(&other).await.unwrap_err(), ProviderError::Invalid);
    }

    /// ★ A34-1: a discovery document that names ANOTHER issuer is somebody
    /// else's: its keys are never used, and the JWT is refused by name.
    #[tokio::test]
    async fn a34_1_a_discovery_document_for_another_issuer_is_refused() {
        let hits = Arc::new([AtomicUsize::new(0), AtomicUsize::new(0), AtomicUsize::new(0)]);
        let issuer =
            spawn_discovering_idp(Some("https://evil.example/realm".into()), hits.clone()).await;
        let p = OidcProvider {
            issuer_url: issuer.clone(),
            audience: Some("holger".into()),
            allow_loopback_http: true,
            ..OidcProvider::default()
        };
        let token = token_for(&issuer, "holger", "alice", 3600);
        let refusal = p.check_bearer(&token).await.unwrap_err();
        assert_eq!(refusal.code(), "oidc.keys_unavailable", "{refusal}");
        assert_eq!(p.verify_bearer(&token).await.unwrap_err(), ProviderError::Invalid);
        assert_eq!(hits[1].load(Ordering::SeqCst), 0, "the foreign document's keys were fetched");
    }

    /// Cache miss → one fetch; subsequent verifies are cache hits.
    #[tokio::test]
    async fn jwks_fetched_once_then_cached() {
        let hits = Arc::new(AtomicUsize::new(0));
        let jwks_uri = spawn_jwks(hits.clone()).await;
        let issuer = "https://idp.example/cache-test-oidc-crate";
        clear_jwks_cache();
        let p = OidcProvider {
            issuer_url: issuer.into(),
            audience: Some("holger".into()),
            jwks_uri: Some(jwks_uri),
            allow_loopback_http: true,
            ..OidcProvider::default()
        };
        let t1 = token_for(issuer, "holger", "u1", 3600);
        let t2 = token_for(issuer, "holger", "u2", 3600);
        assert_eq!(p.verify_bearer(&t1).await.unwrap().unwrap().subject, "u1");
        assert_eq!(p.verify_bearer(&t2).await.unwrap().unwrap().subject, "u2");
        assert_eq!(hits.load(Ordering::SeqCst), 1, "JWKS fetched once then cached");
    }
}