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-oauth2/src/lib.rs

//! # nornir-auth-oauth2
//!
//! The **OAuth 2.0 / 2.1** auth provider — the pure-OAuth2 path that sits BESIDE
//! [`nornir-auth-oidc`] in the shared edda auth family. Where oidc validates a
//! bearer **JWT offline** against a cached JWKS (airgap) or an issuer `/userinfo`,
//! this provider verifies an **opaque access token** the SP-side way: **RFC 7662
//! token introspection** — POST the token to the IdP's introspection endpoint and
//! map the `active`/`sub`/`scope` response to a [`Principal`]. It also drives the
//! two OAuth2 *acquire* flows a nordisk client/service needs:
//!
//! * **client-credentials** (service-to-service / M2M) — [`client_credentials_token`],
//! * **authorization-code + PKCE** (interactive human SSO) — [`authorize_url_pkce`].
//!
//! Every piece REUSES the [`oauth2`] crate (Law #1/#5 — the protocol is never
//! hand-rolled here). This one provider enables **Entra ID / Okta / Auth0 /
//! Google / AWS IAM Identity Center** as OAuth providers.
//!
//! ## Ship-small
//!
//! The `oauth2` core is pure Rust (url + serde, no network). The outbound-HTTP
//! transport rides behind the crate's own `online` feature, so a tier that pins
//! `oauth2` **offline** links no HTTP-egress client and [`OAuth2Provider::verify_bearer`]
//! fails closed (`Ok(None)` — "not mine", try the next provider). The mini/embedded
//! bearer-only tier never pins this crate at all.
//!
//! ## Dispatch semantics
//!
//! `verify_bearer` returns `Ok(Some(principal))` only when introspection reports
//! the token **active** with an identifiable subject. An **inactive** response is
//! `Ok(None)` — NOT a hard [`ProviderError::Invalid`] — because RFC 7662 also
//! answers `active:false` for a token that simply belongs to another provider
//! (e.g. a static bearer key the IdP has never seen); returning `Invalid` would
//! wrongly stop the dispatcher from trying that provider. A transport failure is
//! [`ProviderError::Unavailable`] (the dispatcher moves on).
//!
//! ```
//! use nornir_auth_oauth2::{OAuth2Provider, OAuth2Config};
//! use nornir_service::provider::AuthProvider;
//! # async fn demo() {
//! let p = OAuth2Provider::new(OAuth2Config {
//!     introspection_url: "https://login.example.com/oauth2/introspect".into(),
//!     client_id: "resource-server".into(),
//!     client_secret: "s3cret".into(),
//!     ..Default::default()
//! });
//! assert_eq!(p.name(), "oauth2");
//! # let _ = p;
//! # }
//! ```

use nornir_service::provider::{AuthProvider, Principal, ProviderError};
use oauth2::basic::BasicTokenIntrospectionResponse;
use oauth2::TokenIntrospectionResponse;
use tonic::async_trait;

/// Configuration for an OAuth2 provider / client. The `introspection_url` +
/// credentials are what [`OAuth2Provider`] needs to VERIFY a presented token; the
/// `auth_url` / `token_url` / `redirect_url` feed the *acquire* helpers
/// ([`authorize_url_pkce`] / [`client_credentials_token`]).
#[derive(Debug, Clone, Default)]
pub struct OAuth2Config {
    /// RFC 7662 token-introspection endpoint (verify path).
    pub introspection_url: String,
    /// This resource server's OAuth2 client id (authenticates the introspection
    /// call and identifies the client for the acquire flows).
    pub client_id: String,
    /// This client's secret (confidential client). Empty for a public client.
    pub client_secret: String,
    /// Authorization endpoint — needed only by [`authorize_url_pkce`].
    pub auth_url: String,
    /// Token endpoint — needed only by [`client_credentials_token`].
    pub token_url: String,
    /// Redirect URI registered with the IdP — needed only by [`authorize_url_pkce`].
    pub redirect_url: String,
}

/// An OAuth 2.0 / 2.1 auth provider. Verifies a presented access token by RFC 7662
/// introspection against the configured IdP; a successful, active response with a
/// subject yields a [`Principal`] with `method = "oauth2"` carrying the token's
/// granted scopes.
#[derive(Debug, Clone)]
pub struct OAuth2Provider {
    cfg: OAuth2Config,
}

impl OAuth2Provider {
    /// Build from configuration.
    pub fn new(cfg: OAuth2Config) -> Self {
        Self { cfg }
    }

    /// The configuration this provider was built with.
    pub fn config(&self) -> &OAuth2Config {
        &self.cfg
    }

    /// A configured introspection endpoint. A provider with none authenticates
    /// nothing (`verify_bearer` → `Ok(None)`) — fail-closed.
    pub fn is_enabled(&self) -> bool {
        !self.cfg.introspection_url.trim().is_empty()
    }
}

/// Map an RFC 7662 introspection response to a verification decision — the PURE,
/// network-free core of [`OAuth2Provider::verify_bearer`], so the identity mapping
/// is testable without a live IdP.
///
/// * active + (`sub` or `username`) ⇒ `Ok(Some(Principal{ method: "oauth2" }))`
///   carrying the response's `scope` grants.
/// * active but no subject ⇒ `Ok(None)` (can't key RBAC on nothing).
/// * inactive ⇒ `Ok(None)` — "not mine / rejected", the dispatcher tries the
///   next provider (see the crate dispatch-semantics note).
pub fn principal_from_introspection(
    resp: &BasicTokenIntrospectionResponse,
) -> Result<Option<Principal>, ProviderError> {
    if !resp.active() {
        return Ok(None);
    }
    let subject = resp
        .sub()
        .map(str::to_string)
        .or_else(|| resp.username().map(str::to_string));
    let Some(subject) = subject else {
        log::warn!("oauth2: introspection reported active token with no sub/username — rejecting");
        return Ok(None);
    };
    let scopes = resp
        .scopes()
        .map(|s| s.iter().map(|sc| (**sc).clone()).collect())
        .unwrap_or_default();
    Ok(Some(Principal::new(subject, "oauth2").with_scopes(scopes)))
}

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

    /// Verify a presented access token by RFC 7662 introspection.
    ///
    /// Online (feature `online`): POST the token to the introspection endpoint via
    /// the `oauth2` reqwest transport and map the response through
    /// [`principal_from_introspection`]. Offline (no `online`, or no configured
    /// endpoint): `Ok(None)` — fail closed, the dispatcher tries the next provider.
    async fn verify_bearer(&self, token: &str) -> Result<Option<Principal>, ProviderError> {
        if !self.is_enabled() {
            return Ok(None);
        }
        #[cfg(feature = "online")]
        {
            self.introspect_online(token).await
        }
        #[cfg(not(feature = "online"))]
        {
            let _ = token;
            log::debug!(
                "oauth2: built without `online` — cannot introspect (fail-closed, not mine)"
            );
            Ok(None)
        }
    }
}

#[cfg(feature = "online")]
impl OAuth2Provider {
    /// The networked introspection call (online only). Reuses the `oauth2`
    /// client's RFC 7662 request builder over a rustls reqwest transport.
    async fn introspect_online(
        &self,
        token: &str,
    ) -> Result<Option<Principal>, ProviderError> {
        use oauth2::{
            basic::BasicClient, AccessToken, ClientId, ClientSecret, IntrospectionUrl,
        };

        let url = IntrospectionUrl::new(self.cfg.introspection_url.clone()).map_err(|e| {
            log::error!("oauth2: bad introspection_url: {e}");
            ProviderError::Unavailable
        })?;
        let mut client = BasicClient::new(ClientId::new(self.cfg.client_id.clone()))
            .set_introspection_url(url);
        if !self.cfg.client_secret.is_empty() {
            client = client.set_client_secret(ClientSecret::new(self.cfg.client_secret.clone()));
        }

        let http = reqwest::Client::builder().build().map_err(|e| {
            log::error!("oauth2: could not build HTTP client: {e}");
            ProviderError::Unavailable
        })?;

        match client
            .introspect(&AccessToken::new(token.to_string()))
            .request_async(&http)
            .await
        {
            Ok(resp) => principal_from_introspection(&resp),
            Err(e) => {
                // A transport/endpoint failure is non-authoritative: the token may
                // still be another provider's. Move on.
                log::warn!("oauth2: introspection request failed: {e}");
                Err(ProviderError::Unavailable)
            }
        }
    }
}

/// Acquire a service-to-service (M2M) access token via the **client-credentials**
/// grant (online only). Reuses the `oauth2` client. Returns the raw access-token
/// string on success. Enables a nordisk service to authenticate to another with a
/// short-lived OAuth2 token (Entra ID / Okta / Auth0 / AWS IAM Identity Center).
#[cfg(feature = "online")]
pub async fn client_credentials_token(
    cfg: &OAuth2Config,
    scopes: &[String],
) -> Result<String, String> {
    use oauth2::{basic::BasicClient, ClientId, ClientSecret, Scope, TokenResponse, TokenUrl};

    let token_url =
        TokenUrl::new(cfg.token_url.clone()).map_err(|e| format!("bad token_url: {e}"))?;
    let client = BasicClient::new(ClientId::new(cfg.client_id.clone()))
        .set_client_secret(ClientSecret::new(cfg.client_secret.clone()))
        .set_token_uri(token_url);

    let http = reqwest::Client::builder()
        .build()
        .map_err(|e| format!("http client: {e}"))?;

    let mut req = client.exchange_client_credentials();
    for s in scopes {
        req = req.add_scope(Scope::new(s.clone()));
    }
    let resp = req
        .request_async(&http)
        .await
        .map_err(|e| format!("client-credentials exchange failed: {e}"))?;
    Ok(resp.access_token().secret().clone())
}

/// The interactive **authorization-code + PKCE** redirect (OAuth 2.1's mandated
/// flow for human SSO). PURE — no network — so it is available with or without the
/// `online` feature. Reuses the `oauth2` client to build the authorize URL and a
/// fresh PKCE (S256) pair. Returns `(authorize_url, csrf_state, pkce_verifier)`;
/// the caller redirects the user to `authorize_url` and later exchanges the
/// returned code together with `pkce_verifier`. Enables browser SSO against Entra
/// ID / Okta / Auth0 / Google.
pub fn authorize_url_pkce(cfg: &OAuth2Config, scopes: &[String]) -> Result<(String, String, String), String> {
    use oauth2::{
        basic::BasicClient, AuthUrl, ClientId, CsrfToken, PkceCodeChallenge, RedirectUrl, Scope,
    };

    let auth_url = AuthUrl::new(cfg.auth_url.clone()).map_err(|e| format!("bad auth_url: {e}"))?;
    let redirect =
        RedirectUrl::new(cfg.redirect_url.clone()).map_err(|e| format!("bad redirect_url: {e}"))?;
    let client = BasicClient::new(ClientId::new(cfg.client_id.clone()))
        .set_auth_uri(auth_url)
        .set_redirect_uri(redirect);

    let (challenge, verifier) = PkceCodeChallenge::new_random_sha256();
    let mut req = client.authorize_url(CsrfToken::new_random);
    for s in scopes {
        req = req.add_scope(Scope::new(s.clone()));
    }
    let (url, csrf) = req.set_pkce_challenge(challenge).url();
    Ok((url.to_string(), csrf.secret().clone(), verifier.secret().clone()))
}

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

    /// Deserialize an RFC 7662 response the way the IdP sends it, so the test
    /// exercises the SAME `oauth2` response type production uses.
    fn resp(json: &str) -> BasicTokenIntrospectionResponse {
        serde_json::from_str(json).expect("valid RFC 7662 introspection JSON")
    }

    /// An ACTIVE token with a subject + scopes verifies to a Principal whose
    /// method is `oauth2` and whose scopes are carried through. RED-when-broken:
    /// this is the whole verify contract.
    #[test]
    fn active_token_with_subject_verifies() {
        let r = resp(r#"{"active":true,"sub":"user-42","scope":"read write","username":"alice"}"#);
        let p = principal_from_introspection(&r).unwrap().expect("authenticated");
        assert_eq!(p.subject, "user-42");
        assert_eq!(p.method, "oauth2");
        assert!(p.has_scope("read"));
        assert!(p.has_scope("write"));
    }

    /// Falls back to `username` when the IdP omits `sub`.
    #[test]
    fn active_token_falls_back_to_username() {
        let r = resp(r#"{"active":true,"username":"svc-bot"}"#);
        let p = principal_from_introspection(&r).unwrap().expect("authenticated");
        assert_eq!(p.subject, "svc-bot");
        assert!(p.scopes.is_empty());
    }

    /// An INACTIVE token is rejected (no Principal) — and, per the dispatch
    /// contract, as `Ok(None)` ("not mine / try next"), NOT a hard Invalid.
    /// RED-when-broken: a bug accepting an inactive token would authenticate a
    /// revoked/expired/unknown token.
    #[test]
    fn inactive_token_is_rejected_as_not_mine() {
        let r = resp(r#"{"active":false}"#);
        let decision = principal_from_introspection(&r).unwrap();
        assert!(decision.is_none(), "inactive token must not authenticate");
    }

    /// An active token with NO subject cannot key RBAC — rejected.
    #[test]
    fn active_but_subjectless_token_is_rejected() {
        let r = resp(r#"{"active":true,"scope":"read"}"#);
        assert!(principal_from_introspection(&r).unwrap().is_none());
    }

    /// A provider with no introspection endpoint is disabled and authenticates
    /// nothing (fail-closed), regardless of the `online` feature.
    #[tokio::test]
    async fn disabled_provider_authenticates_nothing() {
        let p = OAuth2Provider::new(OAuth2Config::default());
        assert!(!p.is_enabled());
        assert!(p.verify_bearer("any.token.here").await.unwrap().is_none());
    }

    /// The offline PKCE authorize-URL helper builds a valid URL carrying the
    /// PKCE challenge + requested scopes and yields a non-empty verifier (proves
    /// the `oauth2` reuse without any network).
    #[test]
    fn authorize_url_pkce_builds_a_valid_redirect() {
        let cfg = OAuth2Config {
            auth_url: "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize".into(),
            client_id: "app-123".into(),
            redirect_url: "https://app.example.com/callback".into(),
            ..Default::default()
        };
        let (url, csrf, verifier) =
            authorize_url_pkce(&cfg, &["openid".into(), "profile".into()]).expect("built url");
        assert!(url.starts_with("https://login.microsoftonline.com/"));
        assert!(url.contains("code_challenge="));
        assert!(url.contains("code_challenge_method=S256"));
        assert!(url.contains("client_id=app-123"));
        assert!(url.contains("scope="));
        assert!(!csrf.is_empty());
        assert!(!verifier.is_empty(), "PKCE verifier must be generated");
    }
}