#![cfg_attr(not(feature = "sso"), allow(unused_imports, dead_code))]
use std::sync::Arc;
use gunnar_proto::{OidcSettings, SsoProvider};
#[cfg(feature = "sso")]
use nornir_auth_oidc::rp;
use super::{CachedIdp, Egress, Flow, SsoState, FLOW_TTL_MS, MAX_IDP_BODY, SKEW_SECS};
#[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>,
}
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)
}
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
)
})
}
pub fn ca_of(settings: &OidcSettings) -> Vec<Vec<u8>> {
super::idp_ca::idp_ca_blocks(&settings.idp_ca_pem)
}
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
}
#[cfg(feature = "sso")]
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)
}
#[cfg(feature = "sso")]
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))
}
#[cfg(feature = "sso")]
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,
));
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());
}
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,
};
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,
})
}
pub fn discovery_url(issuer: &str) -> String {
format!("{}/.well-known/openid-configuration", issuer.trim_end_matches('/'))
}
#[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`)";
#[allow(clippy::unused_async)]
pub async fn idp(_: &SsoState, _: Arc<dyn Egress>, _: &str, _: &[Vec<u8>], _: i64, _: bool) -> Result<CachedIdp, String> {
Err(NOT_BUILT.to_owned())
}
#[allow(clippy::unused_async)]
pub async fn begin(_: &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(_: &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};
pub mod gunnar_core_paths {
pub fn redirect_uri(origin: &str) -> String {
format!("{}/auth/sso/oidc/callback", origin.trim_end_matches('/'))
}
pub fn sp_entity_id(origin: &str, account: &str) -> String {
format!("{}/auth/sso/saml/{account}", origin.trim_end_matches('/'))
}
pub fn acs_url(origin: &str) -> String {
format!("{}/auth/sso/saml/acs", origin.trim_end_matches('/'))
}
}