gunnar-grpc 3.0.0

The tonic service implementations and the event bus behind gunnar's gRPC control plane.
Documentation
//! **OIDC**, Gunnar's half: the account's `OidcSettings` mapped onto edda's
//! relying party (`nornir_identity_sso::oidc`, A34 M-4) — authorization code +
//! PKCE S256 + state + nonce, the ID token verified against the IdP's keys,
//! which the shared crate fetches (discovery, then `jwks_uri`) through
//! [`super::Egress`] and caches per issuer and CA. Run on the blocking pool.
// Without `sso` the protocol halves are compiled out; their helpers stay.
#![cfg_attr(not(feature = "sso"), allow(unused_imports, dead_code))]

use std::sync::Arc;

use gunnar_proto::{OidcSettings, SsoProvider};

use super::{Egress, Flow, SsoState};

#[cfg(feature = "sso")]
pub use nornir_identity_sso::oidc::{CachedIdp, Verified};

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

#[cfg(feature = "sso")]
fn shared(provider: &SsoProvider) -> Result<nornir_identity_sso::oidc::OidcProvider, String> {
    let s = settings(provider)?;
    Ok(nornir_identity_sso::oidc::OidcProvider {
        account: provider.account.clone(),
        console_origin: provider.console_origin.clone(),
        issuer: s.issuer.trim().to_owned(),
        client_id: s.client_id.trim().to_owned(),
        scopes: s.scopes.clone(),
        username_claim: s.username_claim.clone(),
        display_name_claim: s.display_name_claim.clone(),
        ca: ca_of(s),
    })
}

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

#[cfg(feature = "sso")]
/// **The IdP's discovery document and keys**, from the cache or fetched
/// (`force` refetches) — `nornir_identity_sso::oidc::idp`.
pub async fn idp(
    state: &Arc<SsoState>,
    egress: Arc<dyn Egress>,
    issuer: &str,
    ca: &[Vec<u8>],
    now: i64,
    force: bool,
) -> Result<CachedIdp, String> {
    let (state, issuer, ca) = (Arc::clone(state), issuer.to_owned(), ca.to_vec());
    blocking(move || nornir_identity_sso::oidc::idp(&state, &*egress, &issuer, &ca, now, force))
        .await
}

#[cfg(feature = "sso")]
/// **The redirect**: (URL, state, flow) — `nornir_identity_sso::oidc::begin`.
pub async fn begin(
    state: &Arc<SsoState>,
    egress: Arc<dyn Egress>,
    provider: &SsoProvider,
    login_hint: &str,
    now: i64,
) -> Result<(String, String, Flow), String> {
    let (state, provider, hint) = (Arc::clone(state), shared(provider)?, login_hint.to_owned());
    blocking(move || nornir_identity_sso::oidc::begin(&state, &*egress, &provider, &hint, now))
        .await
}

#[cfg(feature = "sso")]
/// **The callback**: the code exchanged, the ID token verified, the person
/// read out of it — `nornir_identity_sso::oidc::finish`.
pub async fn finish(
    state: &Arc<SsoState>,
    egress: Arc<dyn Egress>,
    provider: &SsoProvider,
    flow: &Flow,
    code: &str,
    client_secret: &str,
    now: i64,
) -> Result<Verified, String> {
    let (state, provider, flow, code, secret) = (
        Arc::clone(state),
        shared(provider)?,
        flow.clone(),
        code.to_owned(),
        client_secret.to_owned(),
    );
    blocking(move || {
        nornir_identity_sso::oidc::finish(&state, &*egress, &provider, &flow, &code, &secret, now)
    })
    .await
}

/// The discovery URL of an issuer (`<issuer>/.well-known/openid-configuration`).
pub fn discovery_url(issuer: &str) -> String {
    nornir_identity_sso::paths::discovery_url(issuer)
}

/// Without the `sso` feature: the three protocol steps refuse, by name.
#[cfg(not(feature = "sso"))]
mod off {
    use super::*;
    const NOT_BUILT: &str =
        "company SSO (OIDC/SAML) is not compiled into this build (gunnar-grpc feature `sso`)";
    /// What a verified ID token says (never built here).
    #[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,
        pub email_verified: bool,
        pub stable_ids: Vec<String>,
    }
    #[allow(clippy::unused_async)]
    pub async fn idp(
        _: &Arc<SsoState>,
        _: Arc<dyn Egress>,
        _: &str,
        _: &[Vec<u8>],
        _: i64,
        _: bool,
    ) -> Result<(), String> {
        Err(NOT_BUILT.to_owned())
    }
    #[allow(clippy::unused_async)]
    pub async fn begin(
        _: &Arc<SsoState>,
        _: Arc<dyn Egress>,
        _: &SsoProvider,
        _: &str,
        _: i64,
    ) -> Result<(String, String, Flow), String> {
        Err(NOT_BUILT.to_owned())
    }
    #[allow(clippy::unused_async)]
    pub async fn finish(
        _: &Arc<SsoState>,
        _: Arc<dyn Egress>,
        _: &SsoProvider,
        _: &Flow,
        _: &str,
        _: &str,
        _: i64,
    ) -> Result<Verified, String> {
        Err(NOT_BUILT.to_owned())
    }
}
#[cfg(not(feature = "sso"))]
pub use off::{begin, finish, idp, Verified};

/// The SSO paths a console exposes: the shared crate's one definition.
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>` (the account percent-encoded, A34-47).
    pub fn sp_entity_id(origin: &str, account: &str) -> String {
        format!(
            "{}/auth/sso/saml/{}",
            origin.trim_end_matches('/'),
            segment(account)
        )
    }
    /// `<origin>/login/sso/<account>`: the sign-in link for the account's people.
    pub fn login_url(origin: &str, account: &str) -> String {
        format!(
            "{}/login/sso/{}",
            origin.trim_end_matches('/'),
            segment(account)
        )
    }
    /// `<origin>/scim/v2`.
    pub fn scim_base_url(origin: &str) -> String {
        format!("{}/scim/v2", origin.trim_end_matches('/'))
    }
    /// One path segment, percent-encoded (RFC 3986 unreserved kept).
    pub fn segment(raw: &str) -> String {
        let mut out = String::with_capacity(raw.len());
        for b in raw.bytes() {
            if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
                out.push(b as char);
            } else {
                out.push_str(&format!("%{b:02X}"));
            }
        }
        out
    }
    /// `<origin>/auth/sso/saml/acs`.
    pub fn acs_url(origin: &str) -> String {
        format!("{}/auth/sso/saml/acs", origin.trim_end_matches('/'))
    }
}