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");
}
}