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"));
}
}