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

nordisk/edda

crates/nornir-identity-sso/src/oidc.rs

//! **OIDC** (moved from Gunnar's `sso/oidc.rs`, A34 M-4): authorization code +
//! PKCE S256 + state + nonce, the ID token verified by `nornir_auth_oidc::rp`
//! against the IdP's keys, which this crate fetches itself (discovery, then
//! `jwks_uri`) through the product's [`Egress`] and keeps for an hour per
//! issuer AND per CA it was trusted under.
//!
//! Blocking: every function here may call the network.

use nornir_auth_oidc::rp;

use crate::egress::{checked, Egress};
use crate::state::{Flow, SsoState, FLOW_TTL_MS, MAX_IDP_BODY, SKEW_SECS};

/// An account's OIDC provider, as the product stored it.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct OidcProvider {
    /// The account whose people sign in through it.
    pub account: String,
    /// The console the redirect URI hangs off (`https://gunnar.example`).
    pub console_origin: String,
    /// The issuer, exactly as its discovery document names it.
    pub issuer: String,
    /// The client id the IdP issued for this account's console.
    pub client_id: String,
    /// Beside `openid`, which is always asked (`email profile` when none).
    pub scopes: Vec<String>,
    /// The claim that names the person (`preferred_username`, else `email`, when empty).
    pub username_claim: String,
    /// The claim shown as the person's name (`name` when empty).
    pub display_name_claim: String,
    /// The account's own IdP CA (DER); empty when the IdP is under a public root.
    pub ca: Vec<Vec<u8>>,
}

impl OidcProvider {
    fn checked(&self) -> Result<(), String> {
        if self.issuer.trim().is_empty() || self.client_id.trim().is_empty() {
            return Err(format!(
                "{}'s OIDC provider has no issuer or client id",
                self.account
            ));
        }
        Ok(())
    }
}

/// What a verified ID token says about the person.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Verified {
    pub issuer: String,
    pub subject: String,
    pub user_name: String,
    pub display_name: String,
    pub email: String,
    /// The ID token ASSERTS the e-mail is verified (`email_verified: true`;
    /// the string `"true"` some IdPs send counts too). Absent: false.
    pub email_verified: bool,
    /// The IdP's stable ids for the person: `sub`, and Entra's `oid` when sent.
    pub stable_ids: Vec<String>,
}

/// An IdP's discovery document and keys, as fetched.
#[derive(Clone, Debug)]
pub struct CachedIdp {
    pub discovery: rp::Discovery,
    pub jwks: String,
    pub fetched_ms: i64,
}

/// The cache's key for an IdP read with `ca`: the issuer AND the CA it was
/// trusted under, so one account's CA never vouches for another account's
/// read of the same issuer.
pub fn cache_key(issuer: &str, ca: &[Vec<u8>]) -> String {
    let mut key = issuer.to_owned();
    for der in ca {
        key.push('|');
        key.push_str(&crate::fingerprint(der));
    }
    key
}

/// **The IdP's discovery document and keys**, from the cache or fetched
/// (`force` refetches: a key rotated) — over https trusting the public roots
/// and `ca`, at an address the guard admits.
pub fn idp(
    state: &SsoState,
    egress: &dyn Egress,
    issuer: &str,
    ca: &[Vec<u8>],
    now: i64,
    force: bool,
) -> Result<CachedIdp, String> {
    let key = cache_key(issuer, ca);
    if !force {
        if let Some(hit) = state.cached_idp(&key, now) {
            return Ok(hit);
        }
    }
    let loopback = state.allow_loopback_http();
    let nets = state.private_nets();
    let url = rp::discovery_url(issuer);
    crate::guard::url_allowed_in(&url, loopback, &nets)?;
    let doc = checked(egress.get_trusting(&url, ca), "the discovery document")?;
    let discovery = rp::Discovery::parse(&doc, issuer, loopback).map_err(|e| e.to_string())?;
    crate::guard::url_allowed_in(&discovery.jwks_uri, loopback, &nets)?;
    let jwks = checked(
        egress.get_trusting(&discovery.jwks_uri, ca),
        "the identity provider's keys",
    )?;
    let cached = CachedIdp {
        discovery,
        jwks,
        fetched_ms: now,
    };
    state.cache_idp(&key, cached.clone());
    Ok(cached)
}

/// **The redirect**: a fresh state, nonce and PKCE verifier (32 random bytes
/// each). Returns (URL, state, the flow to park under the state's hash).
pub fn begin(
    state: &SsoState,
    egress: &dyn Egress,
    provider: &OidcProvider,
    login_hint: &str,
    now: i64,
) -> Result<(String, String, Flow), String> {
    provider.checked()?;
    let idp = idp(
        state,
        egress,
        provider.issuer.trim(),
        &provider.ca,
        now,
        false,
    )?;
    let state_token = rp::random_token(&crate::random_bytes(32));
    let nonce = rp::random_token(&crate::random_bytes(32));
    let verifier = rp::random_token(&crate::random_bytes(32));
    let pkce = rp::Pkce::from_verifier(&verifier).map_err(|e| e.to_string())?;
    let redirect_uri = crate::paths::redirect_uri(&provider.console_origin);
    let mut scopes: Vec<&str> = provider.scopes.iter().map(String::as_str).collect();
    if scopes.is_empty() {
        scopes = vec!["email", "profile"];
    }
    let url = rp::authorize_url(&rp::AuthorizeRequest {
        authorization_endpoint: &idp.discovery.authorization_endpoint,
        client_id: provider.client_id.trim(),
        redirect_uri: &redirect_uri,
        scopes: &scopes,
        state: &state_token,
        nonce: &nonce,
        code_challenge: &pkce.challenge,
        login_hint: Some(login_hint).filter(|h| !h.trim().is_empty()),
    })
    .map_err(|e| e.to_string())?;
    let flow = Flow {
        account: provider.account.clone(),
        saml: false,
        console_origin: provider.console_origin.clone(),
        expires_ms: now + FLOW_TTL_MS,
        nonce,
        verifier,
        redirect_uri,
        request_id: String::new(),
        client: String::new(),
    };
    Ok((url, state_token, flow))
}

/// **The callback**: the code exchanged at the token endpoint (PKCE verifier,
/// client_secret_basic), the ID token verified (alg allow-list, key type,
/// iss, aud/azp, exp/iat/nbf ± skew, nonce), the person read out of it.
pub fn finish(
    state: &SsoState,
    egress: &dyn Egress,
    provider: &OidcProvider,
    flow: &Flow,
    code: &str,
    client_secret: &str,
    now: i64,
) -> Result<Verified, String> {
    provider.checked()?;
    let issuer = provider.issuer.trim().to_owned();
    let client_id = provider.client_id.trim().to_owned();
    if code.is_empty() || code.len() > 4096 {
        return Err("the callback carries no usable code".to_owned());
    }
    let ca = &provider.ca;
    let idp = idp(state, egress, &issuer, ca, now, false)?;
    crate::guard::url_allowed_in(
        &idp.discovery.token_endpoint,
        state.allow_loopback_http(),
        &state.private_nets(),
    )?;
    let form = rp::form_body(&rp::token_request_form(
        code,
        &flow.redirect_uri,
        &flow.verifier,
        &client_id,
    ));
    // A confidential client authenticates (client_secret_basic); a public one
    // (PKCE alone) sends no Authorization at all.
    let authorization = if client_secret.is_empty() {
        String::new()
    } else {
        rp::basic_auth_header(&client_id, client_secret)
    };
    let answer =
        egress.post_form_trusting(&idp.discovery.token_endpoint, &authorization, &form, ca);
    let (status, body) = answer.map_err(|e| format!("the token request: {e}"))?;
    if body.len() > MAX_IDP_BODY {
        return Err("the token response is too large".to_owned());
    }
    // An OAuth error body is read for its words even on a 400.
    let token = rp::parse_token_response(&body).map_err(|e| e.to_string())?;
    if !(200..300).contains(&status) {
        return Err(format!("the token endpoint answered HTTP {status}"));
    }
    let now_secs = now / 1000;
    let verify = |jwks: &str| {
        rp::verify_id_token(
            &token.id_token,
            jwks,
            &rp::IdTokenExpect {
                issuer: &issuer,
                client_id: &client_id,
                nonce: &flow.nonce,
                now_unix: now_secs,
                skew_secs: SKEW_SECS,
                algorithms: rp::DEFAULT_ALGORITHMS,
            },
        )
    };
    let claims = match verify(&idp.jwks) {
        Err(rp::RpError::UnknownKey(_)) => {
            let fresh = self::idp(state, egress, &issuer, ca, now, true)?;
            verify(&fresh.jwks).map_err(|e| e.to_string())?
        }
        other => other.map_err(|e| e.to_string())?,
    };
    let username_claim = provider.username_claim.trim();
    let user_name = match username_claim {
        "" => claims
            .string("preferred_username")
            .or_else(|| claims.string("email")),
        claim => claims.string(claim),
    }
    .ok_or_else(|| {
        format!(
            "the ID token carries no {:?} claim to name the person by",
            if username_claim.is_empty() {
                "preferred_username"
            } else {
                username_claim
            }
        )
    })?;
    let display_claim = match provider.display_name_claim.trim() {
        "" => "name",
        claim => claim,
    };
    let email_verified = match claims.claims.get("email_verified") {
        Some(serde_json::Value::Bool(b)) => *b,
        Some(serde_json::Value::String(s)) => s.trim().eq_ignore_ascii_case("true"),
        _ => false,
    };
    let mut stable_ids = vec![claims.sub.clone()];
    if let Some(oid) = claims.string("oid") {
        if oid != claims.sub {
            stable_ids.push(oid);
        }
    }
    Ok(Verified {
        issuer: claims.iss.clone(),
        subject: claims.sub.clone(),
        user_name,
        display_name: claims.string(display_claim).unwrap_or_default(),
        email: claims.string("email").unwrap_or_default(),
        email_verified,
        stable_ids,
    })
}

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

    #[test]
    fn the_cache_key_names_the_ca() {
        assert_eq!(cache_key("https://idp.example", &[]), "https://idp.example");
        let a = cache_key("https://idp.example", &[vec![1, 2, 3]]);
        let b = cache_key("https://idp.example", &[vec![4, 5, 6]]);
        assert_ne!(a, b, "two accounts' CAs share a cache entry");
        assert!(a.starts_with("https://idp.example|SHA256:"));
    }

    #[test]
    fn a_provider_without_an_issuer_is_refused_before_the_network() {
        struct Never;
        impl Egress for Never {
            fn get(&self, url: &str) -> Result<(u16, String), String> {
                panic!("called {url}")
            }
            fn post_form(&self, url: &str, _: &str, _: &str) -> Result<(u16, String), String> {
                panic!("called {url}")
            }
        }
        let state = SsoState::default();
        let p = OidcProvider {
            account: "acme".into(),
            ..OidcProvider::default()
        };
        assert!(begin(&state, &Never, &p, "", 0)
            .unwrap_err()
            .contains("no issuer"));
        let p = OidcProvider {
            account: "acme".into(),
            issuer: "http://10.0.0.1".into(),
            client_id: "c".into(),
            ..OidcProvider::default()
        };
        assert!(begin(&state, &Never, &p, "", 0)
            .unwrap_err()
            .contains("not an https URL"));
    }
}