klieo-auth-oauth 3.4.0

OAuth 2.0 bearer-token Authenticator for klieo HTTP transports
Documentation
//! Builder for [`OAuthAuthenticator`]. The async [`build`] step
//! fetches JWKS at construction time so misconfigurations
//! (unreachable endpoint, malformed JWK set, missing required field)
//! fail fast — handlers never see a half-constructed authenticator.
//!
//! [`build`]: OAuthAuthenticatorBuilder::build

use crate::authenticator::OAuthAuthenticator;
use crate::cache::TokenCache;
use crate::error::OAuthError;
use crate::introspect::IntrospectionConfig;
use crate::jwks::Cache;
use jsonwebtoken::Algorithm;
use std::collections::HashMap;
use std::num::NonZeroUsize;
use std::sync::Arc;

/// Fluent builder for [`OAuthAuthenticator`].
pub struct OAuthAuthenticatorBuilder {
    issuer_url: Option<String>,
    audience: Option<String>,
    allowed_algs: Vec<Algorithm>,
    jwks_uri: Option<String>,
    required_scopes: HashMap<String, Vec<String>>,
    http: Option<reqwest::Client>,
    introspection: Option<IntrospectionConfig>,
    token_cache_capacity: usize,
}

impl Default for OAuthAuthenticatorBuilder {
    fn default() -> Self {
        Self {
            issuer_url: None,
            audience: None,
            allowed_algs: vec![Algorithm::RS256, Algorithm::ES256],
            jwks_uri: None,
            required_scopes: HashMap::new(),
            http: None,
            introspection: None,
            token_cache_capacity: 0,
        }
    }
}

impl OAuthAuthenticatorBuilder {
    /// OAuth issuer URL — validated against the JWT `iss` claim.
    /// Required.
    pub fn issuer_url(mut self, url: impl Into<String>) -> Self {
        self.issuer_url = Some(url.into());
        self
    }

    /// Expected token audience — validated against the JWT `aud`
    /// claim. Required; [`build`] returns
    /// [`OAuthError::Misconfigured`] when not set.
    ///
    /// [`build`]: OAuthAuthenticatorBuilder::build
    pub fn audience(mut self, aud: impl Into<String>) -> Self {
        self.audience = Some(aud.into());
        self
    }

    /// Override the algorithm allowlist. Default: `[RS256, ES256]`.
    /// The header `alg` of every JWT is checked against this list;
    /// tokens using an unlisted algorithm are rejected immediately
    /// with [`AuthError::Rejected`] before any key material is consulted.
    ///
    /// Passing an empty `Vec` causes [`OAuthAuthenticatorBuilder::build`] to
    /// return [`OAuthError::Misconfigured`].
    ///
    /// [`AuthError::Rejected`]: klieo_auth_common::AuthError::Rejected
    pub fn allowed_algorithms(mut self, algs: Vec<Algorithm>) -> Self {
        self.allowed_algs = algs;
        self
    }

    /// JWKS endpoint — fetched once at [`build`] and refreshed once
    /// on signature-verification failure. Required.
    ///
    /// [`build`]: OAuthAuthenticatorBuilder::build
    pub fn jwks_uri(mut self, uri: impl Into<String>) -> Self {
        self.jwks_uri = Some(uri.into());
        self
    }

    /// Method-to-required-scopes allow-list consulted by
    /// [`Authenticator::authorize_method`]. Methods absent from the
    /// map are denied; methods mapped to an empty `Vec` allow any
    /// authenticated principal; methods mapped to a non-empty `Vec`
    /// require at least one matching scope on the token.
    ///
    /// [`Authenticator::authorize_method`]: klieo_auth_common::Authenticator::authorize_method
    pub fn required_scopes(mut self, map: HashMap<String, Vec<String>>) -> Self {
        self.required_scopes = map;
        self
    }

    /// Bring-your-own [`reqwest::Client`] (e.g. for connection
    /// pooling or custom TLS roots). Optional; the default builds a
    /// fresh client.
    pub fn http_client(mut self, http: reqwest::Client) -> Self {
        self.http = Some(http);
        self
    }

    /// Configure RFC 7662 introspection fallback. When JWT
    /// verification fails (e.g. opaque token), the authenticator
    /// POSTs to the introspection endpoint with Basic auth and
    /// extracts sub + scope from the response.
    ///
    /// See ADR-021.
    pub fn with_introspection(
        mut self,
        url: impl Into<String>,
        client_id: impl Into<String>,
        client_secret: impl Into<String>,
    ) -> Self {
        self.introspection = Some(IntrospectionConfig {
            url: url.into(),
            client_id: client_id.into(),
            client_secret: client_secret.into(),
        });
        self
    }

    /// Enable an LRU cache for verified bearer tokens. The cache is
    /// keyed by SHA-256(token) and holds `(sub, scopes, expires_at)`;
    /// a cache hit short-circuits both the JWT signature verify and
    /// the introspection roundtrip.
    ///
    /// `capacity` is the maximum number of entries retained. `0`
    /// (the default) disables the cache entirely. Reasonable values
    /// sit in the low thousands — each entry holds a `sub` string,
    /// the scope set, and a `SystemTime`.
    ///
    /// Entry TTL follows the token's `exp` claim, clamped to a
    /// 5-minute ceiling so long-lived tokens still re-verify every
    /// five minutes. Tokens without a usable `exp` fall back to a
    /// 60-second TTL.
    ///
    /// **Revocation caveat**: a token revoked between cache insert
    /// and entry expiry continues to authenticate. Operators that
    /// need sub-minute revocation latency should either configure
    /// short `exp` claims at the IdP or leave the cache disabled.
    #[must_use]
    pub fn with_token_cache(mut self, capacity: usize) -> Self {
        self.token_cache_capacity = capacity;
        self
    }

    /// Fetch the IdP's OpenID Connect Discovery document at
    /// `{issuer_url}/.well-known/openid-configuration` and
    /// auto-populate `jwks_uri` + `issuer_url` from the response.
    ///
    /// Async because it fetches over HTTP at config time. Errors
    /// surface as `OAuthError::DiscoveryFailed`; operators can
    /// fall back to explicit `.jwks_uri(...)` if discovery is
    /// not available.
    ///
    /// See ADR-021.
    pub async fn with_discovery(
        mut self,
        issuer_url: impl Into<String>,
    ) -> Result<Self, OAuthError> {
        let issuer = issuer_url.into();
        let http = crate::http::default_http_client()?;
        let doc = crate::discovery::fetch(&http, &issuer).await?;
        // OIDC Discovery §4.3 / RFC 8414 §3.3: the document's `issuer` MUST be
        // identical to the URL it was fetched from. A mismatch means a spoofed
        // or misconfigured document; trusting its `issuer` would pin the JWT
        // `iss` check to an attacker-chosen value. Tolerate only a trailing
        // slash (same origin + path).
        if doc.issuer.trim_end_matches('/') != issuer.trim_end_matches('/') {
            return Err(OAuthError::DiscoveryFailed(format!(
                "issuer mismatch: document declared an issuer that differs from {issuer}"
            )));
        }
        self.issuer_url = Some(doc.issuer);
        self.jwks_uri = Some(doc.jwks_uri);
        Ok(self)
    }

    /// Validate configuration, fetch JWKS once, and return a ready
    /// [`OAuthAuthenticator`]. Fails fast on missing required fields,
    /// unreachable JWKS endpoint, or malformed JWK set.
    ///
    /// # Errors
    ///
    /// Returns [`OAuthError::Misconfigured`] when any of the following are missing or invalid:
    /// - `issuer_url` — set via [`.issuer_url()`](OAuthAuthenticatorBuilder::issuer_url)
    /// - `audience` — set via [`.audience()`](OAuthAuthenticatorBuilder::audience)
    /// - `allowed_algs` is empty — set via [`.allowed_algorithms()`](OAuthAuthenticatorBuilder::allowed_algorithms)
    /// - `jwks_uri` — set via [`.jwks_uri()`](OAuthAuthenticatorBuilder::jwks_uri)
    ///
    /// Returns [`OAuthError::JwksFetch`] when the JWKS endpoint is unreachable or returns an error.
    pub async fn build(self) -> Result<OAuthAuthenticator, OAuthError> {
        let issuer = self
            .issuer_url
            .ok_or_else(|| OAuthError::Misconfigured("issuer_url required".into()))?;
        let audience = self.audience.ok_or_else(|| {
            OAuthError::Misconfigured("audience required; call .audience() on the builder".into())
        })?;
        if self.allowed_algs.is_empty() {
            return Err(OAuthError::Misconfigured(
                "allowed_algs must not be empty".into(),
            ));
        }
        let jwks_uri = self
            .jwks_uri
            .ok_or_else(|| OAuthError::Misconfigured("jwks_uri required".into()))?;
        let http = match self.http {
            Some(http) => http,
            None => crate::http::default_http_client()?,
        };
        let cache = Cache::fetch(http.clone(), jwks_uri).await?;
        let token_cache =
            NonZeroUsize::new(self.token_cache_capacity).map(|cap| Arc::new(TokenCache::new(cap)));
        Ok(OAuthAuthenticator {
            issuer,
            audience,
            allowed_algs: self.allowed_algs,
            cache,
            scope_map: Arc::new(self.required_scopes),
            http,
            introspection: self.introspection,
            token_cache,
        })
    }
}

impl OAuthAuthenticator {
    /// Construct a fluent builder for this authenticator.
    pub fn builder() -> OAuthAuthenticatorBuilder {
        OAuthAuthenticatorBuilder::default()
    }

    /// Capability-shaped default — fetch the OIDC discovery document
    /// at `issuer_url`, validate tokens against `audience`, and
    /// return a ready [`OAuthAuthenticator`].
    ///
    /// Uses default algorithm allowlist (`RS256` + `ES256`) and an
    /// empty `required_scopes` map. **Empty `required_scopes` denies
    /// every method** — `authorize_method` requires an entry per
    /// method (empty `Vec` allows any authenticated principal,
    /// non-empty `Vec` requires at least one matching scope). Call
    /// [`Self::builder`] and chain
    /// [`OAuthAuthenticatorBuilder::required_scopes`] to allow-list
    /// the methods your transport exposes. Same path for introspection,
    /// custom `reqwest::Client`, or a non-default algorithm list.
    ///
    /// # Errors
    ///
    /// - [`OAuthError::DiscoveryFailed`] when the discovery document
    ///   cannot be fetched or parsed.
    /// - [`OAuthError::JwksFetch`] when the JWKS endpoint discovered
    ///   from the document is unreachable or returns an error.
    pub async fn from_oidc(
        issuer_url: impl Into<String>,
        audience: impl Into<String>,
    ) -> Result<Self, OAuthError> {
        Self::builder()
            .with_discovery(issuer_url)
            .await?
            .audience(audience)
            .build()
            .await
    }
}