gunnar-grpc 1.5.0

The tonic service implementations and the event bus behind gunnar's gRPC control plane.
Documentation
//! **OIDC**: authorization code + PKCE S256 + state + nonce, the ID token
//! verified by `nornir_auth_oidc::rp` against the IdP's keys, which gunnar
//! fetches itself (discovery, then `jwks_uri`) through [`super::Egress`].

use std::sync::Arc;

use gunnar_proto::{OidcSettings, SsoProvider};
use nornir_auth_oidc::rp;

use super::{CachedIdp, Egress, Flow, SsoState, FLOW_TTL_MS, MAX_IDP_BODY, SKEW_SECS};

/// 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,
}

async fn blocking<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Result<T, String> {
    tokio::task::spawn_blocking(f)
        .await
        .map_err(|e| format!("the identity provider call did not finish: {e}"))
}

fn checked(answer: Result<(u16, String), String>, what: &str) -> Result<String, String> {
    let (status, body) = answer.map_err(|e| format!("{what}: {e}"))?;
    if body.len() > MAX_IDP_BODY {
        return Err(format!(
            "{what}: the answer is larger than {MAX_IDP_BODY} bytes"
        ));
    }
    if !(200..300).contains(&status) {
        return Err(format!(
            "{what}: the identity provider answered HTTP {status}"
        ));
    }
    Ok(body)
}

/// The settings of an OIDC provider, or why it has none.
pub fn settings(provider: &SsoProvider) -> Result<&OidcSettings, String> {
    provider
        .oidc
        .as_ref()
        .filter(|o| !o.issuer.trim().is_empty() && !o.client_id.trim().is_empty())
        .ok_or_else(|| {
            format!(
                "{}'s OIDC provider has no issuer or client id",
                provider.account
            )
        })
}

/// **The account's own IdP CA** (`OidcSettings::idp_ca_pem`, judged when it
/// was set): the certificates, DER; none when the IdP is under a public root.
pub fn ca_of(settings: &OidcSettings) -> Vec<Vec<u8>> {
    super::idp_ca::idp_ca_blocks(&settings.idp_ca_pem)
}

/// 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.
fn cache_key(issuer: &str, ca: &[Vec<u8>]) -> String {
    let mut key = issuer.to_owned();
    for der in ca {
        key.push('|');
        key.push_str(&super::saml::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`, the ACCOUNT's own IdP CA (none for most), at an address the
/// guard admits (public, or inside the operator's listed internal networks).
pub async fn idp(
    state: &SsoState,
    egress: Arc<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);
    super::url_allowed_in(&url, loopback, &nets)?;
    let fetch = Arc::clone(&egress);
    let trust = ca.to_vec();
    let doc = checked(
        blocking(move || fetch.get_trusting(&url, &trust)).await?,
        "the discovery document",
    )?;
    let discovery = rp::Discovery::parse(&doc, issuer, loopback).map_err(|e| e.to_string())?;
    super::url_allowed_in(&discovery.jwks_uri, loopback, &nets)?;
    let jwks_uri = discovery.jwks_uri.clone();
    let trust = ca.to_vec();
    let jwks = checked(
        blocking(move || egress.get_trusting(&jwks_uri, &trust)).await?,
        "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), the flow parked under the state's hash. Returns (URL, state).
pub async fn begin(
    state: &SsoState,
    egress: Arc<dyn Egress>,
    provider: &SsoProvider,
    login_hint: &str,
    now: i64,
) -> Result<(String, String, Flow), String> {
    let settings = settings(provider)?;
    let idp = idp(state, egress, settings.issuer.trim(), &ca_of(settings), now, false).await?;
    let state_token = rp::random_token(&crate::webauthn::random_bytes(32));
    let nonce = rp::random_token(&crate::webauthn::random_bytes(32));
    let verifier = rp::random_token(&crate::webauthn::random_bytes(32));
    let pkce = rp::Pkce::from_verifier(&verifier).map_err(|e| e.to_string())?;
    let redirect_uri = gunnar_core_paths::redirect_uri(&provider.console_origin);
    let mut scopes: Vec<&str> = settings.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: settings.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(),
    };
    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 async fn finish(
    state: &SsoState,
    egress: Arc<dyn Egress>,
    provider: &SsoProvider,
    flow: &Flow,
    code: &str,
    client_secret: &str,
    now: i64,
) -> Result<Verified, String> {
    let settings = settings(provider)?;
    let issuer = settings.issuer.trim().to_owned();
    let client_id = settings.client_id.trim().to_owned();
    if code.is_empty() || code.len() > 4096 {
        return Err("the callback carries no usable code".to_owned());
    }
    let ca = ca_of(settings);
    let idp = idp(state, Arc::clone(&egress), &issuer, &ca, now, false).await?;
    super::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 token_endpoint = idp.discovery.token_endpoint.clone();
    let post = Arc::clone(&egress);
    let trust = ca.clone();
    let answer = blocking(move || post.post_form_trusting(&token_endpoint, &authorization, &form, &trust)).await?;
    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 = super::oidc::idp(state, egress, &issuer, &ca, now, true).await?;
            verify(&fresh.jwks).map_err(|e| e.to_string())?
        }
        other => other.map_err(|e| e.to_string())?,
    };
    let username_claim = settings.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 settings.display_name_claim.trim() {
        "" => "name",
        claim => claim,
    };
    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(),
    })
}

/// The SSO paths a console exposes, one definition (they are also in
/// `gunnar_core::sso`, which this crate does not link).
pub mod gunnar_core_paths {
    /// `<origin>/auth/sso/oidc/callback`.
    pub fn redirect_uri(origin: &str) -> String {
        format!("{}/auth/sso/oidc/callback", origin.trim_end_matches('/'))
    }
    /// `<origin>/auth/sso/saml/<account>`.
    pub fn sp_entity_id(origin: &str, account: &str) -> String {
        format!("{}/auth/sso/saml/{account}", origin.trim_end_matches('/'))
    }
    /// `<origin>/auth/sso/saml/acs`.
    pub fn acs_url(origin: &str) -> String {
        format!("{}/auth/sso/saml/acs", origin.trim_end_matches('/'))
    }
}