crates/gunnar-grpc/src/sso/oidc.rs
//! **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('/'))
}
}