klieo-auth-oauth 3.5.0

OAuth 2.0 bearer-token Authenticator for klieo HTTP transports
Documentation
//! RFC 7662 OAuth 2.0 Token Introspection — opaque-token fallback.
//!
//! When `OAuthAuthenticator::verify_token` cannot parse the
//! bearer as a JWT, it POSTs to the configured introspection
//! endpoint with HTTP Basic auth (`client_id:client_secret`)
//! and `application/x-www-form-urlencoded` body `token=<bearer>`.
//! The endpoint returns `{active: bool, sub?, scope?, exp?, ...}`.

use crate::error::OAuthError;
use serde::Deserialize;

/// Configuration for the RFC 7662 introspection endpoint.
#[derive(Clone, Debug)]
pub(crate) struct IntrospectionConfig {
    pub(crate) url: String,
    pub(crate) client_id: String,
    pub(crate) client_secret: String,
}

/// Decoded RFC 7662 introspection response. Only the fields the
/// authenticator consumes are surfaced; unknown fields are ignored.
#[derive(Debug, Deserialize)]
pub(crate) struct IntrospectionResponse {
    pub(crate) active: bool,
    #[serde(default)]
    pub(crate) sub: Option<String>,
    /// RFC 7662 §2.2 audience. A JWT-style claim: a single string or an
    /// array. Pinned against the configured audience to block audience
    /// confusion (a token minted for another resource server).
    #[serde(default)]
    pub(crate) aud: Option<Audience>,
    /// RFC 7662 §2.2 issuer. Pinned against the configured issuer.
    #[serde(default)]
    pub(crate) iss: Option<String>,
    #[serde(default)]
    pub(crate) scope: Option<String>,
    #[serde(default)]
    pub(crate) exp: Option<u64>,
}

/// An `aud` claim: RFC 7519 allows a single string or an array of strings.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub(crate) enum Audience {
    One(String),
    Many(Vec<String>),
}

impl Audience {
    /// Whether `expected` is among the audiences this token was minted for.
    pub(crate) fn contains(&self, expected: &str) -> bool {
        match self {
            Self::One(aud) => aud == expected,
            Self::Many(auds) => auds.iter().any(|aud| aud == expected),
        }
    }
}

/// POST `token=<bearer>` to the introspection endpoint with HTTP Basic
/// auth. Returns the decoded response or maps transport / status / body
/// failures to [`OAuthError::IntrospectionFailed`].
pub(crate) async fn call(
    http: &reqwest::Client,
    cfg: &IntrospectionConfig,
    bearer: &str,
) -> Result<IntrospectionResponse, OAuthError> {
    let resp = http
        .post(&cfg.url)
        .basic_auth(&cfg.client_id, Some(&cfg.client_secret))
        .form(&[("token", bearer)])
        .send()
        .await
        .map_err(|e| OAuthError::IntrospectionFailed(format!("request failed: {e}")))?;
    if !resp.status().is_success() {
        return Err(OAuthError::IntrospectionFailed(format!(
            "status {}",
            resp.status()
        )));
    }
    resp.json::<IntrospectionResponse>()
        .await
        .map_err(|e| OAuthError::IntrospectionFailed(format!("body: {e}")))
}

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

    #[test]
    fn audience_string_matches_only_the_exact_value() {
        let aud = Audience::One("klieo-test".into());
        assert!(aud.contains("klieo-test"));
        assert!(!aud.contains("other-resource-server"));
    }

    #[test]
    fn audience_array_matches_any_member() {
        let aud = Audience::Many(vec!["a".into(), "klieo-test".into()]);
        assert!(aud.contains("klieo-test"));
        assert!(aud.contains("a"));
        assert!(!aud.contains("not-present"));
    }

    #[test]
    fn audience_empty_array_matches_nothing() {
        let aud = Audience::Many(vec![]);
        assert!(!aud.contains("klieo-test"));
    }
}