From 35b71bd0e6c80d436908285219756821c98cc881 Mon Sep 17 00:00:00 2001 From: Mikael Rinne <40919111+rorychatt@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:08:55 +0100 Subject: [PATCH 01/23] Add the OIDC id token verifier and the SsoApi port (D91, first part) A ring-based RS256 verifier for Microsoft Entra v2.0 id tokens in obop-auth (no network, no storage): compact JWS shape, algorithm fixed to RS256, keys only from the pinned key set by kid, then issuer, tenant, audience, azp, nonce, times with a 2 minute skew, oid and amr. The discovery document is accepted only for the pinned tenant and with endpoints under it. obop-commands gets the SsoApi port (no network, no session) and Authenticator::sso(). obop-auth implements it: provider configuration with a sealed client secret, administrator-written bindings keyed by hash(issuer|tenant|oid), the sso_only flag with the break-glass guard, the one-time code and the exchange to the same outcome as the password step. SSO-only accounts refuse the password and the passkey on their own. A test stand-in for the provider (feature test-support) and a table of 15 verifier tests that each name a way a token can be wrong. Co-Authored-By: Claude Sonnet 5.5 --- crates/kernel/obop-auth/src/admin.rs | 2 +- crates/kernel/obop-auth/src/lib.rs | 9 + crates/kernel/obop-auth/src/mfa.rs | 39 +- crates/kernel/obop-auth/src/oidc/mod.rs | 844 ++++++++++++++++++ crates/kernel/obop-auth/src/oidc/soft.rs | 135 +++ crates/kernel/obop-auth/src/oidc/tests.rs | 1 + crates/kernel/obop-auth/src/oidc/verify.rs | 394 ++++++++ .../kernel/obop-auth/src/oidc/verify_tests.rs | 564 ++++++++++++ crates/kernel/obop-auth/src/passkey.rs | 10 + crates/kernel/obop-auth/src/webauthn/soft.rs | 2 +- crates/kernel/obop-commands/src/lib.rs | 7 + crates/kernel/obop-commands/src/sso.rs | 172 ++++ 12 files changed, 2169 insertions(+), 10 deletions(-) create mode 100644 crates/kernel/obop-auth/src/oidc/mod.rs create mode 100644 crates/kernel/obop-auth/src/oidc/soft.rs create mode 100644 crates/kernel/obop-auth/src/oidc/tests.rs create mode 100644 crates/kernel/obop-auth/src/oidc/verify.rs create mode 100644 crates/kernel/obop-auth/src/oidc/verify_tests.rs create mode 100644 crates/kernel/obop-commands/src/sso.rs diff --git a/crates/kernel/obop-auth/src/admin.rs b/crates/kernel/obop-auth/src/admin.rs index 60cd3806..67098363 100644 --- a/crates/kernel/obop-auth/src/admin.rs +++ b/crates/kernel/obop-auth/src/admin.rs @@ -45,7 +45,7 @@ pub struct ImportOutcome { /// The name the log shows for the actor and the account it signs in as. An approved proposal of /// an agent runs as `alice/agent` (what the dispatcher calls the principal), whose account is /// `alice`; user names cannot contain `/`, so the two cannot be mistaken for one another. -fn admin_of(p: &Principal) -> Result<(&str, &str), MfaError> { +pub(crate) fn admin_of(p: &Principal) -> Result<(&str, &str), MfaError> { if p.is_person() && p.groups.contains(ADMIN_GROUP) && !p.mfa_limited { Ok((&p.id, p.id.strip_suffix("/agent").unwrap_or(&p.id))) } else { diff --git a/crates/kernel/obop-auth/src/lib.rs b/crates/kernel/obop-auth/src/lib.rs index 457ef64f..85043890 100644 --- a/crates/kernel/obop-auth/src/lib.rs +++ b/crates/kernel/obop-auth/src/lib.rs @@ -17,6 +17,7 @@ mod account; mod admin; mod mfa; mod oauth; +pub mod oidc; mod passkey; pub mod seal; pub mod totp; @@ -36,6 +37,9 @@ pub struct Auth { hasher: Argon2<'static>, config: AuthConfig, fake_now: AtomicU64, + /// The authority Microsoft sign-in trusts: `None` is Microsoft's own (D91); a loopback + /// address stands in for it in tests (`OBOP_OIDC_TEST_AUTHORITY`). + oidc_authority: std::sync::RwLock>, } fn now_system() -> u64 { @@ -98,6 +102,7 @@ impl Auth { hasher: Argon2::default(), config, fake_now: AtomicU64::new(0), + oidc_authority: std::sync::RwLock::new(oidc::authority_from_env()), } } @@ -511,6 +516,10 @@ impl Authenticator for Auth { Some(self) } + fn sso(&self) -> Option<&dyn obop_commands::SsoApi> { + Some(self) + } + fn accounts(&self) -> Option<&dyn obop_commands::AccountsApi> { Some(self) } diff --git a/crates/kernel/obop-auth/src/mfa.rs b/crates/kernel/obop-auth/src/mfa.rs index e8ae1e45..b8c3de41 100644 --- a/crates/kernel/obop-auth/src/mfa.rs +++ b/crates/kernel/obop-auth/src/mfa.rs @@ -393,23 +393,46 @@ impl Auth { self.event(&name, "login_refused", &name, "the account is disabled"); return Err("invalid username or password".into()); } + // An account that signs in with Microsoft only (D91) has no password step. Same answer as + // a wrong password; the log says what happened. + if self.is_sso_only(&name) { + self.event( + &name, + "login_refused", + &name, + "the account signs in with Microsoft only; the password was not accepted", + ); + return Err("invalid username or password".into()); + } self.purge_tokens(); - if self.has_second_factor(&name) { + self.after_first_factor(&name, &epoch) + } + + /// What follows a first factor that is not enough alone for an account: the demand for a + /// second factor when the account has one, else a session, limited to enrolment when the + /// policy demands a second factor the account lacks. The password step and a Microsoft + /// sign-in whose `amr` does not say `mfa` (D91) both end here. + pub(crate) fn after_first_factor( + &self, + name: &str, + epoch: &Epoch, + ) -> Result { + if self.has_second_factor(name) { let token = format!("obopmfa_{}", hex(&random_bytes::<32>())); let mut f = Fields::new(); - f.insert("user".into(), J::from(name.as_str())); + f.insert("user".into(), J::from(name)); f.insert("expires_at".into(), J::from(self.now() + MFA_TOKEN_SECS)); f.insert("attempts".into(), J::from(0u64)); - // What the password was checked against: a change of the credentials before the + // What the first factor was checked against: a change of the credentials before the // second step ends this sign-in (`refuse_if_disabled`). - f.insert("epoch".into(), epoch.map_or(J::Null, J::from)); + f.insert("epoch".into(), epoch.clone().map_or(J::Null, J::from)); self.backend .insert(MFA_TOKEN_TABLE, &token_hash(&token), &f)?; return Ok(LoginOutcome::MfaRequired { mfa_token: token }); } let groups = self .backend - .get("auth_user", &name) + .get("auth_user", name) .ok() .flatten() .map(|u| str_list(u.get("groups").unwrap_or(&J::Null))) @@ -417,13 +440,13 @@ impl Auth { let limited = self.enrolment_required_for(&groups); if limited { self.event( - &name, + name, "limited_session", - &name, + name, "signed in without two-factor; only enrolment is allowed", ); } - self.session_outcome(&name, limited, &epoch) + self.session_outcome(name, limited, epoch) .map_err(SessionError::message) } diff --git a/crates/kernel/obop-auth/src/oidc/mod.rs b/crates/kernel/obop-auth/src/oidc/mod.rs new file mode 100644 index 00000000..7f8e9149 --- /dev/null +++ b/crates/kernel/obop-auth/src/oidc/mod.rs @@ -0,0 +1,844 @@ +//! Sign-in with Microsoft Entra (D91, Proposed): the provider's configuration, the checks of the +//! id token, the binding of a Microsoft identity to an account, the one-time code and the +//! session. The browser round trip and the two calls to the provider are in `obop-http`; this +//! crate never touches the network and is the only one that mints a session. +//! +//! Records (outside the module registry, like the other `auth_*` records): +//! +//! - `auth_sso_provider`, one record `provider`: kind, tenant, client id, the client secret sealed +//! under `OBOP_SECRET_KEY` (purpose `sso-client-secret`), enabled, who and when. Re-sealed by +//! `obop secrets rotate` (the inventory in `obop-keys`). +//! - `auth_sso_link`, one record per binding, keyed by the SHA-256 of `issuer|tenant|oid`, so the +//! store's own unique key is the rule "one Microsoft identity, one account": the account, the +//! object id and the tenant, who bound it and when. Never by e-mail, name or user principal name. +//! - `sso_only` on `auth_user`: the account has no password or passkey sign-in of its own. +//! - Short-lived: the one-time code, in the shared key-value store as `auth.sso:`. +//! +//! Nothing of the provider is kept but the binding: the id token is read and dropped. + +pub mod verify; + +#[cfg(any(test, feature = "test-support"))] +pub mod soft; + +#[cfg(test)] +mod tests; +#[cfg(test)] +mod verify_tests; + +use crate::mfa::SessionError; +use crate::webauthn::sha256; +use crate::{Auth, Change, Epoch, bump_epoch, epoch_of, hex, random_bytes, str_list}; +use obop_commands::{ + ADMIN_GROUP, LoginOutcome, MfaError, Principal, SsoApi, SsoEndpoints, SsoError, SsoIdentity, + SsoLeg, SsoProvider, +}; +use obop_storage::Fields; +use serde_json::{Value as J, json}; +use verify::{Expect, Jwks, VerifyError}; + +pub const PROVIDER_TABLE: &str = "auth_sso_provider"; +pub const LINK_TABLE: &str = "auth_sso_link"; +const PROVIDER_ID: &str = "provider"; +/// The purpose the client secret is sealed for. +pub const SECRET_PURPOSE: &str = "sso-client-secret"; +/// How long a one-time code lives, in seconds. +pub const CODE_SECS: u64 = 60; +/// Administrators that stay able to sign in locally (password and a second factor) when an account +/// is made SSO-only. +pub const BREAK_GLASS_MIN: usize = 2; +const MAX_SECRET_CHARS: usize = 512; + +/// The `OBOP_OIDC_TEST_AUTHORITY` of a development instance: a literal loopback address over +/// http that stands in for `https://login.microsoftonline.com`. For tests only, like +/// `OBOP_CONNECTORS_ALLOW_LOOPBACK` (and, like it, of no use without it: the outbound client +/// refuses a loopback address unless that is set). Anything else is ignored with a line on stderr. +pub(crate) fn authority_from_env() -> Option { + let v = std::env::var("OBOP_OIDC_TEST_AUTHORITY").ok()?; + let v = v.trim().trim_end_matches('/').to_string(); + if v.is_empty() { + return None; + } + if v == verify::AUTHORITY { + return None; + } + if verify::authority_ok(&v) { + eprintln!( + "WARNING: OBOP_OIDC_TEST_AUTHORITY is set: Microsoft sign-in trusts {v} as the authority. Tests only." + ); + Some(v) + } else { + eprintln!( + "OBOP_OIDC_TEST_AUTHORITY is ignored: it must be http://127.0.0.1: or http://[::1]:" + ); + None + } +} + +fn unavailable(e: impl ToString) -> SsoError { + SsoError::Unavailable(e.to_string()) +} + +fn invalid(m: impl Into) -> SsoError { + SsoError::Invalid(m.into()) +} + +/// The key of a binding: the SHA-256 of `issuer|tenant|oid`, so the store's unique key is the rule. +pub fn link_key(issuer: &str, tenant: &str, oid: &str) -> String { + hex(&sha256( + format!("obop-sso-link\n{issuer}\n{tenant}\n{oid}").as_bytes(), + )) +} + +fn code_key(code: &str) -> String { + format!("auth.sso:{}", hex(&sha256(code.as_bytes()))) +} + +fn clean_client_id(raw: &str) -> Result { + let c = raw.trim().to_ascii_lowercase(); + if verify::is_guid(&c) { + Ok(c) + } else { + Err(invalid( + "the client id is the application (client) id of the app registration, a GUID", + )) + } +} + +impl Auth { + // ---- authority --------------------------------------------------------------------------- + + /// The authority the pinned tenant lives under: Microsoft's, or in a test the stand-in. + pub fn oidc_authority(&self) -> String { + self.oidc_authority + .read() + .ok() + .and_then(|a| a.clone()) + .unwrap_or_else(|| verify::AUTHORITY.to_string()) + } + + /// For tests: use `authority` (a loopback address) instead of Microsoft's. + pub fn set_oidc_authority_for_test(&self, authority: &str) { + assert!(verify::authority_ok(authority), "not an authority"); + *self.oidc_authority.write().unwrap() = Some(authority.to_string()); + } + + // ---- state --------------------------------------------------------------------------------- + + fn provider_record(&self) -> Result, SsoError> { + Ok(self + .backend + .get(PROVIDER_TABLE, PROVIDER_ID) + .map_err(unavailable)? + .map(J::Object)) + } + + fn view(r: &J) -> SsoProvider { + SsoProvider { + kind: r["kind"].as_str().unwrap_or("entra").to_string(), + tenant: r["tenant"].as_str().unwrap_or("").to_string(), + client_id: r["client_id"].as_str().unwrap_or("").to_string(), + enabled: r["enabled"].as_bool() == Some(true), + secret_set: r["client_secret"].is_string(), + updated_at: r["updated_at"].as_str().map(String::from), + updated_by: r["updated_by"].as_str().map(String::from), + } + } + + /// Whether the account signs in with Microsoft only. + pub(crate) fn is_sso_only(&self, name: &str) -> bool { + self.backend + .get("auth_user", name) + .ok() + .flatten() + .is_some_and(|u| u.get("sso_only").and_then(J::as_bool) == Some(true)) + } + + /// The binding of an account as `(link id, record)`, with a lookup that reports a refusal. + fn link_of(&self, user: &str) -> Result, SsoError> { + Ok(self + .backend + .find(LINK_TABLE, "user", &J::from(user)) + .map_err(unavailable)? + .into_iter() + .next() + .map(|(k, f)| (k, J::Object(f)))) + } + + /// A working second factor: a passkey, or an authenticator app when the key ring that opens + /// its secret is there. A lookup the store refused is "none" (the guard fails closed). + fn working_second_factor(&self, name: &str, u: &J) -> bool { + (u["mfa_enabled"].as_bool() == Some(true) && !self.config.keys.is_empty()) + || self.passkeys_checked(name).is_ok_and(|p| !p.is_empty()) + } + + /// The enabled administrators other than `except` that are not SSO-only and hold a working + /// second factor: the ones that can still sign in when Microsoft cannot (break-glass). + fn break_glass(&self, except: Option<&str>) -> Result, SsoError> { + let mut out = Vec::new(); + for (name, u) in self.backend.list("auth_user").map_err(unavailable)? { + let u = J::Object(u); + if Some(name.as_str()) != except + && str_list(&u["groups"]).iter().any(|g| g == ADMIN_GROUP) + && u["disabled"].as_bool() != Some(true) + && u["sso_only"].as_bool() != Some(true) + && self.working_second_factor(&name, &u) + { + out.push(name); + } + } + out.sort(); + Ok(out) + } + + fn sso_event(&self, user: &str, kind: &str, actor: &str, detail: &str) { + self.event(user, kind, actor, detail); + } + + /// The administrator a call runs as (a person whose session is whole), and the account that + /// administrator signs in as (an approved proposal of an agent runs as `alice/agent`). + fn sso_admin<'a>(&self, p: &'a Principal) -> Result<(&'a str, &'a str), SsoError> { + crate::admin::admin_of(p).map_err(|_| SsoError::NotFound) + } + + // ---- the one-time code ----------------------------------------------------------------- + + fn code_take(&self, code: &str) -> Option { + let key = code_key(code); + let raw = self.backend.kv_get(&key).ok().flatten(); + let used = self.backend.kv_delete(&key).unwrap_or(false); + let rec: J = serde_json::from_str(&raw?).ok()?; + (used && rec["expires_at"].as_u64().is_some_and(|e| e > self.now())).then_some(rec) + } + + // ---- the operator's own tools (database access is the authority, as for `admin create`) ---- + + /// Bind from the command line. Same rules as the administrator's. + pub fn operator_sso_bind(&self, user: &str, oid: &str) -> Result { + self.bind_as("operator", user, oid).map_err(describe_sso) + } + + pub fn operator_sso_unbind(&self, user: &str) -> Result { + self.unbind_as("operator", user).map_err(describe_sso) + } + + pub fn operator_sso_only(&self, user: &str, on: bool) -> Result { + self.sso_only_as("operator", user, on).map_err(describe_sso) + } + + pub fn operator_sso_configure( + &self, + tenant: &str, + client_id: &str, + client_secret: Option<&str>, + enabled: bool, + ) -> Result { + self.configure_as("operator", tenant, client_id, client_secret, enabled) + .map_err(describe_sso) + } + + pub fn operator_sso_clear(&self) -> Result<(), String> { + self.clear_as("operator").map_err(describe_sso) + } + + /// The accounts with their binding, and the break-glass administrators, for the command line. + pub fn operator_sso_status(&self) -> Result { + self.bindings_impl().map_err(describe_sso) + } + + // ---- configuration ------------------------------------------------------------------------ + + fn configure_as( + &self, + actor: &str, + tenant: &str, + client_id: &str, + client_secret: Option<&str>, + enabled: bool, + ) -> Result { + let tenant = verify::tenant_id(tenant).map_err(SsoError::Invalid)?; + let client_id = clean_client_id(client_id)?; + let _g = self + .mfa_guard() + .map_err(|e| unavailable(format!("{e:?}")))?; + let before = self.provider_record()?; + let sealed = match client_secret { + Some(s) => { + let s = s.trim(); + if s.is_empty() + || s.chars().count() > MAX_SECRET_CHARS + || s.chars().any(char::is_control) + { + return Err(invalid( + "the client secret is empty, too long or has control characters", + )); + } + if self.config.keys.is_empty() { + return Err(unavailable( + "the client secret is sealed with the server secret OBOP_SECRET_KEY, which is not set", + )); + } + Some( + self.config + .keys + .seal_str(SECRET_PURPOSE, s) + .map_err(|e| unavailable(String::from(e)))?, + ) + } + None => before + .as_ref() + .and_then(|b| b["client_secret"].as_str()) + .map(String::from), + }; + if sealed.is_none() { + return Err(invalid("a client secret is needed")); + } + if let Some(b) = &before + && b["tenant"].as_str() != Some(tenant.as_str()) + && b["tenant"].is_string() + && !self + .backend + .list(LINK_TABLE) + .map_err(unavailable)? + .is_empty() + { + return Err(invalid( + "accounts are bound to the current tenant: unbind them before the tenant changes", + )); + } + let mut f = Fields::new(); + f.insert("kind".into(), J::from("entra")); + f.insert("tenant".into(), J::from(tenant.as_str())); + f.insert("client_id".into(), J::from(client_id.as_str())); + f.insert("client_secret".into(), sealed.map_or(J::Null, J::from)); + f.insert("enabled".into(), J::Bool(enabled)); + f.insert( + "updated_at".into(), + J::from(obop_commands::audit::rfc3339(self.now())), + ); + f.insert("updated_by".into(), J::from(actor)); + if before.is_some() { + self.backend + .update(PROVIDER_TABLE, PROVIDER_ID, &f) + .map_err(unavailable)?; + } else { + self.backend + .insert(PROVIDER_TABLE, PROVIDER_ID, &f) + .map_err(unavailable)?; + } + self.sso_event( + "-", + "oidc_provider_configured", + actor, + &format!( + "tenant {tenant}, client id {client_id}, {}; client secret {}", + if enabled { "enabled" } else { "not enabled" }, + if client_secret.is_some() { + "replaced" + } else { + "kept" + } + ), + ); + Ok(Self::view(&J::Object(f))) + } + + fn clear_as(&self, actor: &str) -> Result<(), SsoError> { + let _g = self + .mfa_guard() + .map_err(|e| unavailable(format!("{e:?}")))?; + if self.provider_record()?.is_none() { + return Err(invalid("no provider is configured")); + } + let only = self + .backend + .list("auth_user") + .map_err(unavailable)? + .iter() + .filter(|(_, u)| u.get("sso_only").and_then(J::as_bool) == Some(true)) + .count(); + self.backend + .delete(PROVIDER_TABLE, PROVIDER_ID) + .map_err(unavailable)?; + self.sso_event( + "-", + "oidc_provider_cleared", + actor, + &format!("the provider's configuration was removed; {only} SSO-only account(s) can sign in only through the break-glass administrators until it is configured again"), + ); + Ok(()) + } + + // ---- binding --------------------------------------------------------------------------------- + + fn bind_as(&self, actor: &str, user: &str, oid: &str) -> Result { + let user = user.trim().to_lowercase(); + let oid = oid.trim().to_ascii_lowercase(); + if !verify::is_guid(&oid) { + return Err(invalid( + "the object id is the user's object id in the directory, a GUID", + )); + } + let provider = self + .provider_record()? + .ok_or_else(|| invalid("configure the provider before binding an account"))?; + let tenant = provider["tenant"].as_str().unwrap_or("").to_string(); + let issuer = verify::issuer(&self.oidc_authority(), &tenant); + let _g = self + .mfa_guard() + .map_err(|e| unavailable(format!("{e:?}")))?; + let u = self.user_record(&user).map_err(|e| match e { + MfaError::NotFound => invalid(format!("no user {user}")), + other => unavailable(format!("{other:?}")), + })?; + if u["disabled"].as_bool() == Some(true) { + return Err(invalid("the account is disabled: enable it first")); + } + if self.link_of(&user)?.is_some() { + return Err(invalid(format!( + "{user} is bound to a Microsoft identity already: unbind it first" + ))); + } + let id = link_key(&issuer, &tenant, &oid); + let mut f = Fields::new(); + f.insert("user".into(), J::from(user.as_str())); + f.insert("issuer".into(), J::from(issuer.as_str())); + f.insert("tid".into(), J::from(tenant.as_str())); + f.insert("oid".into(), J::from(oid.as_str())); + f.insert( + "bound_at".into(), + J::from(obop_commands::audit::rfc3339(self.now())), + ); + f.insert("bound_by".into(), J::from(actor)); + self.backend.insert(LINK_TABLE, &id, &f).map_err(|e| { + if e.contains("duplicate") || e.contains("UNIQUE") || e.contains("unique") { + invalid("that object id is bound to another account already") + } else { + unavailable(e) + } + })?; + self.sso_event( + &user, + "oidc_bind", + actor, + &format!("bound to object id {oid} in tenant {tenant}"), + ); + Ok(json!({"user": user, "oid": oid, "tenant": tenant})) + } + + fn unbind_as(&self, actor: &str, user: &str) -> Result { + let user = user.trim().to_lowercase(); + let _g = self + .mfa_guard() + .map_err(|e| unavailable(format!("{e:?}")))?; + let u = self.user_record(&user).map_err(|e| match e { + MfaError::NotFound => invalid(format!("no user {user}")), + other => unavailable(format!("{other:?}")), + })?; + if u["sso_only"].as_bool() == Some(true) { + return Err(invalid( + "the account signs in with Microsoft only: turn that off before unbinding it", + )); + } + let Some((id, link)) = self.link_of(&user)? else { + return Err(invalid(format!( + "{user} is not bound to a Microsoft identity" + ))); + }; + self.backend.delete(LINK_TABLE, &id).map_err(unavailable)?; + // A sign-in that began with the binding must not finish after it: the epoch changes. + let mut p = Fields::new(); + bump_epoch(&mut p); + let _ = self.put(&user, &p); + self.sso_event( + &user, + "oidc_unbind", + actor, + &format!( + "unbound from object id {}", + link["oid"].as_str().unwrap_or("?") + ), + ); + Ok(json!({"user": user, "oid": link["oid"]})) + } + + fn sso_only_as(&self, actor: &str, user: &str, on: bool) -> Result { + let user = user.trim().to_lowercase(); + let _g = self + .mfa_guard() + .map_err(|e| unavailable(format!("{e:?}")))?; + let u = self.user_record(&user).map_err(|e| match e { + MfaError::NotFound => invalid(format!("no user {user}")), + other => unavailable(format!("{other:?}")), + })?; + let was = u["sso_only"].as_bool() == Some(true); + let ch = Change::new( + &user, + "oidc_sso_only", + actor, + "signs in with Microsoft only", + ); + if was == on { + // The repeat of a request that changed the flag and could not end the sessions + // finishes that (D74). + if on + && let Some(n) = self + .finish_owed(&user, "sso_only", &ch) + .map_err(|e| unavailable(format!("{e:?}")))? + { + self.sso_event( + &user, + "oidc_sso_only", + actor, + &format!("signs in with Microsoft only; {n} session(s) signed out (repeated)"), + ); + } + return Ok(json!({"user": user, "sso_only": on, "changed": false})); + } + if !on { + let mut p = Fields::new(); + p.insert("sso_only".into(), J::Bool(false)); + bump_epoch(&mut p); + self.put(&user, &p) + .map_err(|e| unavailable(format!("{e:?}")))?; + self.sso_event( + &user, + "oidc_sso_only", + actor, + "turned off: the account may use its password again", + ); + return Ok(json!({"user": user, "sso_only": false, "changed": true})); + } + // Turning it on. + let provider = self.provider_record()?; + if !provider.as_ref().is_some_and(|p| Self::view(p).enabled) { + return Err(invalid( + "the Microsoft sign-in is not enabled: an account cannot be made SSO-only without it", + )); + } + if u["disabled"].as_bool() == Some(true) { + return Err(invalid("the account is disabled: enable it first")); + } + if self.link_of(&user)?.is_none() { + return Err(invalid(format!( + "{user} is not bound to a Microsoft identity: bind it first, or the person could not sign in" + ))); + } + let rest = self.break_glass(Some(&user))?; + if rest.len() < BREAK_GLASS_MIN { + return Err(invalid(format!( + "at least {BREAK_GLASS_MIN} other enabled administrators must keep signing in locally (password and an authenticator app or a passkey, not SSO-only) before an account is made SSO-only; there are {} now{}", + rest.len(), + if rest.is_empty() { + String::new() + } else { + format!(": {}", rest.join(", ")) + } + ))); + } + let previous = self + .owe_revocation(&user, "sso_only", None) + .map_err(|e| unavailable(format!("{e:?}")))?; + let before = obop_storage::fault::change_count(); + let mut p = Fields::new(); + p.insert("sso_only".into(), J::Bool(true)); + // A temporary password the person can no longer use would limit every session to a + // password change. + p.insert("must_change_password".into(), J::Bool(false)); + bump_epoch(&mut p); + if let Err(e) = self.put(&user, &p) { + self.retract_owed(&user, "sso_only", &e, before, previous); + return Err(unavailable(format!("{e:?}"))); + } + let n = self + .revoke_user_sessions(&user, None, &ch) + .map_err(|e| unavailable(format!("{e:?}")))?; + self.clear_owed(&user, "sso_only"); + self.sso_event( + &user, + "oidc_sso_only", + actor, + &format!( + "signs in with Microsoft only; {n} session(s) signed out; {} break-glass administrator(s) remain", + rest.len() + ), + ); + Ok(json!({"user": user, "sso_only": true, "changed": true, "break_glass": rest})) + } + + fn bindings_impl(&self) -> Result { + let links = self.backend.list(LINK_TABLE).map_err(unavailable)?; + let mut rows: Vec = Vec::new(); + for (name, u) in self.backend.list("auth_user").map_err(unavailable)? { + let link = links + .iter() + .find(|(_, l)| l.get("user").and_then(J::as_str) == Some(name.as_str())); + rows.push(json!({ + "name": name, + "bound": link.is_some(), + "oid": link.and_then(|(_, l)| l.get("oid")).cloned().unwrap_or(J::Null), + "sso_only": u.get("sso_only").and_then(J::as_bool) == Some(true), + })); + } + rows.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); + let provider = self.provider_record()?.map(|r| Self::view(&r)); + let bg = self.break_glass(None)?; + Ok(json!({ + "provider": provider.map(|p| json!({ + "kind": p.kind, "tenant": p.tenant, "client_id": p.client_id, + "enabled": p.enabled, "secret_set": p.secret_set, + })), + "accounts": rows, + "break_glass": bg, + "break_glass_enough": bg.len() >= BREAK_GLASS_MIN, + })) + } +} + +fn describe_sso(e: SsoError) -> String { + match e { + SsoError::Refused(m) | SsoError::Invalid(m) | SsoError::Unavailable(m) => m, + SsoError::UnknownKey => "the signing key is not known".into(), + SsoError::NotFound => "not allowed".into(), + } +} + +impl SsoApi for Auth { + fn offered(&self) -> bool { + self.provider_record() + .ok() + .flatten() + .is_some_and(|r| Self::view(&r).enabled && r["client_secret"].is_string()) + } + + fn provider(&self) -> Result, SsoError> { + Ok(self.provider_record()?.map(|r| Self::view(&r))) + } + + fn leg(&self) -> Result, SsoError> { + let Some(r) = self.provider_record()? else { + return Ok(None); + }; + let v = Self::view(&r); + if !v.enabled { + return Ok(None); + } + let sealed = r["client_secret"] + .as_str() + .ok_or_else(|| unavailable("no client secret is stored"))?; + if self.config.keys.is_empty() { + return Err(unavailable("the server secret OBOP_SECRET_KEY is not set")); + } + let secret = self + .config + .keys + .open_str(SECRET_PURPOSE, sealed) + .map_err(|_| { + unavailable("the client secret could not be opened with the server key ring") + })?; + // A tenant stored by a hand that skipped the checks is not used. + let tenant = verify::tenant_id(&v.tenant) + .map_err(|_| unavailable("the stored tenant is not valid"))?; + Ok(Some(SsoLeg { + authority: self.oidc_authority(), + tenant, + client_id: v.client_id, + client_secret: secret, + })) + } + + fn endpoints(&self, discovery: &[u8]) -> Result { + let p = self + .provider_record()? + .ok_or_else(|| unavailable("no provider is configured"))?; + let tenant = verify::tenant_id(p["tenant"].as_str().unwrap_or("")) + .map_err(|_| unavailable("the stored tenant is not valid"))?; + let e = verify::parse_discovery(discovery, &self.oidc_authority(), &tenant) + .map_err(SsoError::Refused)?; + Ok(SsoEndpoints { + authorization: e.authorization, + token: e.token, + jwks: e.jwks, + }) + } + + fn verify(&self, id_token: &str, jwks: &[u8], nonce: &str) -> Result { + let p = self + .provider_record()? + .ok_or_else(|| unavailable("no provider is configured"))?; + let tenant = verify::tenant_id(p["tenant"].as_str().unwrap_or("")) + .map_err(|_| unavailable("the stored tenant is not valid"))?; + let client_id = p["client_id"].as_str().unwrap_or(""); + let keys = Jwks::parse(jwks).map_err(unavailable)?; + let authority = self.oidc_authority(); + let exp = Expect { + authority: &authority, + tenant: &tenant, + client_id, + nonce, + now: self.now(), + }; + verify::verify_id_token(id_token, &keys, &exp).map_err(|e| match e { + VerifyError::UnknownKey => SsoError::UnknownKey, + VerifyError::Refused(why) => SsoError::Refused(why.to_string()), + }) + } + + fn begin(&self, identity: &SsoIdentity, ip: &str) -> Result { + let _ = ip; + let refuse = |user: &str, why: &str| -> SsoError { + self.event(user, "oidc_refused", "-", why); + SsoError::Refused(why.to_string()) + }; + // The tenant of the identity is the pinned one (verified), but a provider changed since + // the flow began is not the one it was checked against. + let Some(p) = self.provider_record()? else { + return Err(refuse("-", "no provider is configured")); + }; + let v = Self::view(&p); + if !v.enabled || v.tenant != identity.tenant { + return Err(refuse( + "-", + "the provider was changed or switched off during the sign-in", + )); + } + let id = link_key(&identity.issuer, &identity.tenant, &identity.oid); + let link = self.backend.get(LINK_TABLE, &id).map_err(unavailable)?; + let Some(link) = link.map(J::Object) else { + return Err(refuse( + "-", + &format!( + "no account is bound to object id {} in tenant {}", + identity.oid, identity.tenant + ), + )); + }; + let user = link["user"].as_str().unwrap_or("").to_string(); + let u = match self.user_record(&user) { + Ok(u) => u, + Err(MfaError::NotFound) => { + return Err(refuse(&user, "the account of the binding does not exist")); + } + Err(e) => return Err(unavailable(format!("{e:?}"))), + }; + if u["disabled"].as_bool() == Some(true) { + return Err(refuse(&user, "the account is disabled")); + } + let code = format!("obopsso_{}", hex(&random_bytes::<32>())); + let rec = json!({ + "user": user, + "epoch": epoch_of(&u), + "mfa": identity.mfa(), + "expires_at": self.now() + CODE_SECS, + }); + self.backend + .kv_put(&code_key(&code), &rec.to_string(), Some(CODE_SECS * 1000)) + .map_err(unavailable)?; + Ok(code) + } + + fn exchange(&self, code: &str) -> Result { + let rec = self.code_take(code).ok_or(MfaError::Expired)?; + let name = rec["user"].as_str().ok_or(MfaError::Expired)?.to_string(); + let pending: Epoch = rec["epoch"].as_str().map(String::from); + let mfa = rec["mfa"].as_bool() == Some(true); + // The account as it is now: disabled, removed or changed since the callback is no sign-in. + let u = self.user_record(&name).map_err(|e| match e { + MfaError::NotFound => MfaError::Expired, + other => other, + })?; + if u["disabled"].as_bool() == Some(true) { + self.event( + &name, + "oidc_refused", + "-", + "the account was disabled before the sign-in finished", + ); + return Err(MfaError::Expired); + } + if epoch_of(&u) != pending { + self.event( + &name, + "oidc_refused", + "-", + "the credentials of the account changed before the sign-in finished", + ); + return Err(MfaError::Expired); + } + let sso_only = u["sso_only"].as_bool() == Some(true); + self.purge_tokens(); + let out = if sso_only && mfa { + // Microsoft says a second factor was used, and for an account that has no other way in + // that is its second factor: whole session, no obop code asked. + self.session_outcome(&name, false, &pending) + .map_err(SessionError::mfa)? + } else { + self.after_first_factor(&name, &pending) + .map_err(MfaError::Unavailable)? + }; + self.event( + &name, + "oidc_login", + &name, + &format!( + "signed in with Microsoft; the provider {} a second factor{}", + if mfa { "reports" } else { "does not report" }, + match &out { + LoginOutcome::Session { + mfa_enrolment_required: true, + .. + } => "; the session is limited to enrolment", + LoginOutcome::MfaRequired { .. } => "; an obop second factor is asked", + _ => "", + } + ), + ); + Ok(out) + } + + fn audit(&self, user: &str, kind: &str, detail: &str) { + // Only the kinds of this sign-in; a caller cannot write any other entry. + let kind = match kind { + "oidc_refused" | "oidc_start" | "oidc_cancelled" | "oidc_error" => kind, + _ => "oidc_error", + }; + self.event(user, kind, "-", detail); + } + + fn configure( + &self, + admin: &Principal, + tenant: &str, + client_id: &str, + client_secret: Option<&str>, + enabled: bool, + ) -> Result { + let (actor, _) = self.sso_admin(admin)?; + self.configure_as(actor, tenant, client_id, client_secret, enabled) + } + + fn clear(&self, admin: &Principal) -> Result<(), SsoError> { + let (actor, _) = self.sso_admin(admin)?; + self.clear_as(actor) + } + + fn bind(&self, admin: &Principal, user: &str, oid: &str) -> Result { + let (actor, _) = self.sso_admin(admin)?; + self.bind_as(actor, user, oid) + } + + fn unbind(&self, admin: &Principal, user: &str) -> Result { + let (actor, _) = self.sso_admin(admin)?; + self.unbind_as(actor, user) + } + + fn set_sso_only(&self, admin: &Principal, user: &str, on: bool) -> Result { + let (actor, _) = self.sso_admin(admin)?; + self.sso_only_as(actor, user, on) + } + + fn bindings(&self, admin: &Principal) -> Result { + self.sso_admin(admin)?; + self.bindings_impl() + } +} diff --git a/crates/kernel/obop-auth/src/oidc/soft.rs b/crates/kernel/obop-auth/src/oidc/soft.rs new file mode 100644 index 00000000..0c65a384 --- /dev/null +++ b/crates/kernel/obop-auth/src/oidc/soft.rs @@ -0,0 +1,135 @@ +//! A stand-in for Microsoft Entra's token side, for tests (feature `test-support`): it publishes a +//! discovery document and a key set and signs id tokens, with every claim open to change, so the +//! checks of `verify` and the whole sign-in can be tried against what a provider would send and +//! against what it must not. It is not Entra and proves nothing about Entra: the claims it makes +//! are those D91 reads, in the shape Microsoft's documentation describes (general knowledge). +//! +//! `ring` cannot make RSA keys, so the key is the test-only fixture the software authenticator of +//! the passkey tests already uses (`testdata/rsa2048-test-key.der`). It protects nothing. + +use crate::webauthn::soft::split_rsa_der; +use crate::webauthn::{b64url, b64url_decode}; +use ring::rand::SystemRandom; +use ring::signature::{RSA_PKCS1_SHA256, RsaKeyPair}; +use serde_json::{Value as J, json}; + +const RSA_FIXTURE: &[u8] = include_bytes!("../../testdata/rsa2048-test-key.der"); + +/// The kid the stand-in publishes. +pub const KID: &str = "standin-key-1"; + +pub struct StandIn { + key: RsaKeyPair, + /// The `kid` the key set publishes and the tokens name. + pub kid: String, +} + +impl Default for StandIn { + fn default() -> Self { + Self::new() + } +} + +impl StandIn { + pub fn new() -> Self { + Self { + key: RsaKeyPair::from_der(RSA_FIXTURE).expect("the fixture key"), + kid: KID.into(), + } + } + + fn jwk(&self, kid: &str) -> J { + let (n, e) = split_rsa_der(self.key.public().as_ref()); + json!({"kty": "RSA", "use": "sig", "alg": "RS256", "kid": kid, "n": b64url(n), "e": b64url(e)}) + } + + /// The key set the stand-in publishes. + pub fn jwks(&self) -> String { + json!({"keys": [self.jwk(&self.kid)]}).to_string() + } + + /// A key set with the given keys: `(kid, jwk)`; use [`StandIn::jwk_for`] for the real key. + pub fn jwks_of(keys: &[J]) -> String { + json!({"keys": keys}).to_string() + } + + /// The real key under another name. + pub fn jwk_for(&self, kid: &str) -> J { + self.jwk(kid) + } + + /// A key set whose only key has this stand-in's name but another modulus (one bit flipped): + /// a token this stand-in signed does not verify against it. + pub fn jwks_with_wrong_modulus(&self) -> String { + let mut k = self.jwk(&self.kid); + let mut n = b64url_decode(k["n"].as_str().unwrap()).unwrap(); + let last = n.len() - 1; + n[last] ^= 1; + k["n"] = J::from(b64url(&n)); + json!({"keys": [k]}).to_string() + } + + /// The discovery document of a tenant under `authority`. + pub fn discovery(&self, authority: &str, tenant: &str) -> String { + json!({ + "issuer": format!("{authority}/{tenant}/v2.0"), + "authorization_endpoint": format!("{authority}/{tenant}/oauth2/v2.0/authorize"), + "token_endpoint": format!("{authority}/{tenant}/oauth2/v2.0/token"), + "jwks_uri": format!("{authority}/{tenant}/discovery/v2.0/keys"), + }) + .to_string() + } + + /// The claims of a good id token for `oid` at time `now`: v2.0 issuer, the tenant, the + /// client, the nonce, valid for an hour, and `amr` of a password and a second factor. + pub fn claims( + authority: &str, + tenant: &str, + client_id: &str, + nonce: &str, + oid: &str, + now: u64, + ) -> J { + json!({ + "iss": format!("{authority}/{tenant}/v2.0"), + "tid": tenant, + "aud": client_id, + "sub": "standin-subject-per-application", + "oid": oid, + "nonce": nonce, + "ver": "2.0", + "iat": now, + "nbf": now, + "exp": now + 3600, + "auth_time": now, + "amr": ["pwd", "mfa"], + }) + } + + /// A compact JWS over `header` and `claims`, signed with RS256 by the stand-in's key. + pub fn sign(&self, header: &J, claims: &J) -> String { + let signing = format!( + "{}.{}", + b64url(header.to_string().as_bytes()), + b64url(claims.to_string().as_bytes()) + ); + let mut sig = vec![0u8; self.key.public().modulus_len()]; + self.key + .sign( + &RSA_PKCS1_SHA256, + &SystemRandom::new(), + signing.as_bytes(), + &mut sig, + ) + .expect("signing"); + format!("{signing}.{}", b64url(&sig)) + } + + /// A token with the usual header (`RS256`, this key's `kid`). + pub fn token(&self, claims: &J) -> String { + self.sign( + &json!({"alg": "RS256", "typ": "JWT", "kid": self.kid}), + claims, + ) + } +} diff --git a/crates/kernel/obop-auth/src/oidc/tests.rs b/crates/kernel/obop-auth/src/oidc/tests.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/crates/kernel/obop-auth/src/oidc/tests.rs @@ -0,0 +1 @@ + diff --git a/crates/kernel/obop-auth/src/oidc/verify.rs b/crates/kernel/obop-auth/src/oidc/verify.rs new file mode 100644 index 00000000..e0e3af66 --- /dev/null +++ b/crates/kernel/obop-auth/src/oidc/verify.rs @@ -0,0 +1,394 @@ +//! The checks of an OpenID Connect id token from Microsoft Entra (D91, Proposed), with no storage +//! and no network: a compact JWS, RS256 only, signature by `ring`, then the claims. Every function +//! takes untrusted input and answers a plain reason; none of them panics on bad input. +//! +//! What is checked, in this order, and why: +//! +//! 1. The shape: three base64url parts, no padding, at most 32 KiB. A JWS with a different count +//! of parts (an encrypted token has five) is refused. +//! 2. The header: `alg` is exactly `RS256` (so `none`, `HS256` with the public key as the secret, +//! `RS384`, `ES256` and every other value are refused before any key is looked at), there is +//! no `crit`, and a `kid` names the key. Keys in the header (`jwk`, `jku`, `x5u`) are never +//! used: the only keys are those of the key set the caller fetched from the pinned tenant. +//! 3. The signature, over the first two parts as they came. +//! 4. The claims, only after the signature: `iss` exactly `//v2.0` for the +//! configured tenant, `tid` the configured tenant, `aud` the client id (a string, or a list of +//! exactly one), `azp` the client id when present, `nonce` equal to the one of this flow, +//! `exp` in the future, `nbf` and `iat` not in the future (a skew of two minutes), `oid` and +//! `tid` GUIDs, `amr` read as a list of strings (absent or of another type: no `mfa`), and +//! `auth_time` kept when it is a number. +//! +//! The issuer format `https://login.microsoftonline.com//v2.0` is Microsoft's +//! documented one for v2.0 tokens (general knowledge, not tried against a real tenant). + +use crate::totp::ct_eq; +use crate::webauthn::{b64url_decode, rsa_public_key_der}; +use obop_commands::SsoIdentity; +use ring::signature::{RSA_PKCS1_2048_8192_SHA256, UnparsedPublicKey}; +use serde_json::Value as J; + +/// How far the clocks of the provider and of this server may differ. +pub const SKEW_SECS: u64 = 120; +/// The largest id token read. Real ones are 1 to 3 KiB. +pub const MAX_TOKEN_BYTES: usize = 32 * 1024; +/// The largest key set read (Entra publishes a handful of keys). +pub const MAX_JWKS_BYTES: usize = 256 * 1024; +const MAX_KEYS: usize = 100; +const MAX_KID: usize = 128; + +/// The one authority of production. +pub const AUTHORITY: &str = "https://login.microsoftonline.com"; + +/// Why a token or a key set was not accepted. The text is for the security log, never shown to +/// the person who tried. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VerifyError { + /// The key the header names is not in the key set: fetch it again, once, and retry. + UnknownKey, + Refused(&'static str), +} + +fn refuse(why: &'static str) -> Result { + Err(VerifyError::Refused(why)) +} + +// ---- tenant and authority --------------------------------------------------------------------- + +/// A GUID in the 8-4-4-4-12 form. +pub fn is_guid(s: &str) -> bool { + let b = s.as_bytes(); + b.len() == 36 + && b.iter().enumerate().all(|(i, c)| match i { + 8 | 13 | 18 | 23 => *c == b'-', + _ => c.is_ascii_hexdigit(), + }) +} + +/// The configured tenant: a GUID, lower case. The multi-tenant authorities and anything that is +/// not a GUID are refused, with the reason. +pub fn tenant_id(raw: &str) -> Result { + let t = raw.trim().to_ascii_lowercase(); + match t.as_str() { + "common" | "organizations" | "consumers" => Err(format!( + "{t:?} is a multi-tenant authority: sign-in is pinned to one tenant, given as its id (a GUID)" + )), + _ if is_guid(&t) => Ok(t), + _ => Err( + "the tenant must be the tenant id, a GUID such as 00000000-0000-0000-0000-000000000000" + .into(), + ), + } +} + +/// An authority is the production one, or (tests only) a literal loopback address over http. +/// No path, no user information, no query. +pub fn authority_ok(a: &str) -> bool { + if a == AUTHORITY { + return true; + } + let Some(rest) = a.strip_prefix("http://") else { + return false; + }; + let Some((host, port)) = rest.rsplit_once(':') else { + return false; + }; + matches!(host, "127.0.0.1" | "[::1]") + && !port.is_empty() + && port.len() <= 5 + && port.bytes().all(|c| c.is_ascii_digit()) +} + +/// The issuer of v2.0 tokens of a tenant. +pub fn issuer(authority: &str, tenant: &str) -> String { + format!("{authority}/{tenant}/v2.0") +} + +// ---- the key set ------------------------------------------------------------------------------ + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Jwk { + pub kid: String, + /// The `RSAPublicKey` DER, ready for `ring`. + pub der: Vec, +} + +/// The keys of a JWKS that can verify an RS256 signature. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Jwks { + pub keys: Vec, +} + +impl Jwks { + /// Read a key set. Keys of another type, `use` other than `sig`, `alg` other than `RS256`, + /// `key_ops` without `verify`, or with a modulus outside 2048 to 4096 bits, are left out. A + /// document that is not a key set, or leaves no key, is an error. + pub fn parse(doc: &[u8]) -> Result { + if doc.len() > MAX_JWKS_BYTES { + return Err("the key set is too large".into()); + } + let v: J = serde_json::from_slice(doc).map_err(|_| "the key set is not JSON")?; + let list = v["keys"].as_array().ok_or("the key set has no keys")?; + if list.len() > MAX_KEYS { + return Err("the key set has too many keys".into()); + } + let mut keys = Vec::new(); + for k in list { + let usable = k["kty"].as_str() == Some("RSA") + && k.get("use").is_none_or(|u| u.as_str() == Some("sig")) + && k.get("alg").is_none_or(|a| a.as_str() == Some("RS256")) + && k.get("key_ops").is_none_or(|ops| { + ops.as_array() + .is_some_and(|o| o.iter().any(|x| x.as_str() == Some("verify"))) + }); + let (Some(kid), true) = ( + k["kid"] + .as_str() + .filter(|s| !s.is_empty() && s.len() <= MAX_KID), + usable, + ) else { + continue; + }; + let n = k["n"].as_str().and_then(b64url_decode); + let e = k["e"].as_str().and_then(b64url_decode); + if let (Some(n), Some(e)) = (n, e) + && (256..=512).contains(&n.len()) + && !e.is_empty() + && e.len() <= 4 + { + keys.push(Jwk { + kid: kid.to_string(), + der: rsa_public_key_der(&n, &e), + }); + } + } + if keys.is_empty() { + return Err("the key set holds no key that can verify an RS256 signature".into()); + } + Ok(Self { keys }) + } + + fn find(&self, kid: &str) -> Result<&Jwk, VerifyError> { + let mut hits = self.keys.iter().filter(|k| k.kid == kid); + match (hits.next(), hits.next()) { + (None, _) => Err(VerifyError::UnknownKey), + (Some(k), None) => Ok(k), + // Two keys with one name: which one signed is a guess, and a guess is not a check. + (Some(_), Some(_)) => refuse("two keys in the key set have the same kid"), + } + } + + /// Whether the key a token names is in the set (for deciding to fetch the set again). + pub fn knows(&self, kid: &str) -> bool { + self.keys.iter().any(|k| k.kid == kid) + } +} + +/// The `kid` of a token's header, when the token has a readable one: for deciding whether to fetch +/// the key set again. Checks nothing else. +pub fn kid_of(token: &str) -> Option { + let header = token.split('.').next()?; + let h: J = serde_json::from_slice(&b64url_decode(header)?).ok()?; + h["kid"].as_str().map(String::from) +} + +// ---- the discovery document --------------------------------------------------------------------- + +/// The addresses of a discovery document. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Endpoints { + pub authorization: String, + pub token: String, + pub jwks: String, +} + +fn under(authority: &str, tenant: &str, url: &str) -> bool { + let prefix = format!("{authority}/{tenant}/"); + url.len() <= 512 + && url.strip_prefix(&prefix).is_some_and(|rest| { + !rest.is_empty() + && rest + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"/._~-".contains(&c)) + && !rest.split('/').any(|seg| seg == ".." || seg == ".") + }) +} + +/// Read the discovery document of the pinned tenant. Its `issuer` is exactly the issuer of the +/// tenant, and each of the three endpoints lies under `//` (so a document +/// that points the browser or the server anywhere else is refused). +pub fn parse_discovery(doc: &[u8], authority: &str, tenant: &str) -> Result { + if doc.len() > MAX_JWKS_BYTES { + return Err("the discovery document is too large".into()); + } + let v: J = serde_json::from_slice(doc).map_err(|_| "the discovery document is not JSON")?; + if v["issuer"].as_str() != Some(issuer(authority, tenant).as_str()) { + return Err("the discovery document names another issuer than the pinned tenant".into()); + } + let get = |k: &str| -> Result { + let u = v[k].as_str().unwrap_or(""); + if under(authority, tenant, u) { + Ok(u.to_string()) + } else { + Err(format!( + "the discovery document's {k} is not under the pinned tenant" + )) + } + }; + Ok(Endpoints { + authorization: get("authorization_endpoint")?, + token: get("token_endpoint")?, + jwks: get("jwks_uri")?, + }) +} + +// ---- the token --------------------------------------------------------------------------------- + +/// What a token is checked against. +#[derive(Debug, Clone, Copy)] +pub struct Expect<'a> { + pub authority: &'a str, + /// The pinned tenant, a lower-case GUID (`tenant_id`). + pub tenant: &'a str, + pub client_id: &'a str, + /// The nonce of this flow. + pub nonce: &'a str, + /// Unix seconds. + pub now: u64, +} + +fn text<'a>(v: &'a J, k: &str) -> Option<&'a str> { + v[k].as_str().filter(|s| !s.is_empty()) +} + +/// Check an id token. See the module description. +pub fn verify_id_token( + token: &str, + jwks: &Jwks, + exp: &Expect<'_>, +) -> Result { + if token.len() > MAX_TOKEN_BYTES { + return refuse("the token is too large"); + } + let parts: Vec<&str> = token.split('.').collect(); + let [header, payload, signature] = parts[..] else { + return refuse("not a compact JWS with three parts"); + }; + if header.is_empty() || payload.is_empty() || signature.is_empty() { + return refuse("a part of the token is empty"); + } + let h: J = b64url_decode(header) + .and_then(|b| serde_json::from_slice(&b).ok()) + .ok_or(VerifyError::Refused("the header is not base64url JSON"))?; + if !h.is_object() { + return refuse("the header is not an object"); + } + // The algorithm is fixed by the server, not chosen by the token: one exact value. + if h["alg"].as_str() != Some("RS256") { + return refuse("the algorithm is not RS256"); + } + if h.get("crit").is_some() { + return refuse("the header has critical extensions"); + } + let Some(kid) = text(&h, "kid").filter(|k| k.len() <= MAX_KID) else { + return refuse("the header names no key"); + }; + let key = jwks.find(kid)?; + let sig = + b64url_decode(signature).ok_or(VerifyError::Refused("the signature is not base64url"))?; + let signed = &token[..header.len() + 1 + payload.len()]; + if UnparsedPublicKey::new(&RSA_PKCS1_2048_8192_SHA256, &key.der) + .verify(signed.as_bytes(), &sig) + .is_err() + { + return refuse("the signature does not verify"); + } + // Only a token the provider signed is read as claims. + let c: J = b64url_decode(payload) + .and_then(|b| serde_json::from_slice(&b).ok()) + .ok_or(VerifyError::Refused("the claims are not base64url JSON"))?; + claims(&c, exp) +} + +fn claims(c: &J, exp: &Expect<'_>) -> Result { + let want_iss = issuer(exp.authority, exp.tenant); + if c["iss"].as_str() != Some(want_iss.as_str()) { + return refuse("the issuer is not the pinned tenant's"); + } + let tid = text(c, "tid").map(str::to_ascii_lowercase); + match tid.as_deref() { + Some(t) if is_guid(t) => { + if t != exp.tenant { + return refuse("the tenant is not the pinned tenant"); + } + } + _ => return refuse("the token carries no tenant id"), + } + let aud_ok = match &c["aud"] { + J::String(a) => a == exp.client_id, + J::Array(a) => a.len() == 1 && a[0].as_str() == Some(exp.client_id), + _ => false, + }; + if !aud_ok { + return refuse("the audience is not this client"); + } + if let Some(azp) = c.get("azp") + && azp.as_str() != Some(exp.client_id) + { + return refuse("the authorized party is not this client"); + } + match c["nonce"].as_str() { + Some(n) if !exp.nonce.is_empty() && ct_eq(n.as_bytes(), exp.nonce.as_bytes()) => {} + _ => return refuse("the nonce is not the one of this sign-in"), + } + let time = |k: &str| -> Result, VerifyError> { + match c.get(k) { + None | Some(J::Null) => Ok(None), + Some(v) => v + .as_u64() + .or_else(|| { + v.as_f64() + .filter(|f| *f >= 0.0 && f.fract() == 0.0) + .map(|f| f as u64) + }) + .map(Some) + .ok_or(VerifyError::Refused("a time claim is not a number")), + } + }; + let Some(exp_at) = time("exp")? else { + return refuse("the token has no expiry"); + }; + if exp_at.saturating_add(SKEW_SECS) <= exp.now { + return refuse("the token has expired"); + } + if let Some(nbf) = time("nbf")? + && nbf > exp.now.saturating_add(SKEW_SECS) + { + return refuse("the token is not valid yet"); + } + if let Some(iat) = time("iat")? + && iat > exp.now.saturating_add(SKEW_SECS) + { + return refuse("the token was issued in the future"); + } + let oid = text(c, "oid").map(str::to_ascii_lowercase); + let Some(oid) = oid.filter(|o| is_guid(o)) else { + return refuse("the token carries no object id"); + }; + // `amr` is a list of strings. Anything else says nothing about a second factor. + let amr: Vec = c["amr"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + let auth_time = time("auth_time").ok().flatten(); + Ok(SsoIdentity { + issuer: want_iss, + tenant: exp.tenant.to_string(), + oid, + amr, + auth_time, + }) +} diff --git a/crates/kernel/obop-auth/src/oidc/verify_tests.rs b/crates/kernel/obop-auth/src/oidc/verify_tests.rs new file mode 100644 index 00000000..ea86b0ae --- /dev/null +++ b/crates/kernel/obop-auth/src/oidc/verify_tests.rs @@ -0,0 +1,564 @@ +//! The id token checks of D91 as a table: every row is a way a token can be wrong, and every one +//! must be refused; the first row is the token that must pass. A check that is left out of +//! `verify` makes exactly its row fail (see the mutation list in the pull request). + +use super::soft::StandIn; +use super::verify::*; +use crate::webauthn::{b64url, b64url_decode}; +use serde_json::{Value as J, json}; + +const TENANT: &str = "11111111-2222-3333-4444-555555555555"; +const OTHER_TENANT: &str = "99999999-2222-3333-4444-555555555555"; +const CLIENT: &str = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; +const OID: &str = "0f0f0f0f-1111-2222-3333-444444444444"; +const NONCE: &str = "nonce-of-this-sign-in"; +const NOW: u64 = 1_800_000_000; + +fn expect() -> Expect<'static> { + Expect { + authority: AUTHORITY, + tenant: TENANT, + client_id: CLIENT, + nonce: NONCE, + now: NOW, + } +} + +fn claims() -> J { + StandIn::claims(AUTHORITY, TENANT, CLIENT, NONCE, OID, NOW) +} + +fn keys(s: &StandIn) -> Jwks { + Jwks::parse(s.jwks().as_bytes()).unwrap() +} + +fn remove(c: &mut J, k: &str) { + c.as_object_mut().unwrap().remove(k); +} + +fn refused(r: Result, why: &str) { + match r { + Err(VerifyError::Refused(w)) => { + assert!( + w.contains(why), + "refused for {w:?}, wanted a reason with {why:?}" + ) + } + other => panic!("wanted a refusal about {why:?}, got {other:?}"), + } +} + +/// Change the claims and sign them again with the stand-in key. +fn with(f: impl FnOnce(&mut J)) -> (StandIn, String) { + let s = StandIn::new(); + let mut c = claims(); + f(&mut c); + let t = s.token(&c); + (s, t) +} + +fn check(f: impl FnOnce(&mut J), why: &str) { + let (s, t) = with(f); + refused(verify_id_token(&t, &keys(&s), &expect()), why); +} + +#[test] +fn a_good_token_passes_and_says_who_and_how() { + let s = StandIn::new(); + let id = verify_id_token(&s.token(&claims()), &keys(&s), &expect()).unwrap(); + assert_eq!(id.oid, OID); + assert_eq!(id.tenant, TENANT); + assert_eq!(id.issuer, format!("{AUTHORITY}/{TENANT}/v2.0")); + assert!(id.mfa()); + assert_eq!(id.auth_time, Some(NOW)); +} + +#[test] +fn the_tenant_is_pinned_the_issuer_and_the_tid() { + // A token of another tenant, correctly signed, with its own issuer. + check( + |c| { + c["tid"] = J::from(OTHER_TENANT); + c["iss"] = J::from(format!("{AUTHORITY}/{OTHER_TENANT}/v2.0")); + }, + "issuer", + ); + // The right issuer but a tid of another tenant. + check(|c| c["tid"] = J::from(OTHER_TENANT), "pinned tenant"); + // The multi-tenant authorities as the issuer. + for a in ["common", "organizations", "consumers"] { + check( + |c| c["iss"] = J::from(format!("{AUTHORITY}/{a}/v2.0")), + "issuer", + ); + } + // The v1 issuer format, a trailing slash, another host, another version, no issuer. + check( + |c| c["iss"] = J::from(format!("https://sts.windows.net/{TENANT}/")), + "issuer", + ); + check( + |c| c["iss"] = J::from(format!("{AUTHORITY}/{TENANT}/v2.0/")), + "issuer", + ); + check( + |c| { + c["iss"] = J::from(format!( + "https://login.microsoftonline.com.evil.test/{TENANT}/v2.0" + )) + }, + "issuer", + ); + check( + |c| c["iss"] = J::from(format!("{AUTHORITY}/{TENANT}/v1.0")), + "issuer", + ); + check(|c| remove(c, "iss"), "issuer"); + check(|c| remove(c, "tid"), "tenant id"); + check(|c| c["tid"] = J::from("not-a-guid"), "tenant id"); +} + +#[test] +fn the_tenant_in_the_configuration_is_a_guid_and_never_a_multi_tenant_authority() { + assert_eq!(tenant_id(TENANT).unwrap(), TENANT); + assert_eq!( + tenant_id("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE").unwrap(), + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + ); + assert_eq!(tenant_id(&format!(" {TENANT}\n")).unwrap(), TENANT); + for bad in [ + "common", + "Common", + "organizations", + "consumers", + "contoso.onmicrosoft.com", + "", + "11111111-2222-3333-4444-55555555555", + "11111111-2222-3333-4444-5555555555555", + "1111111122223333444455555555555555ff", + "{11111111-2222-3333-4444-555555555555}", + "11111111-2222-3333-4444-55555555555g", + ] { + assert!(tenant_id(bad).is_err(), "{bad:?} must be refused"); + } + assert!(tenant_id("common").unwrap_err().contains("multi-tenant")); +} + +#[test] +fn the_authority_is_microsofts_or_in_a_test_a_loopback_address() { + assert!(authority_ok(AUTHORITY)); + assert!(authority_ok("http://127.0.0.1:4455")); + assert!(authority_ok("http://[::1]:4455")); + for bad in [ + "http://login.microsoftonline.com", + "https://login.microsoftonline.com/", + "https://login.microsoftonline.com/common", + "https://login.microsoftonline.us", + "https://127.0.0.1:4455", + "http://127.0.0.1", + "http://localhost:4455", + "http://127.0.0.1:4455/path", + "http://user@127.0.0.1:4455", + "http://10.0.0.1:4455", + "http://127.0.0.1:44555555", + ] { + assert!(!authority_ok(bad), "{bad:?} must be refused"); + } +} + +#[test] +fn audience_and_authorized_party_are_this_client() { + check(|c| c["aud"] = J::from("someone-else"), "audience"); + check(|c| c["aud"] = J::from(OID), "audience"); + check(|c| remove(c, "aud"), "audience"); + check(|c| c["aud"] = json!([CLIENT, "someone-else"]), "audience"); + check(|c| c["aud"] = json!([]), "audience"); + check(|c| c["aud"] = json!(7), "audience"); + check(|c| c["azp"] = J::from("someone-else"), "authorized party"); + // One audience in a list, and an azp that agrees, are fine. + let (s, t) = with(|c| { + c["aud"] = json!([CLIENT]); + c["azp"] = J::from(CLIENT); + }); + assert!(verify_id_token(&t, &keys(&s), &expect()).is_ok()); +} + +#[test] +fn the_nonce_is_the_one_of_this_sign_in() { + check(|c| c["nonce"] = J::from("another"), "nonce"); + check(|c| c["nonce"] = J::from(format!("{NONCE}x")), "nonce"); + check(|c| remove(c, "nonce"), "nonce"); + check(|c| c["nonce"] = J::from(7), "nonce"); + // A flow that has no nonce (it must always have one) accepts nothing. + let s = StandIn::new(); + let mut c = claims(); + c["nonce"] = J::from(""); + let e = Expect { + nonce: "", + ..expect() + }; + refused(verify_id_token(&s.token(&c), &keys(&s), &e), "nonce"); +} + +#[test] +fn the_times_are_checked_with_a_small_skew() { + // Expired, just inside the skew (still accepted), just outside, missing, not a number. + check(|c| c["exp"] = J::from(NOW - SKEW_SECS), "expired"); + check(|c| c["exp"] = J::from(NOW - 3600), "expired"); + let (s, t) = with(|c| c["exp"] = J::from(NOW - SKEW_SECS + 1)); + assert!(verify_id_token(&t, &keys(&s), &expect()).is_ok()); + check(|c| remove(c, "exp"), "no expiry"); + check(|c| c["exp"] = J::from("soon"), "number"); + // Not yet valid. + check(|c| c["nbf"] = J::from(NOW + SKEW_SECS + 1), "not valid yet"); + let (s, t) = with(|c| c["nbf"] = J::from(NOW + SKEW_SECS)); + assert!(verify_id_token(&t, &keys(&s), &expect()).is_ok()); + // Issued in the future. + check(|c| c["iat"] = J::from(NOW + SKEW_SECS + 1), "future"); + // Time claims as floats are read as the whole numbers they are. + let (s, t) = with(|c| c["exp"] = J::from((NOW + 3600) as f64)); + assert!(verify_id_token(&t, &keys(&s), &expect()).is_ok()); +} + +#[test] +fn the_object_id_is_a_guid_and_is_required() { + check(|c| remove(c, "oid"), "object id"); + check(|c| c["oid"] = J::from("ada@example.test"), "object id"); + check(|c| c["oid"] = J::from(""), "object id"); + check(|c| c["oid"] = J::from(7), "object id"); + // The subject is not what binds: a token with only `sub` is refused. + check( + |c| { + remove(c, "oid"); + c["sub"] = J::from(OID); + }, + "object id", + ); + // Upper case is the same identity. + let (s, t) = with(|c| c["oid"] = J::from(OID.to_uppercase())); + assert_eq!(verify_id_token(&t, &keys(&s), &expect()).unwrap().oid, OID); +} + +#[test] +fn amr_is_a_list_and_absence_is_not_mfa() { + let id = |f: fn(&mut J)| { + let (s, t) = with(f); + verify_id_token(&t, &keys(&s), &expect()).unwrap() + }; + assert!(id(|_| {}).mfa()); + assert!(!id(|c| remove(c, "amr")).mfa()); + assert!(!id(|c| c["amr"] = json!(["pwd"])).mfa()); + assert!(!id(|c| c["amr"] = json!([])).mfa()); + // Not a list: says nothing about a second factor, however it is spelled. + assert!(!id(|c| c["amr"] = J::from("mfa")).mfa()); + assert!(!id(|c| c["amr"] = json!({"mfa": true})).mfa()); + assert!(!id(|c| c["amr"] = json!([true, 1, null])).mfa()); + assert!( + !id(|c| c["amr"] = json!(["MFA"])).mfa(), + "the value is exactly mfa" + ); + // A malformed auth_time is dropped, not fatal and not trusted. + assert_eq!( + id(|c| c["auth_time"] = J::from("yesterday")).auth_time, + None + ); +} + +#[test] +fn the_algorithm_is_fixed_by_the_server() { + let s = StandIn::new(); + let k = keys(&s); + let signing_input = |header: J| { + format!( + "{}.{}", + b64url(header.to_string().as_bytes()), + b64url(claims().to_string().as_bytes()) + ) + }; + // none, with an empty signature and with a made-up one. + let none = signing_input(json!({"alg": "none", "kid": s.kid})); + refused(verify_id_token(&format!("{none}."), &k, &expect()), "empty"); + refused( + verify_id_token(&format!("{none}.AAAA"), &k, &expect()), + "algorithm", + ); + // HS256 with the public key as the secret: the attack on servers that believe the header. + let hs = signing_input(json!({"alg": "HS256", "kid": s.kid})); + let mac = ring::hmac::sign( + &ring::hmac::Key::new(ring::hmac::HMAC_SHA256, &k.keys[0].der), + hs.as_bytes(), + ); + let tag = b64url(mac.as_ref()); + refused( + verify_id_token(&format!("{hs}.{tag}"), &k, &expect()), + "algorithm", + ); + // Other algorithms and spellings, even with a signature that would verify under RS256. + for alg in [ + "RS384", "RS512", "PS256", "ES256", "EdDSA", "rs256", "RS256 ", "", "HS256", + ] { + let t = s.sign(&json!({"alg": alg, "kid": s.kid}), &claims()); + refused(verify_id_token(&t, &k, &expect()), "algorithm"); + } + let t = s.sign(&json!({"kid": s.kid}), &claims()); + refused(verify_id_token(&t, &k, &expect()), "algorithm"); + let t = s.sign(&json!({"alg": ["RS256"], "kid": s.kid}), &claims()); + refused(verify_id_token(&t, &k, &expect()), "algorithm"); + // Critical extensions are not understood, so the token is not accepted. + let t = s.sign( + &json!({"alg": "RS256", "kid": s.kid, "crit": ["exp"]}), + &claims(), + ); + refused(verify_id_token(&t, &k, &expect()), "critical"); +} + +#[test] +fn keys_come_from_the_pinned_key_set_by_kid_and_nowhere_else() { + let s = StandIn::new(); + let k = keys(&s); + // No kid, an empty kid, a kid that is not a string. + for kid in [J::Null, J::from(""), J::from(7)] { + let mut h = json!({"alg": "RS256"}); + if !kid.is_null() { + h["kid"] = kid; + } + refused( + verify_id_token(&s.sign(&h, &claims()), &k, &expect()), + "names no key", + ); + } + // An unknown kid asks for the key set to be fetched again. + let t = s.sign(&json!({"alg": "RS256", "kid": "rotated-key"}), &claims()); + assert_eq!( + verify_id_token(&t, &k, &expect()), + Err(VerifyError::UnknownKey) + ); + assert!(!k.knows("rotated-key") && k.knows(&s.kid)); + // A key carried in the header is never used, whatever it says. + let jwk = s.jwk_for("rotated-key"); + let t = s.sign( + &json!({"alg": "RS256", "kid": "rotated-key", "jwk": jwk, "jku": "https://evil.test/keys", "x5u": "https://evil.test/x"}), + &claims(), + ); + assert_eq!( + verify_id_token(&t, &k, &expect()), + Err(VerifyError::UnknownKey) + ); + // Two keys of the same name: which one signed is not told by the name, so nothing verifies. + let dup = StandIn::jwks_of(&[s.jwk_for(&s.kid), s.jwk_for(&s.kid)]); + refused( + verify_id_token( + &s.token(&claims()), + &Jwks::parse(dup.as_bytes()).unwrap(), + &expect(), + ), + "same kid", + ); + // A key marked for encryption, or for another algorithm, is not a key for this. + let mut enc = s.jwk_for(&s.kid); + enc["use"] = J::from("enc"); + let set = Jwks::parse(StandIn::jwks_of(&[enc.clone(), s.jwk_for("other")]).as_bytes()).unwrap(); + assert_eq!( + verify_id_token(&s.token(&claims()), &set, &expect()), + Err(VerifyError::UnknownKey) + ); + enc["use"] = J::from("sig"); + enc["alg"] = J::from("RS512"); + let set = Jwks::parse(StandIn::jwks_of(&[enc, s.jwk_for("other")]).as_bytes()).unwrap(); + assert_eq!( + verify_id_token(&s.token(&claims()), &set, &expect()), + Err(VerifyError::UnknownKey) + ); + // Another key under the right name: the signature does not verify. + let wrong = Jwks::parse(s.jwks_with_wrong_modulus().as_bytes()).unwrap(); + refused( + verify_id_token(&s.token(&claims()), &wrong, &expect()), + "signature", + ); +} + +#[test] +fn a_changed_or_cut_token_does_not_verify() { + let s = StandIn::new(); + let k = keys(&s); + let good = s.token(&claims()); + let mut parts = good.split('.').map(String::from).collect::>(); + // Truncated signature, one byte flipped, a signature of the wrong length, an empty one. + let sig = b64url_decode(&parts[2]).unwrap(); + let cut = format!( + "{}.{}.{}", + parts[0], + parts[1], + b64url(&sig[..sig.len() - 4]) + ); + refused(verify_id_token(&cut, &k, &expect()), "signature"); + let mut flipped = sig.clone(); + flipped[10] ^= 1; + let t = format!("{}.{}.{}", parts[0], parts[1], b64url(&flipped)); + refused(verify_id_token(&t, &k, &expect()), "signature"); + let t = format!("{}.{}.{}", parts[0], parts[1], b64url(&[0u8; 256])); + refused(verify_id_token(&t, &k, &expect()), "signature"); + let t = format!("{}.{}.", parts[0], parts[1]); + refused(verify_id_token(&t, &k, &expect()), "empty"); + let t = format!("{}.{}.not base64!", parts[0], parts[1]); + refused(verify_id_token(&t, &k, &expect()), "base64url"); + // Claims changed after signing: the signature covers the text as it was. + let mut other = claims(); + other["oid"] = J::from("77777777-1111-2222-3333-444444444444"); + parts[1] = b64url(other.to_string().as_bytes()); + refused( + verify_id_token(&parts.join("."), &k, &expect()), + "signature", + ); + // The header changed (the key name) after signing. + let mut parts = good.split('.').map(String::from).collect::>(); + parts[0] = b64url( + json!({"alg": "RS256", "kid": "rotated-key"}) + .to_string() + .as_bytes(), + ); + assert_eq!( + verify_id_token(&parts.join("."), &k, &expect()), + Err(VerifyError::UnknownKey) + ); +} + +#[test] +fn only_a_compact_jws_of_three_parts_is_read() { + let s = StandIn::new(); + let k = keys(&s); + let good = s.token(&claims()); + refused(verify_id_token("", &k, &expect()), "three parts"); + refused(verify_id_token("a.b", &k, &expect()), "three parts"); + refused( + verify_id_token(&format!("{good}.x"), &k, &expect()), + "three parts", + ); + refused(verify_id_token("a.b.c.d.e", &k, &expect()), "three parts"); + refused(verify_id_token("..", &k, &expect()), "empty"); + // The header is not JSON, or JSON that is not an object. + refused(verify_id_token("e30.e30.AAAA", &k, &expect()), "algorithm"); + let arr = b64url(b"[1]"); + refused( + verify_id_token(&format!("{arr}.e30.AAAA"), &k, &expect()), + "object", + ); + refused(verify_id_token("!!!.e30.AAAA", &k, &expect()), "base64url"); + // Size: refused before anything is decoded. + let big = format!("{}.b.c", "a".repeat(MAX_TOKEN_BYTES)); + refused(verify_id_token(&big, &k, &expect()), "too large"); +} + +#[test] +fn the_key_set_keeps_only_keys_that_can_verify_rs256() { + let s = StandIn::new(); + let good = s.jwk_for("good"); + let mut short = s.jwk_for("short"); + short["n"] = J::from(b64url(&[0xffu8; 128])); // 1024 bits + let mut ec = s.jwk_for("ec"); + ec["kty"] = J::from("EC"); + let mut noverify = s.jwk_for("noverify"); + noverify["key_ops"] = json!(["encrypt"]); + let mut bigexp = s.jwk_for("bigexp"); + bigexp["e"] = J::from(b64url(&[1u8, 0, 0, 0, 1])); + let mut noid = s.jwk_for("x"); + noid["kid"] = J::Null; + let set = Jwks::parse(StandIn::jwks_of(&[good, short, ec, noverify, bigexp, noid]).as_bytes()) + .unwrap(); + assert_eq!( + set.keys.iter().map(|k| k.kid.as_str()).collect::>(), + ["good"] + ); + let bad: [&[u8]; 6] = [ + b"", + b"not json", + b"{}", + b"{\"keys\": 5}", + b"{\"keys\": []}", + br#"{"keys": [{"kty": "oct", "kid": "x", "k": "AAAA"}]}"#, + ]; + for doc in bad { + assert!( + Jwks::parse(doc).is_err(), + "{:?}", + String::from_utf8_lossy(doc) + ); + } + let many = (0..=100) + .map(|i| s.jwk_for(&format!("k{i}"))) + .collect::>(); + assert!(Jwks::parse(StandIn::jwks_of(&many).as_bytes()).is_err()); + assert!(Jwks::parse(&vec![b' '; MAX_JWKS_BYTES + 1]).is_err()); + assert_eq!(kid_of(&s.token(&claims())).as_deref(), Some(s.kid.as_str())); + assert_eq!(kid_of("garbage"), None); +} + +#[test] +fn the_discovery_document_is_the_pinned_tenants_and_points_only_under_it() { + let s = StandIn::new(); + let ok = parse_discovery(s.discovery(AUTHORITY, TENANT).as_bytes(), AUTHORITY, TENANT).unwrap(); + assert_eq!( + ok.authorization, + format!("{AUTHORITY}/{TENANT}/oauth2/v2.0/authorize") + ); + assert_eq!(ok.token, format!("{AUTHORITY}/{TENANT}/oauth2/v2.0/token")); + assert_eq!(ok.jwks, format!("{AUTHORITY}/{TENANT}/discovery/v2.0/keys")); + let doc = |f: &dyn Fn(&mut J)| { + let mut d: J = serde_json::from_str(&s.discovery(AUTHORITY, TENANT)).unwrap(); + f(&mut d); + parse_discovery(d.to_string().as_bytes(), AUTHORITY, TENANT) + }; + assert!(doc(&|_| {}).is_ok()); + // Another issuer: the document of another tenant, or the template with {tenantid} in it. + assert!(doc(&|d| d["issuer"] = J::from(format!("{AUTHORITY}/{OTHER_TENANT}/v2.0"))).is_err()); + assert!(doc(&|d| d["issuer"] = J::from(format!("{AUTHORITY}/{{tenantid}}/v2.0"))).is_err()); + assert!( + doc(&|d| { + d.as_object_mut().unwrap().remove("issuer"); + }) + .is_err() + ); + // An endpoint anywhere else, however close it looks. + for (key, url) in [ + ("token_endpoint", "https://evil.test/token".to_string()), + ( + "token_endpoint", + format!("https://login.microsoftonline.com.evil.test/{TENANT}/oauth2/v2.0/token"), + ), + ( + "token_endpoint", + format!("{AUTHORITY}/{OTHER_TENANT}/oauth2/v2.0/token"), + ), + ( + "token_endpoint", + format!("{AUTHORITY}/common/oauth2/v2.0/token"), + ), + ( + "token_endpoint", + format!("{AUTHORITY}/{TENANT}/../{OTHER_TENANT}/token"), + ), + ("token_endpoint", format!("{AUTHORITY}/{TENANT}/token?x=1")), + ("token_endpoint", format!("{AUTHORITY}/{TENANT}/token#x")), + ("token_endpoint", format!("{AUTHORITY}/{TENANT}/")), + ( + "token_endpoint", + format!("http://login.microsoftonline.com/{TENANT}/oauth2/v2.0/token"), + ), + ("authorization_endpoint", "javascript:alert(1)".to_string()), + ("authorization_endpoint", String::new()), + ("jwks_uri", format!("{AUTHORITY}@evil.test/{TENANT}/keys")), + ] { + assert!( + doc(&|d| d[key] = J::from(url.clone())).is_err(), + "{key} {url:?} must be refused" + ); + } + assert!( + doc(&|d| { + d.as_object_mut().unwrap().remove("jwks_uri"); + }) + .is_err() + ); + assert!(parse_discovery(b"not json", AUTHORITY, TENANT).is_err()); + assert!(parse_discovery(&vec![b' '; MAX_JWKS_BYTES + 1], AUTHORITY, TENANT).is_err()); +} diff --git a/crates/kernel/obop-auth/src/passkey.rs b/crates/kernel/obop-auth/src/passkey.rs index a268d6eb..f352648e 100644 --- a/crates/kernel/obop-auth/src/passkey.rs +++ b/crates/kernel/obop-auth/src/passkey.rs @@ -530,6 +530,16 @@ impl Auth { credential: &J, ) -> Result { let (name, _, epoch) = self.verify_assertion(ctx, "login", credential, true)?; + // An account that signs in with Microsoft only (D91) has no passkey sign-in of its own. + if self.is_sso_only(&name) { + self.event( + &name, + "login_refused", + &name, + "the account signs in with Microsoft only; a passkey alone was not accepted", + ); + return Err(denied("that passkey could not be used to sign in")); + } self.purge_tokens(); self.event( &name, diff --git a/crates/kernel/obop-auth/src/webauthn/soft.rs b/crates/kernel/obop-auth/src/webauthn/soft.rs index 79de6748..6b71f4cd 100644 --- a/crates/kernel/obop-auth/src/webauthn/soft.rs +++ b/crates/kernel/obop-auth/src/webauthn/soft.rs @@ -244,7 +244,7 @@ impl SoftAuthenticator { } /// `(n, e)` of a DER `RSAPublicKey` (SEQUENCE of two INTEGERs), without leading zeros. -fn split_rsa_der(der: &[u8]) -> (&[u8], &[u8]) { +pub(crate) fn split_rsa_der(der: &[u8]) -> (&[u8], &[u8]) { fn len(b: &[u8]) -> (usize, usize) { if b[0] < 0x80 { (usize::from(b[0]), 1) diff --git a/crates/kernel/obop-commands/src/lib.rs b/crates/kernel/obop-commands/src/lib.rs index 8821575e..e6c9fa32 100644 --- a/crates/kernel/obop-commands/src/lib.rs +++ b/crates/kernel/obop-commands/src/lib.rs @@ -1376,6 +1376,11 @@ pub trait Authenticator: Send + Sync { fn passkeys(&self) -> Option<&dyn PasskeyApi> { None } + /// Sign-in with an external identity provider (Microsoft Entra, D91, Proposed), for + /// authenticators that support it. + fn sso(&self) -> Option<&dyn SsoApi> { + None + } /// Resolve a bearer token and say which audience (resource) it was issued for. Tokens from /// the OAuth flow (D64) carry one and work only there; every other token has none. fn resolve_with_audience(&self, token: &str) -> Option<(Principal, Option)> { @@ -1707,6 +1712,7 @@ mod kinds; pub mod pay; pub mod public; pub mod query; +pub mod sso; pub mod store; pub use accounts::{AccountsApi, NewUser}; @@ -1718,6 +1724,7 @@ pub use public::{ Adjust, Lookup, PublicApi, PublicFilter, PublicRead, PublicReceipt, PublicRecord, PublicSpec, PublicSubmit, Receipt, Submission, SubmitError, }; +pub use sso::{SsoApi, SsoEndpoints, SsoError, SsoIdentity, SsoLeg, SsoProvider}; pub use store::Store; pub mod names { diff --git a/crates/kernel/obop-commands/src/sso.rs b/crates/kernel/obop-commands/src/sso.rs new file mode 100644 index 00000000..7940ccb2 --- /dev/null +++ b/crates/kernel/obop-commands/src/sso.rs @@ -0,0 +1,172 @@ +//! Sign-in with an external identity provider (D91, Proposed): the port between the HTTP leg +//! (`obop-http`: the browser round trip and the two calls to the provider) and the identity +//! provider of the instance (`obop-auth`: the checks of the id token, the binding of a Microsoft +//! identity to an account, the one-time code and the session). Nothing here touches the network +//! and nothing here mints a session: `obop-auth` is the only crate that does. +//! +//! The provider is Microsoft Entra, one tenant, pinned in the configuration. The id token is +//! verified and dropped; no token of the provider is stored. + +use crate::{LoginOutcome, MfaError, Principal}; + +/// The configuration of the one provider, as an administrator sees it. The client secret is never +/// part of it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SsoProvider { + /// `entra`. + pub kind: String, + /// The pinned tenant (a GUID). Never `common`, `organizations` or `consumers`. + pub tenant: String, + pub client_id: String, + /// Whether the sign-in is offered (the button on the sign-in page). + pub enabled: bool, + /// Whether a client secret is stored (sealed under `OBOP_SECRET_KEY`). + pub secret_set: bool, + pub updated_at: Option, + pub updated_by: Option, +} + +/// What the browser leg needs to talk to the provider. The secret is opened for this one use. +#[derive(Clone)] +pub struct SsoLeg { + /// `https://login.microsoftonline.com` (a loopback address in tests only). + pub authority: String, + pub tenant: String, + pub client_id: String, + pub client_secret: String, +} + +impl std::fmt::Debug for SsoLeg { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SsoLeg") + .field("authority", &self.authority) + .field("tenant", &self.tenant) + .field("client_id", &self.client_id) + .field("client_secret", &"(sealed)") + .finish() + } +} + +/// The three addresses of the provider's discovery document that the leg uses, all checked to lie +/// under the pinned tenant of the authority. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SsoEndpoints { + pub authorization: String, + pub token: String, + pub jwks: String, +} + +/// A verified identity: what an id token that passed every check says. Handed back to +/// `SsoApi::begin` and nowhere else; it carries no token and no name or address of the person. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SsoIdentity { + pub issuer: String, + pub tenant: String, + /// The object id of the person in the directory (the stable identifier; never an e-mail). + pub oid: String, + /// The `amr` claim as a list (absent: empty). + pub amr: Vec, + pub auth_time: Option, +} + +impl SsoIdentity { + /// Whether the provider says a second factor was used. + pub fn mfa(&self) -> bool { + self.amr.iter().any(|m| m == "mfa") + } +} + +/// Why a step of the Microsoft sign-in did not go through. The person is told one thing for +/// `Refused` and `UnknownKey`; the security log gets the reason. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SsoError { + /// Not a valid sign-in: a check of the token failed, nobody is bound, the account is + /// disabled. The text is for the log, never for the browser. + Refused(String), + /// The id token names a key the cached key set does not hold: fetch the key set again (once, + /// rate limited) and retry before refusing. + UnknownKey, + /// Bad input from an administrator (a tenant that is not a GUID, an object id that is not + /// one, a rule of the break-glass guard). + Invalid(String), + /// The store or the key ring could not do it. + Unavailable(String), + /// The caller may not do this (not an administrator): answered like an unknown command. + NotFound, +} + +/// The identity provider's side of the sign-in with Microsoft (D91). Implemented by `obop-auth`. +pub trait SsoApi: Send + Sync { + /// Whether the sign-in button is offered: a provider is configured and enabled. A store that + /// does not answer is "no" (nothing is offered on a guess). + fn offered(&self) -> bool; + + /// The configuration, without the secret; `None` when none is stored. + fn provider(&self) -> Result, SsoError>; + + /// The configuration of an enabled provider with the secret opened, for the one HTTP leg. + /// `Ok(None)` when none is stored or it is not enabled. + fn leg(&self) -> Result, SsoError>; + + /// Check the discovery document of the pinned tenant: its `issuer` is exactly the issuer of + /// the tenant and its three endpoints lie under the authority and the tenant. + fn endpoints(&self, discovery: &[u8]) -> Result; + + /// Verify an id token against the key set (the JSON of the `jwks_uri`) and the `nonce` of the + /// flow it was asked for. Checks the signature, the algorithm, the issuer, the tenant, the + /// audience, the nonce and the times; see D91. + fn verify(&self, id_token: &str, jwks: &[u8], nonce: &str) -> Result; + + /// A verified identity asks to sign in: find the account it is bound to and, if that account + /// may sign in, return a one-time code (single use, 60 seconds, stored hashed) for + /// `exchange`. Every refusal is `Refused`; the log says which. `ip` is for the log only. + fn begin(&self, identity: &SsoIdentity, ip: &str) -> Result; + + /// Spend the one-time code and give the sign-in step its result, as the password step does: + /// a session, a session limited to enrolment, or the demand for an obop second factor. + fn exchange(&self, code: &str) -> Result; + + /// Write a line to the security log (`oidc_*`); never a token, a code or a secret. + fn audit(&self, user: &str, kind: &str, detail: &str); + + // ---- administration (an administrator whose session is whole; agents only propose) ---- + + /// Store the configuration. The tenant must be a GUID (`common`, `organizations`, + /// `consumers` and anything else are refused). `client_secret` `None` keeps the stored one. + fn configure( + &self, + admin: &Principal, + tenant: &str, + client_id: &str, + client_secret: Option<&str>, + enabled: bool, + ) -> Result; + + /// Remove the configuration (the bindings stay; nobody can sign in with Microsoft meanwhile). + /// SSO-only accounts then have no way in except the break-glass administrators' help, so the + /// log entry says how many there were. + fn clear(&self, admin: &Principal) -> Result<(), SsoError>; + + /// Bind the object id `oid` (in the pinned tenant) to the existing account `user`. The + /// account must exist and be enabled, and neither may be bound already. Creates no account, + /// no person, no grant and no group. + fn bind(&self, admin: &Principal, user: &str, oid: &str) + -> Result; + + /// Remove the binding of `user`. Refused while the account is SSO-only (turn that off first). + fn unbind(&self, admin: &Principal, user: &str) -> Result; + + /// Make the account SSO-only (it refuses the password and the passkey on its own) or turn + /// that off. Turning it on needs a binding, and leaves at least two other enabled + /// administrators that are not SSO-only and hold a working second factor (break-glass). + fn set_sso_only( + &self, + admin: &Principal, + user: &str, + on: bool, + ) -> Result; + + /// The accounts with their binding and SSO-only flag, and how many break-glass administrators + /// the instance has. For administrators. + fn bindings(&self, admin: &Principal) -> Result; +} From 70d32de5efdbc5cf36e7778878a03a665de3d894 Mon Sep 17 00:00:00 2001 From: Mikael Rinne <40919111+rorychatt@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:19:37 +0100 Subject: [PATCH 02/23] Test the Microsoft sign-in at the provider level, seal its secret for rotation (D91) obop-auth: 19 tests of configuration (tenant a GUID, secret sealed and never shown), bindings (one identity one account, keyed by issuer, tenant and object id), the one-time code (hashed, single use, 60 s, rechecked against the account and the credential epoch), the second factor rule (Microsoft's mfa stands for it only on SSO-only accounts), the password and passkey refusals of SSO-only accounts, the break-glass rule and the key rotation of the client secret. An SSO-only account also refuses an administrator-set temporary password. The client secret is listed in the obop-keys rotation inventory and in the obop-ops inventory test. Co-Authored-By: Claude Sonnet 5.5 --- crates/kernel/obop-auth/src/account.rs | 10 +- crates/kernel/obop-auth/src/oidc/mod.rs | 14 +- crates/kernel/obop-auth/src/oidc/tests.rs | 763 +++++++++++++++++++ crates/kernel/obop-auth/src/passkey_tests.rs | 8 +- crates/kernel/obop-keys/src/reseal.rs | 10 +- crates/kernel/obop-ops/src/secrets/tests.rs | 1 + 6 files changed, 793 insertions(+), 13 deletions(-) diff --git a/crates/kernel/obop-auth/src/account.rs b/crates/kernel/obop-auth/src/account.rs index eeb04bda..b49f62f0 100644 --- a/crates/kernel/obop-auth/src/account.rs +++ b/crates/kernel/obop-auth/src/account.rs @@ -687,7 +687,15 @@ impl Auth { ))); } let _g = self.mfa_guard()?; - self.known_user(&target)?; + let user = self.known_user(&target)?; + // An account that signs in with Microsoft only (D91) has no password sign-in, so a + // temporary password would be a way in that the setting was meant to close. + if user["sso_only"].as_bool() == Some(true) { + return Err(MfaError::Invalid( + "the account signs in with Microsoft only: turn that off before setting a password" + .into(), + )); + } let hash = self .hash_password(temporary) .map_err(MfaError::Unavailable)?; diff --git a/crates/kernel/obop-auth/src/oidc/mod.rs b/crates/kernel/obop-auth/src/oidc/mod.rs index 7f8e9149..136e0938 100644 --- a/crates/kernel/obop-auth/src/oidc/mod.rs +++ b/crates/kernel/obop-auth/src/oidc/mod.rs @@ -54,12 +54,14 @@ const MAX_SECRET_CHARS: usize = 512; /// `OBOP_CONNECTORS_ALLOW_LOOPBACK` (and, like it, of no use without it: the outbound client /// refuses a loopback address unless that is set). Anything else is ignored with a line on stderr. pub(crate) fn authority_from_env() -> Option { - let v = std::env::var("OBOP_OIDC_TEST_AUTHORITY").ok()?; - let v = v.trim().trim_end_matches('/').to_string(); - if v.is_empty() { - return None; - } - if v == verify::AUTHORITY { + authority_from(std::env::var("OBOP_OIDC_TEST_AUTHORITY").ok().as_deref()) +} + +/// The checks of [`authority_from_env`] on the value, so a test can try them without the process +/// environment. +fn authority_from(value: Option<&str>) -> Option { + let v = value?.trim().trim_end_matches('/').to_string(); + if v.is_empty() || v == verify::AUTHORITY { return None; } if verify::authority_ok(&v) { diff --git a/crates/kernel/obop-auth/src/oidc/tests.rs b/crates/kernel/obop-auth/src/oidc/tests.rs index 8b137891..314101a9 100644 --- a/crates/kernel/obop-auth/src/oidc/tests.rs +++ b/crates/kernel/obop-auth/src/oidc/tests.rs @@ -1 +1,764 @@ +//! Sign-in with Microsoft at the level of the identity provider (D91): configuration, bindings, the +//! break-glass rule, the one-time code and what the session is for each kind of account. The token +//! checks themselves are in `verify_tests`; the browser round trip is in `obop-http` and the +//! tests of `crates/obop`. +use super::soft::StandIn; +use super::verify::AUTHORITY; +use super::{BREAK_GLASS_MIN, CODE_SECS, LINK_TABLE, PROVIDER_TABLE, code_key, link_key}; +use crate::mfa::EVENT_TABLE; +use crate::mfa_tests::{ + PW, Rig, T0, code_at, enforcing, enrol, mfa_token, rig, rig_with, session, user_with, +}; +use crate::passkey_tests::{ORIGIN, add, ctx, pw}; +use crate::webauthn::soft::SoftAuthenticator; +use crate::{AuthConfig, SESSION_SECS, bump_epoch, token_hash}; +use obop_commands::{ + ADMIN_GROUP, Authenticator, LoginOutcome, MfaApi, MfaError, PasskeyApi, Principal, SsoError, + SsoIdentity, +}; +use obop_storage::Fields; +use serde_json::{Value as J, json}; + +const TENANT: &str = "11111111-2222-3333-4444-555555555555"; +const OTHER_TENANT: &str = "99999999-2222-3333-4444-555555555555"; +const CLIENT: &str = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; +const ADA_OID: &str = "0f0f0f0f-1111-2222-3333-444444444444"; +const BOB_OID: &str = "0b0b0b0b-1111-2222-3333-444444444444"; +const NONCE: &str = "nonce-of-this-sign-in"; +const SECRET: &str = "client-secret-value-9f3a"; + +fn sso(r: &Rig) -> &dyn obop_commands::SsoApi { + r.a.sso().expect("the provider of the instance") +} + +fn admin(name: &str) -> Principal { + Principal::user(name, &[ADMIN_GROUP]) +} + +/// Three administrators with an authenticator app (the break-glass ones), `ada` bound to `ADA_OID` +/// and a provider that is configured and enabled. +fn world_with(config: AuthConfig) -> Rig { + let r = rig_with(config); + for n in ["root1", "root2", "root3"] { + user_with(&r.a, n, &[ADMIN_GROUP]); + enrol(&r.a, n); + } + user_with(&r.a, "ada", &["base.user"]); + user_with(&r.a, "bob", &["base.user"]); + r.a.operator_sso_configure(TENANT, CLIENT, Some(SECRET), true) + .unwrap(); + r.a.operator_sso_bind("ada", ADA_OID).unwrap(); + r +} + +fn world() -> Rig { + world_with(AuthConfig::insecure_dev()) +} + +/// What the verifier says about a token the stand-in signed for `oid`, with the claims edited. +fn identity_with(r: &Rig, oid: &str, edit: impl FnOnce(&mut J)) -> SsoIdentity { + let s = StandIn::new(); + let mut c = StandIn::claims(AUTHORITY, TENANT, CLIENT, NONCE, oid, r.a.now()); + edit(&mut c); + sso(r) + .verify(&s.token(&c), s.jwks().as_bytes(), NONCE) + .expect("a good token") +} + +fn identity(r: &Rig, oid: &str) -> SsoIdentity { + identity_with(r, oid, |_| {}) +} + +fn without_mfa(c: &mut J) { + c["amr"] = json!(["pwd"]); +} + +fn events(r: &Rig, kind: &str) -> Vec { + r.b.list(EVENT_TABLE) + .unwrap() + .into_iter() + .filter(|(_, f)| f["kind"] == kind) + .map(|(_, f)| f["detail"].as_str().unwrap_or("").to_string()) + .collect() +} + +fn session_of(out: LoginOutcome) -> (String, bool) { + match out { + LoginOutcome::Session { + token, + mfa_enrolment_required, + .. + } => (token, mfa_enrolment_required), + other => panic!("expected a session, got {other:?}"), + } +} + +fn invalid(r: Result) -> String { + match r { + Err(SsoError::Invalid(m)) => m, + other => panic!("expected a refusal of the input, got {other:?}"), + } +} + +fn sso_only(r: &Rig, user: &str, on: bool) -> Result { + sso(r).set_sso_only(&admin("root1"), user, on) +} + +#[test] +fn the_configuration_is_checked_sealed_and_never_shown() { + let r = rig(); + let root = admin("root"); + let cfg = |tenant: &str, client: &str, secret: Option<&str>| { + sso(&r).configure(&root, tenant, client, secret, true) + }; + // The tenant is a GUID; the multi-tenant authorities are not a tenant. + for t in [ + "common", + "organizations", + "consumers", + "contoso.onmicrosoft.com", + "", + ] { + invalid(cfg(t, CLIENT, Some(SECRET))); + } + assert!(invalid(cfg("common", CLIENT, Some(SECRET))).contains("multi-tenant")); + invalid(cfg(TENANT, "not-a-guid", Some(SECRET))); + // A secret is needed the first time, and it is a plain line of text. + assert!(invalid(cfg(TENANT, CLIENT, None)).contains("client secret")); + invalid(cfg(TENANT, CLIENT, Some(""))); + invalid(cfg(TENANT, CLIENT, Some("two\nlines"))); + invalid(cfg(TENANT, CLIENT, Some(&"x".repeat(513)))); + assert!(sso(&r).provider().unwrap().is_none() && !sso(&r).offered()); + + let v = cfg(&TENANT.to_uppercase(), &CLIENT.to_uppercase(), Some(SECRET)).unwrap(); + assert_eq!( + (v.tenant.as_str(), v.client_id.as_str(), v.enabled), + (TENANT, CLIENT, true) + ); + assert!(v.secret_set && sso(&r).offered()); + // Sealed at rest: the stored value is not the secret. + let stored = r.b.get(PROVIDER_TABLE, "provider").unwrap().unwrap(); + let sealed = stored["client_secret"].as_str().unwrap(); + assert!(!sealed.contains(SECRET) && sealed != SECRET); + // The leg opens it for the one call that needs it, and does not print it. + let leg = sso(&r).leg().unwrap().unwrap(); + assert_eq!(leg.client_secret, SECRET); + assert_eq!( + (leg.authority.as_str(), leg.tenant.as_str()), + (AUTHORITY, TENANT) + ); + assert!(!format!("{leg:?}").contains(SECRET)); + // No secret in the answer or in the log. + assert!(!format!("{:?}", sso(&r).provider().unwrap()).contains(SECRET)); + // A change without a new secret keeps the stored one; switched off it offers nothing. + cfg(TENANT, CLIENT, None).unwrap(); + assert_eq!(sso(&r).leg().unwrap().unwrap().client_secret, SECRET); + sso(&r) + .configure(&root, TENANT, CLIENT, None, false) + .unwrap(); + assert!(!sso(&r).offered() && sso(&r).leg().unwrap().is_none()); + for (_, f) in r.b.list(EVENT_TABLE).unwrap() { + assert!( + !J::Object(f).to_string().contains(SECRET), + "a secret in the log" + ); + } + assert_eq!(events(&r, "oidc_provider_configured").len(), 3); +} + +#[test] +fn only_a_whole_administrator_session_configures_and_binds() { + let r = world(); + let mut limited = admin("root1"); + limited.mfa_limited = true; + let agent = Principal::agent("root1", &[ADMIN_GROUP], &[ADMIN_GROUP]); + for p in [ + Principal::user("ada", &["base.user"]), + limited, + agent, + Principal::user("sales", &["sales.manager"]), + ] { + let s = sso(&r); + assert_eq!( + s.configure(&p, TENANT, CLIENT, None, true).unwrap_err(), + SsoError::NotFound + ); + assert_eq!(s.clear(&p).unwrap_err(), SsoError::NotFound); + assert_eq!(s.bind(&p, "bob", BOB_OID).unwrap_err(), SsoError::NotFound); + assert_eq!(s.unbind(&p, "ada").unwrap_err(), SsoError::NotFound); + assert_eq!( + s.set_sso_only(&p, "ada", true).unwrap_err(), + SsoError::NotFound + ); + assert_eq!(s.bindings(&p).unwrap_err(), SsoError::NotFound); + } + // An approved proposal of an agent runs as `root1/agent`, a person's name with a suffix. + assert!(sso(&r).bind(&admin("root1/agent"), "bob", BOB_OID).is_ok()); + assert_eq!(events(&r, "oidc_bind").len(), 2); +} + +#[test] +fn a_secret_cannot_be_sealed_without_the_server_key() { + let r = rig_with(AuthConfig { + keys: obop_keys::KeyRing::default(), + ..AuthConfig::insecure_dev() + }); + assert!(matches!( + sso(&r).configure(&admin("root"), TENANT, CLIENT, Some(SECRET), true), + Err(SsoError::Unavailable(m)) if m.contains("OBOP_SECRET_KEY") + )); + assert!(!sso(&r).offered()); +} + +#[test] +fn one_identity_one_account_and_the_binding_is_the_object_id() { + let r = rig(); + user_with(&r.a, "ada", &["base.user"]); + user_with(&r.a, "bob", &["base.user"]); + // Nothing binds before there is a provider. + assert!(invalid(sso(&r).bind(&admin("root"), "ada", ADA_OID)).contains("configure")); + r.a.operator_sso_configure(TENANT, CLIENT, Some(SECRET), true) + .unwrap(); + let a = admin("root"); + // Not an object id: a name, an address, nothing. + for bad in [ + "ada@example.test", + "Ada Lovelace", + "", + "0f0f0f0f-1111-2222-3333-44444444444", + ] { + assert!(invalid(sso(&r).bind(&a, "ada", bad)).contains("object id")); + } + invalid(sso(&r).bind(&a, "ghost", ADA_OID)); + let done = sso(&r).bind(&a, "Ada", &ADA_OID.to_uppercase()).unwrap(); + assert_eq!(done["oid"], ADA_OID); + // The record is keyed by the hash of issuer, tenant and object id, and holds no name. + let issuer = format!("{AUTHORITY}/{TENANT}/v2.0"); + let rec = + r.b.get(LINK_TABLE, &link_key(&issuer, TENANT, ADA_OID)) + .unwrap() + .unwrap(); + assert_eq!( + ( + rec["user"].as_str(), + rec["oid"].as_str(), + rec["tid"].as_str() + ), + (Some("ada"), Some(ADA_OID), Some(TENANT)) + ); + assert!( + !J::Object(rec) + .to_string() + .to_lowercase() + .contains("example.test") + ); + // One account, one identity; one identity, one account. + assert!(invalid(sso(&r).bind(&a, "ada", BOB_OID)).contains("bound")); + assert!(invalid(sso(&r).bind(&a, "bob", ADA_OID)).contains("another account")); + assert!(invalid(sso(&r).bind(&a, "bob", &ADA_OID.to_uppercase())).contains("another account")); + // A disabled account is not bound. + r.a.operator_set_disabled("bob", true).unwrap(); + assert!(invalid(sso(&r).bind(&a, "bob", BOB_OID)).contains("disabled")); + // The key depends on every part, and is stable. + let k = link_key(&issuer, TENANT, ADA_OID); + assert_eq!(k, link_key(&issuer, TENANT, ADA_OID)); + assert_ne!(k, link_key(&issuer, TENANT, BOB_OID)); + assert_ne!(k, link_key(&issuer, OTHER_TENANT, ADA_OID)); + assert_ne!(k, link_key("https://other.example", TENANT, ADA_OID)); + // Unbinding ends the way in; the repeat has nothing to do. + let id = identity(&r, ADA_OID); + sso(&r).unbind(&a, "ada").unwrap(); + assert!(invalid(sso(&r).unbind(&a, "ada")).contains("not bound")); + assert!(matches!( + sso(&r).begin(&id, "203.0.113.9"), + Err(SsoError::Refused(_)) + )); +} + +#[test] +fn the_tenant_cannot_change_under_bound_accounts() { + let r = world(); + let root = admin("root1"); + assert!(invalid(sso(&r).configure(&root, OTHER_TENANT, CLIENT, None, true)).contains("unbind")); + // The same tenant, a new client id and a new secret are fine. + let other_client = "bbbbbbbb-bbbb-cccc-dddd-eeeeeeeeeeee"; + sso(&r) + .configure(&root, TENANT, other_client, Some("another-secret"), true) + .unwrap(); + assert_eq!( + sso(&r).leg().unwrap().unwrap().client_secret, + "another-secret" + ); +} + +#[test] +fn a_bound_identity_gets_a_one_time_code_that_is_spent_once_and_expires() { + let r = world(); + let id = identity(&r, ADA_OID); + let code = sso(&r).begin(&id, "203.0.113.9").unwrap(); + assert!(code.starts_with("obopsso_") && code.len() > 40); + // Stored hashed, with an expiry; the code itself is not a key. + assert!(r.b.kv_get(&format!("auth.sso:{code}")).unwrap().is_none()); + assert!(r.b.kv_get(&code_key(&code)).unwrap().is_some()); + let (token, limited) = session_of(sso(&r).exchange(&code).unwrap()); + assert!(!limited); + assert_eq!(r.a.resolve(&token).unwrap().id, "ada"); + // The session is the one a password gives: bearer, the same lifetime. + let stored = r.b.get("auth_token", &token_hash(&token)).unwrap().unwrap(); + assert_eq!(stored["expires_at"], json!(T0 + SESSION_SECS)); + assert_eq!(stored["kind"], "user"); + // Spent. + assert!(matches!(sso(&r).exchange(&code), Err(MfaError::Expired))); + assert!(r.b.kv_get(&code_key(&code)).unwrap().is_none()); + // A code nobody issued, and one that waited too long. + assert!(matches!( + sso(&r).exchange("obopsso_made_up"), + Err(MfaError::Expired) + )); + let late = sso(&r).begin(&id, "203.0.113.9").unwrap(); + r.a.set_clock_for_test(T0 + CODE_SECS + 1); + assert!(matches!(sso(&r).exchange(&late), Err(MfaError::Expired))); + // Two sign-ins get two codes. + r.a.set_clock_for_test(T0); + assert_ne!( + sso(&r).begin(&id, "x").unwrap(), + sso(&r).begin(&id, "x").unwrap() + ); + assert_eq!(events(&r, "oidc_login").len(), 1); + // Neither the code nor the secret is in the log. + for (_, f) in r.b.list(EVENT_TABLE).unwrap() { + let line = J::Object(f).to_string(); + assert!(!line.contains(&code) && !line.contains(SECRET) && !line.contains("obopsso_")); + } +} + +#[test] +fn an_identity_nobody_is_bound_to_signs_in_as_nobody() { + let r = world(); + // Another object id in the right tenant, however well signed. + let stranger = identity(&r, BOB_OID); + assert!(matches!( + sso(&r).begin(&stranger, "203.0.113.9"), + Err(SsoError::Refused(_)) + )); + assert_eq!(events(&r, "oidc_refused").len(), 1); + assert!(events(&r, "oidc_refused")[0].contains(BOB_OID)); + // The same object id in another tenant or under another issuer is another identity. + let mut forged = identity(&r, ADA_OID); + forged.tenant = OTHER_TENANT.into(); + assert!(sso(&r).begin(&forged, "x").is_err()); + let mut forged = identity(&r, ADA_OID); + forged.issuer = format!("{AUTHORITY}/{OTHER_TENANT}/v2.0"); + assert!(sso(&r).begin(&forged, "x").is_err()); +} + +#[test] +fn the_account_and_the_provider_are_checked_again_when_the_code_is_spent() { + let r = world(); + let id = identity(&r, ADA_OID); + // Disabled before the code is issued. + r.a.operator_set_disabled("ada", true).unwrap(); + assert!(matches!(sso(&r).begin(&id, "x"), Err(SsoError::Refused(_)))); + r.a.operator_set_disabled("ada", false).unwrap(); + // Disabled between the callback and the exchange. + let code = sso(&r).begin(&id, "x").unwrap(); + r.a.operator_set_disabled("ada", true).unwrap(); + assert!(matches!(sso(&r).exchange(&code), Err(MfaError::Expired))); + r.a.operator_set_disabled("ada", false).unwrap(); + // Offboarded in between. + let code = sso(&r).begin(&id, "x").unwrap(); + r.a.operator_offboard("ada", "left").unwrap(); + assert!(matches!(sso(&r).exchange(&code), Err(MfaError::Expired))); + r.a.operator_set_disabled("ada", false).unwrap(); + // The credentials changed in between (a reset, new groups): the half-finished sign-in ends. + let code = sso(&r).begin(&id, "x").unwrap(); + let mut patch = Fields::new(); + bump_epoch(&mut patch); + r.b.update("auth_user", "ada", &patch).unwrap(); + assert!(matches!(sso(&r).exchange(&code), Err(MfaError::Expired))); + // Unbound in between. + let code = sso(&r).begin(&id, "x").unwrap(); + sso(&r).unbind(&admin("root1"), "ada").unwrap(); + assert!(matches!(sso(&r).exchange(&code), Err(MfaError::Expired))); + // The provider switched off between the verification and the code. + r.a.operator_sso_bind("ada", ADA_OID).unwrap(); + r.a.operator_sso_configure(TENANT, CLIENT, None, false) + .unwrap(); + assert!(matches!(sso(&r).begin(&id, "x"), Err(SsoError::Refused(_)))); + assert!(!events(&r, "oidc_refused").is_empty()); +} + +#[test] +fn an_account_with_its_own_second_factor_still_gets_asked_for_it() { + let r = world(); + let (secret, _) = enrol(&r.a, "ada"); + // Microsoft says mfa, but ada is not SSO-only: her obop second factor stays. + let code = sso(&r).begin(&identity(&r, ADA_OID), "x").unwrap(); + let LoginOutcome::MfaRequired { mfa_token: t } = sso(&r).exchange(&code).unwrap() else { + panic!("an obop second factor was expected") + }; + assert!(r.a.resolve(&t).is_none(), "an mfa token is not a session"); + r.a.set_clock_for_test(T0 + 60); + let (token, limited) = session_of( + r.a.login_mfa(&t, Some(&code_at(&secret, T0 + 60)), None) + .unwrap(), + ); + assert!(!limited && r.a.resolve(&token).unwrap().id == "ada"); + // And without `mfa` in amr nothing changes. + let id = identity_with(&r, ADA_OID, without_mfa); + assert!(!id.mfa()); + let code = sso(&r).begin(&id, "x").unwrap(); + assert!(matches!( + sso(&r).exchange(&code).unwrap(), + LoginOutcome::MfaRequired { .. } + )); +} + +#[test] +fn an_sso_only_account_takes_microsofts_second_factor_as_its_own() { + let r = world_with(enforcing()); + // An administrator who has no obop second factor: with the policy on, a password session + // would be limited to enrolment. + user_with(&r.a, "boss", &[ADMIN_GROUP]); + r.a.operator_sso_bind("boss", BOB_OID).unwrap(); + let (_, limited) = session_of(r.a.login_step("boss", PW).unwrap()); + assert!(limited, "the policy limits a password session of an admin"); + sso_only(&r, "boss", true).unwrap(); + // Microsoft reports a second factor: a whole session, no obop code. + let code = sso(&r).begin(&identity(&r, BOB_OID), "x").unwrap(); + let (token, limited) = session_of(sso(&r).exchange(&code).unwrap()); + assert!(!limited); + let p = r.a.resolve(&token).unwrap(); + assert!(p.allows(ADMIN_GROUP) && !p.mfa_limited); + // Microsoft does not report one: nothing stands for the second factor, so the session is + // limited to enrolment, exactly as for a password. + let code = sso(&r) + .begin(&identity_with(&r, BOB_OID, without_mfa), "x") + .unwrap(); + let (token, limited) = session_of(sso(&r).exchange(&code).unwrap()); + assert!(limited); + assert!(r.a.resolve(&token).unwrap().mfa_limited); + let line = events(&r, "oidc_login"); + assert_eq!(line.len(), 2); + assert!(line[0].contains("reports a second factor") && line[1].contains("does not report")); +} + +#[test] +fn an_sso_only_account_refuses_its_password_and_ends_its_sessions() { + let r = world(); + let (old, _) = session(&r.a, "ada"); + assert!(r.a.resolve(&old).is_some()); + let on = sso_only(&r, "ada", true).unwrap(); + assert_eq!(on["changed"], true); + assert_eq!(on["break_glass"], json!(["root1", "root2", "root3"])); + // Signed out at once. + assert!(r.a.resolve(&old).is_none()); + // The password, right or wrong, gets the answer of a wrong password. + let right = r.a.login_step("ada", PW).unwrap_err(); + let wrong = r.a.login_step("ada", "not the password").unwrap_err(); + let ghost = r.a.login_step("ghost", PW).unwrap_err(); + assert_eq!(right, wrong); + assert_eq!(right, ghost); + assert!(r.a.login("ada", PW).is_err()); + assert_eq!(events(&r, "login_refused").len(), 2); + assert!(events(&r, "login_refused")[0].contains("Microsoft only")); + // The repeat changes nothing; an SSO-only account is not unbound; turning it off gives the + // password back. + assert_eq!(sso_only(&r, "ada", true).unwrap()["changed"], false); + assert!(invalid(sso(&r).unbind(&admin("root1"), "ada")).contains("turn that off")); + let off = sso_only(&r, "ada", false).unwrap(); + assert_eq!(off["changed"], true); + assert!(r.a.login_step("ada", PW).is_ok()); + // No password can be set for it while it is SSO-only. + sso_only(&r, "ada", true).unwrap(); + assert!(matches!( + r.a.reset_user_password(&admin("root1"), "ada", "a temporary password"), + Err(MfaError::Invalid(m)) if m.contains("Microsoft only") + )); + assert!(r.a.login_step("ada", "a temporary password").is_err()); +} + +#[test] +fn an_sso_only_account_does_not_sign_in_with_a_passkey_alone() { + let r = world(); + let (token, p) = session(&r.a, "ada"); + let mut key = SoftAuthenticator::es256(); + add(&r, &token, &p, &mut key, "Phone").unwrap(); + let opts = r.a.proof_options(&p, &ctx()).unwrap(); + r.a.proof(&p, &ctx(), &key.get(&opts, ORIGIN)).unwrap(); + r.a.set_passwordless(&p, true, &pw()).unwrap(); + let alone = |key: &mut SoftAuthenticator| { + let opts = r.a.passkey_login_options(&ctx(), None).unwrap(); + r.a.passkey_login(&ctx(), None, &key.get(&opts, ORIGIN)) + }; + // Before: passwordless works. + assert!(matches!(alone(&mut key), Ok(LoginOutcome::Session { .. }))); + sso_only(&r, "ada", true).unwrap(); + match alone(&mut key) { + Err(MfaError::Denied(m)) => assert!(m.contains("could not be used")), + other => panic!("expected a refusal, got {other:?}"), + } + assert!( + events(&r, "login_refused") + .iter() + .any(|d| d.contains("passkey alone")) + ); + // Turned off, it works again. + sso_only(&r, "ada", false).unwrap(); + assert!(matches!(alone(&mut key), Ok(LoginOutcome::Session { .. }))); +} + +#[test] +fn an_account_is_made_sso_only_only_with_a_binding_a_provider_and_break_glass_administrators() { + // Two administrators, one with a second factor and one without. + let r = rig(); + for n in ["root1", "root2"] { + user_with(&r.a, n, &[ADMIN_GROUP]); + } + enrol(&r.a, "root1"); + user_with(&r.a, "ada", &["base.user"]); + r.a.operator_sso_configure(TENANT, CLIENT, Some(SECRET), true) + .unwrap(); + // Not bound. + assert!(invalid(sso_only(&r, "ada", true)).contains("not bound")); + r.a.operator_sso_bind("ada", ADA_OID).unwrap(); + // One administrator with a second factor is not enough; the one without does not count. + let m = invalid(sso_only(&r, "ada", true)); + assert!(m.contains(&format!("at least {BREAK_GLASS_MIN}")) && m.contains("there are 1 now")); + assert!(m.contains("root1") && !m.contains("root2")); + // A disabled administrator does not count, whatever second factor it holds. + user_with(&r.a, "root3", &[ADMIN_GROUP]); + enrol(&r.a, "root3"); + r.a.operator_set_disabled("root3", true).unwrap(); + assert!(invalid(sso_only(&r, "ada", true)).contains("there are 1 now")); + r.a.operator_set_disabled("root3", false).unwrap(); + // An administrator with a passkey and no app counts. + let (token, p) = session(&r.a, "root2"); + let mut key = SoftAuthenticator::es256(); + add(&r, &token, &p, &mut key, "Key").unwrap(); + assert_eq!(sso_only(&r, "ada", true).unwrap()["changed"], true); + let status = sso(&r).bindings(&admin("root1")).unwrap(); + assert_eq!(status["break_glass"], json!(["root1", "root2", "root3"])); + assert_eq!(status["break_glass_enough"], true); + // An administrator who is SSO-only does not count as one that signs in locally: after root1 + // only root2 and root3 remain, which is enough; after root2 only root3 would. + r.a.operator_sso_bind("root1", BOB_OID).unwrap(); + assert_eq!(sso_only(&r, "root1", true).unwrap()["changed"], true); + r.a.operator_sso_bind("root2", "0c0c0c0c-1111-2222-3333-444444444444") + .unwrap(); + let m = invalid(sso_only(&r, "root2", true)); + assert!(m.contains("there are 1 now") && m.contains("root3"), "{m}"); + // Provider off: nothing can be made SSO-only. + r.a.operator_sso_configure(TENANT, CLIENT, None, false) + .unwrap(); + user_with(&r.a, "bob", &["base.user"]); + assert!(invalid(sso_only(&r, "bob", true)).contains("not enabled")); + // The status shows who is bound and who signs in with Microsoft only. + let status = sso(&r).bindings(&admin("root1")).unwrap(); + let row = |n: &str| { + status["accounts"] + .as_array() + .unwrap() + .iter() + .find(|a| a["name"] == n) + .cloned() + .unwrap() + }; + assert_eq!(row("ada")["sso_only"], true); + assert_eq!(row("ada")["oid"], ADA_OID); + assert_eq!(row("bob")["bound"], false); + assert_eq!(status["break_glass"], json!(["root2", "root3"])); + assert_eq!(status["break_glass_enough"], true); +} + +#[test] +fn a_disabled_account_is_not_made_sso_only() { + let r = world(); + r.a.operator_set_disabled("ada", true).unwrap(); + assert!(invalid(sso_only(&r, "ada", true)).contains("disabled")); + invalid(sso_only(&r, "ghost", true)); +} + +#[test] +fn clearing_the_provider_keeps_the_bindings_and_says_who_depends_on_it() { + let r = world(); + sso_only(&r, "ada", true).unwrap(); + let id = identity(&r, ADA_OID); + sso(&r).clear(&admin("root1")).unwrap(); + assert!(!sso(&r).offered() && sso(&r).provider().unwrap().is_none()); + assert!(sso(&r).leg().unwrap().is_none()); + assert!(matches!(sso(&r).begin(&id, "x"), Err(SsoError::Refused(_)))); + let line = events(&r, "oidc_provider_cleared"); + assert_eq!(line.len(), 1); + assert!(line[0].contains("1 SSO-only"), "{}", line[0]); + // The binding stays for the day the provider is configured again. + let status = sso(&r).bindings(&admin("root1")).unwrap(); + assert!(status["provider"].is_null()); + assert!( + status["accounts"] + .as_array() + .unwrap() + .iter() + .any(|a| a["name"] == "ada" && a["bound"] == true) + ); + assert!(invalid(sso(&r).clear(&admin("root1"))).contains("no provider")); +} + +#[test] +fn the_provider_port_checks_the_token_against_this_instance() { + let r = world(); + let s = StandIn::new(); + let now = r.a.now(); + let good = StandIn::claims(AUTHORITY, TENANT, CLIENT, NONCE, ADA_OID, now); + let jwks = s.jwks(); + let verify = |c: &J, nonce: &str| sso(&r).verify(&s.token(c), jwks.as_bytes(), nonce); + assert!(verify(&good, NONCE).is_ok()); + assert!(matches!( + verify(&good, "another nonce"), + Err(SsoError::Refused(_)) + )); + let mut c = good.clone(); + c["aud"] = J::from("11111111-0000-0000-0000-000000000000"); + assert!(matches!(verify(&c, NONCE), Err(SsoError::Refused(_)))); + let mut c = good.clone(); + c["tid"] = J::from(OTHER_TENANT); + c["iss"] = J::from(format!("{AUTHORITY}/{OTHER_TENANT}/v2.0")); + assert!(matches!(verify(&c, NONCE), Err(SsoError::Refused(_)))); + // A key the cached set does not hold asks for the set to be fetched again. + let unknown = s.sign(&json!({"alg": "RS256", "kid": "rotated"}), &good); + assert_eq!( + sso(&r).verify(&unknown, jwks.as_bytes(), NONCE), + Err(SsoError::UnknownKey) + ); + // A key set that is not one is the provider's trouble, not a refusal of the person. + assert!(matches!( + sso(&r).verify(&s.token(&good), b"", NONCE), + Err(SsoError::Unavailable(_)) + )); + // The discovery document of the pinned tenant, and nobody else's. + let ok = sso(&r) + .endpoints(s.discovery(AUTHORITY, TENANT).as_bytes()) + .unwrap(); + assert!(ok.token.starts_with(&format!("{AUTHORITY}/{TENANT}/"))); + assert!(matches!( + sso(&r).endpoints(s.discovery(AUTHORITY, OTHER_TENANT).as_bytes()), + Err(SsoError::Refused(_)) + )); +} + +#[test] +fn the_stand_in_authority_is_loopback_over_http_or_nothing() { + use super::authority_from; + assert_eq!(authority_from(None), None); + assert_eq!(authority_from(Some("")), None); + assert_eq!(authority_from(Some(AUTHORITY)), None); + assert_eq!( + authority_from(Some(" http://127.0.0.1:4455/ ")).as_deref(), + Some("http://127.0.0.1:4455") + ); + for bad in [ + "https://login.microsoftonline.com.evil.test", + "http://evil.test:80", + "http://localhost:4455", + "https://127.0.0.1:4455", + "http://127.0.0.1:4455/tenant", + ] { + assert_eq!(authority_from(Some(bad)), None, "{bad}"); + } + // The authority a test sets is the one the tokens are checked under. + let r = world(); + r.a.set_oidc_authority_for_test("http://127.0.0.1:4455"); + assert_eq!(r.a.oidc_authority(), "http://127.0.0.1:4455"); + let s = StandIn::new(); + let c = StandIn::claims( + "http://127.0.0.1:4455", + TENANT, + CLIENT, + NONCE, + ADA_OID, + r.a.now(), + ); + assert!( + sso(&r) + .verify(&s.token(&c), s.jwks().as_bytes(), NONCE) + .is_ok() + ); + // A token of the real authority is then not this instance's. + let c = StandIn::claims(AUTHORITY, TENANT, CLIENT, NONCE, ADA_OID, r.a.now()); + assert!( + sso(&r) + .verify(&s.token(&c), s.jwks().as_bytes(), NONCE) + .is_err() + ); +} + +#[test] +fn only_the_sign_in_kinds_can_be_written_to_the_log_through_the_port() { + let r = world(); + let binds = events(&r, "oidc_bind").len(); // the one of the setup + sso(&r).audit("ada", "oidc_start", "begun"); + sso(&r).audit( + "ada", + "oidc_cancelled", + "the person said no at the provider", + ); + sso(&r).audit("ada", "login_ok", "pretending to be a password sign-in"); + sso(&r).audit("ada", "oidc_bind", "pretending to be an administrator"); + assert_eq!(events(&r, "oidc_start").len(), 1); + assert_eq!(events(&r, "oidc_cancelled").len(), 1); + assert!(events(&r, "login_ok").is_empty()); + assert_eq!(events(&r, "oidc_bind").len(), binds); + assert_eq!(events(&r, "oidc_error").len(), 2); +} + +#[test] +fn the_password_sign_in_of_an_ordinary_account_is_unchanged() { + // A guard for the change to the password step: an account with a second factor still gets + // the two-step sign-in, and one without still gets a session. + let r = world(); + let (secret, _) = enrol(&r.a, "bob"); + let t = mfa_token(&r.a, "bob"); + r.a.set_clock_for_test(T0 + 60); + let (token, limited) = session_of( + r.a.login_mfa(&t, Some(&code_at(&secret, T0 + 60)), None) + .unwrap(), + ); + assert!(!limited && r.a.resolve(&token).is_some()); + let (token, limited) = session_of(r.a.login_step("ada", PW).unwrap()); + assert!(!limited && r.a.resolve(&token).is_some()); +} + +#[test] +fn the_client_secret_survives_a_key_rotation() { + // The secret is listed in the rotation inventory under the purpose it is sealed with, and + // `secrets rotate` re-seals it so that the new key alone opens it. + use obop_keys::reseal::{FIELDS, Options, Purpose, run}; + assert!(FIELDS.iter().any(|f| f.model == PROVIDER_TABLE + && f.field == "client_secret" + && matches!(f.purpose, Purpose::Fixed(p) if p == super::SECRET_PURPOSE))); + let old = "old-master-key-for-the-test-only!!"; + let new = "new-master-key-for-the-test-only!!"; + let r = rig_with(AuthConfig { + keys: obop_keys::KeyRing::from_secrets(old, &[]).unwrap(), + ..AuthConfig::insecure_dev() + }); + r.a.operator_sso_configure(TENANT, CLIENT, Some(SECRET), true) + .unwrap(); + let ring = obop_keys::KeyRing::from_secrets(new, &[old]).unwrap(); + let report = run(&r.b, &ring, Options::default(), &mut |_| {}).unwrap(); + assert!(report.to_reseal >= 1, "{report:?}"); + // Only the new key is left. + let only_new = obop_keys::KeyRing::from_secrets(new, &[]).unwrap(); + let a = crate::Auth::with_test_config( + r.b.clone(), + AuthConfig { + keys: only_new, + ..AuthConfig::insecure_dev() + }, + ); + assert_eq!( + a.sso().unwrap().leg().unwrap().unwrap().client_secret, + SECRET + ); +} diff --git a/crates/kernel/obop-auth/src/passkey_tests.rs b/crates/kernel/obop-auth/src/passkey_tests.rs index 7aaafe70..a1efc78e 100644 --- a/crates/kernel/obop-auth/src/passkey_tests.rs +++ b/crates/kernel/obop-auth/src/passkey_tests.rs @@ -8,9 +8,9 @@ use crate::webauthn::soft::SoftAuthenticator; use crate::webauthn::{ALG_EDDSA, ALG_ES256, ALG_RS256, b64url, cbor}; use obop_commands::{ADMIN_GROUP, PasskeyApi, Reauth, WebauthnCtx}; -const ORIGIN: &str = "https://obop.test"; +pub(crate) const ORIGIN: &str = "https://obop.test"; -fn ctx() -> WebauthnCtx { +pub(crate) fn ctx() -> WebauthnCtx { WebauthnCtx { rp_id: "obop.test".into(), origin: ORIGIN.into(), @@ -19,7 +19,7 @@ fn ctx() -> WebauthnCtx { } } -fn pw() -> Reauth<'static> { +pub(crate) fn pw() -> Reauth<'static> { Reauth { password: PW, ..Default::default() @@ -27,7 +27,7 @@ fn pw() -> Reauth<'static> { } /// Add `auth` as a passkey of the signed-in person. -fn add( +pub(crate) fn add( r: &Rig, token: &str, p: &Principal, diff --git a/crates/kernel/obop-keys/src/reseal.rs b/crates/kernel/obop-keys/src/reseal.rs index a1b464e9..b7b70a9d 100644 --- a/crates/kernel/obop-keys/src/reseal.rs +++ b/crates/kernel/obop-keys/src/reseal.rs @@ -2,8 +2,8 @@ //! current key does not already protect. //! //! The inventory below is the one list of where the server keeps sealed material: second-factor -//! secrets, connector credentials and OAuth tokens, the stored pseudonym keys, the tokens of the -//! mask vaults, the payloads of queued deliveries, and the short-lived sealed values (approval +//! secrets, connector credentials and OAuth tokens, the client secret of the Microsoft sign-in, the +//! stored pseudonym keys, the tokens of the mask vaults, the payloads of queued deliveries, and the short-lived sealed values (approval //! plans, REST vaults, MCP sessions). A test in `obop-ops` checks it against the code that seals. //! //! The run is in two passes. The first only reads: it opens every value with the key ring and @@ -98,6 +98,12 @@ pub const FIELDS: &[SealedField] = &[ field: "token", purpose: Purpose::Fixed("update-token"), }, + // The client secret of the Microsoft sign-in (D91). + SealedField { + model: "auth_sso_provider", + field: "client_secret", + purpose: Purpose::Fixed("sso-client-secret"), + }, ]; /// Short-lived values whose purpose is their own key. diff --git a/crates/kernel/obop-ops/src/secrets/tests.rs b/crates/kernel/obop-ops/src/secrets/tests.rs index 8ded6d1f..440c93fa 100644 --- a/crates/kernel/obop-ops/src/secrets/tests.rs +++ b/crates/kernel/obop-ops/src/secrets/tests.rs @@ -261,6 +261,7 @@ fn the_inventory_names_every_place_the_server_seals_something() { ("connector_oauth_state", "verifier"), ("connector_job", "body"), ("connector_dead", "body"), + ("auth_sso_provider", "client_secret"), ] { assert!( listed.contains(&want), From 4474e8c97df87a5290153272c3992699607a1109 Mon Sep 17 00:00:00 2001 From: Mikael Rinne <40919111+rorychatt@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:46:06 +0100 Subject: [PATCH 03/23] Add the Microsoft sign-in routes, the account commands and the admin CLI (D91) obop-http: GET /api/auth/sso, /oidc/start, /oidc/callback and POST /oidc/exchange. The flow's state, nonce and the hash of the PKCE verifier live in the shared store for ten minutes and are spent once; the verifier is the value of an HttpOnly cookie of the browser that began. The server calls the provider's discovery document, key set and token endpoint through the hardened egress client, verifies the id token through the SsoApi port and drops it. A failure is one redirect with a fixed word; the reason goes to the security log. The one-time code is bound to a second HttpOnly cookie, so it is worth nothing to another browser (login CSRF). obop-pkg: the sso_provider model (configure and clear are refused for agents and not offered on MCP) and bind_sso, unbind_sso, set_sso_only on user_account (an agent can propose, a person decides). obop: obop admin sso status|configure|clear|bind|unbind|only, with the client secret read from stdin. Nine HTTP tests against a stand-in provider on loopback. Co-Authored-By: Claude Sonnet 5.5 --- crates/kernel/obop-auth/src/account.rs | 16 + crates/kernel/obop-auth/src/oidc/mod.rs | 31 +- crates/kernel/obop-auth/src/oidc/tests.rs | 128 ++- crates/kernel/obop-commands/src/sso.rs | 12 +- crates/kernel/obop-http/src/lib.rs | 11 + crates/kernel/obop-http/src/oauth.rs | 10 +- crates/kernel/obop-http/src/sso.rs | 650 +++++++++++ crates/kernel/obop-pkg/src/accounts.rs | 79 +- crates/kernel/obop-pkg/src/installer.rs | 1 + crates/kernel/obop-pkg/src/lib.rs | 1 + crates/kernel/obop-pkg/src/sso.rs | 168 +++ crates/obop/src/accounts_cmd.rs | 123 +++ crates/obop/src/main.rs | 29 + crates/obop/src/sso_tests.rs | 1188 +++++++++++++++++++++ 14 files changed, 2400 insertions(+), 47 deletions(-) create mode 100644 crates/kernel/obop-http/src/sso.rs create mode 100644 crates/kernel/obop-pkg/src/sso.rs create mode 100644 crates/obop/src/sso_tests.rs diff --git a/crates/kernel/obop-auth/src/account.rs b/crates/kernel/obop-auth/src/account.rs index b49f62f0..143bea32 100644 --- a/crates/kernel/obop-auth/src/account.rs +++ b/crates/kernel/obop-auth/src/account.rs @@ -540,6 +540,20 @@ impl Auth { *passkeys.entry(o.to_string()).or_default() += 1; } } + // Who is bound to a Microsoft identity (D91): the object id, never a name or an address. + let mut bound: std::collections::HashMap = Default::default(); + for (_, l) in self + .backend + .list(crate::oidc::LINK_TABLE) + .unwrap_or_default() + { + if let (Some(u), Some(o)) = ( + l.get("user").and_then(J::as_str), + l.get("oid").and_then(J::as_str), + ) { + bound.insert(u.to_string(), o.to_string()); + } + } let mut users: Vec = self .backend .list("auth_user") @@ -547,6 +561,8 @@ impl Auth { .into_iter() .map(|(name, u)| { json!({ + "sso_only": u.get("sso_only").and_then(J::as_bool) == Some(true), + "sso_oid": bound.get(&name), "name": name, "groups": str_list(&u["groups"]), "admin": str_list(&u["groups"]).iter().any(|g| g == ADMIN_GROUP), diff --git a/crates/kernel/obop-auth/src/oidc/mod.rs b/crates/kernel/obop-auth/src/oidc/mod.rs index 136e0938..8b2db368 100644 --- a/crates/kernel/obop-auth/src/oidc/mod.rs +++ b/crates/kernel/obop-auth/src/oidc/mod.rs @@ -92,6 +92,13 @@ pub fn link_key(issuer: &str, tenant: &str, oid: &str) -> String { )) } +/// The shortest browser secret `begin` accepts (the cookie is 43 characters). +const MIN_BINDER: usize = 16; + +fn binder_hash(binder: &str) -> String { + hex(&sha256(format!("obop-sso-binder\n{binder}").as_bytes())) +} + fn code_key(code: &str) -> String { format!("auth.sso:{}", hex(&sha256(code.as_bytes()))) } @@ -686,8 +693,10 @@ impl SsoApi for Auth { }) } - fn begin(&self, identity: &SsoIdentity, ip: &str) -> Result { - let _ = ip; + fn begin(&self, identity: &SsoIdentity, binder: &str) -> Result { + if binder.len() < MIN_BINDER { + return Err(invalid("the browser secret of the sign-in is missing")); + } let refuse = |user: &str, why: &str| -> SsoError { self.event(user, "oidc_refused", "-", why); SsoError::Refused(why.to_string()) @@ -731,6 +740,7 @@ impl SsoApi for Auth { "user": user, "epoch": epoch_of(&u), "mfa": identity.mfa(), + "binder": binder_hash(binder), "expires_at": self.now() + CODE_SECS, }); self.backend @@ -739,9 +749,24 @@ impl SsoApi for Auth { Ok(code) } - fn exchange(&self, code: &str) -> Result { + fn exchange(&self, code: &str, binder: &str) -> Result { let rec = self.code_take(code).ok_or(MfaError::Expired)?; let name = rec["user"].as_str().ok_or(MfaError::Expired)?.to_string(); + // The code is spent. Presented by another browser than the one that signed in, it is + // worth nothing: a link that carries a code to someone else signs that person in as the + // one who made the link. + let held = rec["binder"].as_str().unwrap_or(""); + if binder.len() < MIN_BINDER + || !crate::totp::ct_eq(held.as_bytes(), binder_hash(binder).as_bytes()) + { + self.event( + &name, + "oidc_refused", + "-", + "the one-time code was presented by another browser than the one that signed in", + ); + return Err(MfaError::Expired); + } let pending: Epoch = rec["epoch"].as_str().map(String::from); let mfa = rec["mfa"].as_bool() == Some(true); // The account as it is now: disabled, removed or changed since the callback is no sign-in. diff --git a/crates/kernel/obop-auth/src/oidc/tests.rs b/crates/kernel/obop-auth/src/oidc/tests.rs index 314101a9..34b65e5b 100644 --- a/crates/kernel/obop-auth/src/oidc/tests.rs +++ b/crates/kernel/obop-auth/src/oidc/tests.rs @@ -26,6 +26,7 @@ const CLIENT: &str = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; const ADA_OID: &str = "0f0f0f0f-1111-2222-3333-444444444444"; const BOB_OID: &str = "0b0b0b0b-1111-2222-3333-444444444444"; const NONCE: &str = "nonce-of-this-sign-in"; +const BINDER: &str = "browser-secret-of-this-sign-in-0123456789"; const SECRET: &str = "client-secret-value-9f3a"; fn sso(r: &Rig) -> &dyn obop_commands::SsoApi { @@ -271,7 +272,7 @@ fn one_identity_one_account_and_the_binding_is_the_object_id() { sso(&r).unbind(&a, "ada").unwrap(); assert!(invalid(sso(&r).unbind(&a, "ada")).contains("not bound")); assert!(matches!( - sso(&r).begin(&id, "203.0.113.9"), + sso(&r).begin(&id, BINDER), Err(SsoError::Refused(_)) )); } @@ -296,12 +297,12 @@ fn the_tenant_cannot_change_under_bound_accounts() { fn a_bound_identity_gets_a_one_time_code_that_is_spent_once_and_expires() { let r = world(); let id = identity(&r, ADA_OID); - let code = sso(&r).begin(&id, "203.0.113.9").unwrap(); + let code = sso(&r).begin(&id, BINDER).unwrap(); assert!(code.starts_with("obopsso_") && code.len() > 40); // Stored hashed, with an expiry; the code itself is not a key. assert!(r.b.kv_get(&format!("auth.sso:{code}")).unwrap().is_none()); assert!(r.b.kv_get(&code_key(&code)).unwrap().is_some()); - let (token, limited) = session_of(sso(&r).exchange(&code).unwrap()); + let (token, limited) = session_of(sso(&r).exchange(&code, BINDER).unwrap()); assert!(!limited); assert_eq!(r.a.resolve(&token).unwrap().id, "ada"); // The session is the one a password gives: bearer, the same lifetime. @@ -309,21 +310,27 @@ fn a_bound_identity_gets_a_one_time_code_that_is_spent_once_and_expires() { assert_eq!(stored["expires_at"], json!(T0 + SESSION_SECS)); assert_eq!(stored["kind"], "user"); // Spent. - assert!(matches!(sso(&r).exchange(&code), Err(MfaError::Expired))); + assert!(matches!( + sso(&r).exchange(&code, BINDER), + Err(MfaError::Expired) + )); assert!(r.b.kv_get(&code_key(&code)).unwrap().is_none()); // A code nobody issued, and one that waited too long. assert!(matches!( - sso(&r).exchange("obopsso_made_up"), + sso(&r).exchange("obopsso_made_up", BINDER), Err(MfaError::Expired) )); - let late = sso(&r).begin(&id, "203.0.113.9").unwrap(); + let late = sso(&r).begin(&id, BINDER).unwrap(); r.a.set_clock_for_test(T0 + CODE_SECS + 1); - assert!(matches!(sso(&r).exchange(&late), Err(MfaError::Expired))); + assert!(matches!( + sso(&r).exchange(&late, BINDER), + Err(MfaError::Expired) + )); // Two sign-ins get two codes. r.a.set_clock_for_test(T0); assert_ne!( - sso(&r).begin(&id, "x").unwrap(), - sso(&r).begin(&id, "x").unwrap() + sso(&r).begin(&id, BINDER).unwrap(), + sso(&r).begin(&id, BINDER).unwrap() ); assert_eq!(events(&r, "oidc_login").len(), 1); // Neither the code nor the secret is in the log. @@ -333,13 +340,50 @@ fn a_bound_identity_gets_a_one_time_code_that_is_spent_once_and_expires() { } } +#[test] +fn a_code_is_worth_nothing_to_another_browser_than_the_one_that_signed_in() { + // Login CSRF: someone signs in at Microsoft in their own browser, gets the one-time code and + // puts it in a link for a victim. The victim's browser does not hold the browser secret. + let r = world(); + let id = identity(&r, ADA_OID); + let code = sso(&r).begin(&id, BINDER).unwrap(); + assert!(matches!( + sso(&r).exchange(&code, "another-browsers-secret-0123456789"), + Err(MfaError::Expired) + )); + assert!(matches!( + sso(&r).exchange(&code, ""), + Err(MfaError::Expired) + )); + // The code was spent by the attempt: the right browser cannot use it afterwards either. + assert!(matches!( + sso(&r).exchange(&code, BINDER), + Err(MfaError::Expired) + )); + assert!( + events(&r, "oidc_refused") + .iter() + .any(|d| d.contains("another browser")) + ); + // Only its hash is kept with the code. + let code = sso(&r).begin(&id, BINDER).unwrap(); + let stored = r.b.kv_get(&code_key(&code)).unwrap().unwrap(); + assert!(!stored.contains(BINDER)); + // No browser secret, no code. + assert!(matches!(sso(&r).begin(&id, ""), Err(SsoError::Invalid(_)))); + assert!(matches!( + sso(&r).begin(&id, "short"), + Err(SsoError::Invalid(_)) + )); +} + #[test] fn an_identity_nobody_is_bound_to_signs_in_as_nobody() { let r = world(); // Another object id in the right tenant, however well signed. let stranger = identity(&r, BOB_OID); assert!(matches!( - sso(&r).begin(&stranger, "203.0.113.9"), + sso(&r).begin(&stranger, BINDER), Err(SsoError::Refused(_)) )); assert_eq!(events(&r, "oidc_refused").len(), 1); @@ -347,10 +391,10 @@ fn an_identity_nobody_is_bound_to_signs_in_as_nobody() { // The same object id in another tenant or under another issuer is another identity. let mut forged = identity(&r, ADA_OID); forged.tenant = OTHER_TENANT.into(); - assert!(sso(&r).begin(&forged, "x").is_err()); + assert!(sso(&r).begin(&forged, BINDER).is_err()); let mut forged = identity(&r, ADA_OID); forged.issuer = format!("{AUTHORITY}/{OTHER_TENANT}/v2.0"); - assert!(sso(&r).begin(&forged, "x").is_err()); + assert!(sso(&r).begin(&forged, BINDER).is_err()); } #[test] @@ -359,33 +403,51 @@ fn the_account_and_the_provider_are_checked_again_when_the_code_is_spent() { let id = identity(&r, ADA_OID); // Disabled before the code is issued. r.a.operator_set_disabled("ada", true).unwrap(); - assert!(matches!(sso(&r).begin(&id, "x"), Err(SsoError::Refused(_)))); + assert!(matches!( + sso(&r).begin(&id, BINDER), + Err(SsoError::Refused(_)) + )); r.a.operator_set_disabled("ada", false).unwrap(); // Disabled between the callback and the exchange. - let code = sso(&r).begin(&id, "x").unwrap(); + let code = sso(&r).begin(&id, BINDER).unwrap(); r.a.operator_set_disabled("ada", true).unwrap(); - assert!(matches!(sso(&r).exchange(&code), Err(MfaError::Expired))); + assert!(matches!( + sso(&r).exchange(&code, BINDER), + Err(MfaError::Expired) + )); r.a.operator_set_disabled("ada", false).unwrap(); // Offboarded in between. - let code = sso(&r).begin(&id, "x").unwrap(); + let code = sso(&r).begin(&id, BINDER).unwrap(); r.a.operator_offboard("ada", "left").unwrap(); - assert!(matches!(sso(&r).exchange(&code), Err(MfaError::Expired))); + assert!(matches!( + sso(&r).exchange(&code, BINDER), + Err(MfaError::Expired) + )); r.a.operator_set_disabled("ada", false).unwrap(); // The credentials changed in between (a reset, new groups): the half-finished sign-in ends. - let code = sso(&r).begin(&id, "x").unwrap(); + let code = sso(&r).begin(&id, BINDER).unwrap(); let mut patch = Fields::new(); bump_epoch(&mut patch); r.b.update("auth_user", "ada", &patch).unwrap(); - assert!(matches!(sso(&r).exchange(&code), Err(MfaError::Expired))); + assert!(matches!( + sso(&r).exchange(&code, BINDER), + Err(MfaError::Expired) + )); // Unbound in between. - let code = sso(&r).begin(&id, "x").unwrap(); + let code = sso(&r).begin(&id, BINDER).unwrap(); sso(&r).unbind(&admin("root1"), "ada").unwrap(); - assert!(matches!(sso(&r).exchange(&code), Err(MfaError::Expired))); + assert!(matches!( + sso(&r).exchange(&code, BINDER), + Err(MfaError::Expired) + )); // The provider switched off between the verification and the code. r.a.operator_sso_bind("ada", ADA_OID).unwrap(); r.a.operator_sso_configure(TENANT, CLIENT, None, false) .unwrap(); - assert!(matches!(sso(&r).begin(&id, "x"), Err(SsoError::Refused(_)))); + assert!(matches!( + sso(&r).begin(&id, BINDER), + Err(SsoError::Refused(_)) + )); assert!(!events(&r, "oidc_refused").is_empty()); } @@ -394,8 +456,9 @@ fn an_account_with_its_own_second_factor_still_gets_asked_for_it() { let r = world(); let (secret, _) = enrol(&r.a, "ada"); // Microsoft says mfa, but ada is not SSO-only: her obop second factor stays. - let code = sso(&r).begin(&identity(&r, ADA_OID), "x").unwrap(); - let LoginOutcome::MfaRequired { mfa_token: t } = sso(&r).exchange(&code).unwrap() else { + let code = sso(&r).begin(&identity(&r, ADA_OID), BINDER).unwrap(); + let LoginOutcome::MfaRequired { mfa_token: t } = sso(&r).exchange(&code, BINDER).unwrap() + else { panic!("an obop second factor was expected") }; assert!(r.a.resolve(&t).is_none(), "an mfa token is not a session"); @@ -408,9 +471,9 @@ fn an_account_with_its_own_second_factor_still_gets_asked_for_it() { // And without `mfa` in amr nothing changes. let id = identity_with(&r, ADA_OID, without_mfa); assert!(!id.mfa()); - let code = sso(&r).begin(&id, "x").unwrap(); + let code = sso(&r).begin(&id, BINDER).unwrap(); assert!(matches!( - sso(&r).exchange(&code).unwrap(), + sso(&r).exchange(&code, BINDER).unwrap(), LoginOutcome::MfaRequired { .. } )); } @@ -426,17 +489,17 @@ fn an_sso_only_account_takes_microsofts_second_factor_as_its_own() { assert!(limited, "the policy limits a password session of an admin"); sso_only(&r, "boss", true).unwrap(); // Microsoft reports a second factor: a whole session, no obop code. - let code = sso(&r).begin(&identity(&r, BOB_OID), "x").unwrap(); - let (token, limited) = session_of(sso(&r).exchange(&code).unwrap()); + let code = sso(&r).begin(&identity(&r, BOB_OID), BINDER).unwrap(); + let (token, limited) = session_of(sso(&r).exchange(&code, BINDER).unwrap()); assert!(!limited); let p = r.a.resolve(&token).unwrap(); assert!(p.allows(ADMIN_GROUP) && !p.mfa_limited); // Microsoft does not report one: nothing stands for the second factor, so the session is // limited to enrolment, exactly as for a password. let code = sso(&r) - .begin(&identity_with(&r, BOB_OID, without_mfa), "x") + .begin(&identity_with(&r, BOB_OID, without_mfa), BINDER) .unwrap(); - let (token, limited) = session_of(sso(&r).exchange(&code).unwrap()); + let (token, limited) = session_of(sso(&r).exchange(&code, BINDER).unwrap()); assert!(limited); assert!(r.a.resolve(&token).unwrap().mfa_limited); let line = events(&r, "oidc_login"); @@ -588,7 +651,10 @@ fn clearing_the_provider_keeps_the_bindings_and_says_who_depends_on_it() { sso(&r).clear(&admin("root1")).unwrap(); assert!(!sso(&r).offered() && sso(&r).provider().unwrap().is_none()); assert!(sso(&r).leg().unwrap().is_none()); - assert!(matches!(sso(&r).begin(&id, "x"), Err(SsoError::Refused(_)))); + assert!(matches!( + sso(&r).begin(&id, BINDER), + Err(SsoError::Refused(_)) + )); let line = events(&r, "oidc_provider_cleared"); assert_eq!(line.len(), 1); assert!(line[0].contains("1 SSO-only"), "{}", line[0]); diff --git a/crates/kernel/obop-commands/src/sso.rs b/crates/kernel/obop-commands/src/sso.rs index 7940ccb2..cca6977b 100644 --- a/crates/kernel/obop-commands/src/sso.rs +++ b/crates/kernel/obop-commands/src/sso.rs @@ -119,12 +119,16 @@ pub trait SsoApi: Send + Sync { /// A verified identity asks to sign in: find the account it is bound to and, if that account /// may sign in, return a one-time code (single use, 60 seconds, stored hashed) for - /// `exchange`. Every refusal is `Refused`; the log says which. `ip` is for the log only. - fn begin(&self, identity: &SsoIdentity, ip: &str) -> Result; + /// `exchange`. Every refusal is `Refused`; the log says which. `binder` is a secret that only + /// the browser that signed in holds (an HttpOnly cookie): the code is worth nothing to a + /// browser that does not present it, so a code cannot be carried to another browser in a link + /// (login CSRF). Only its hash is stored. + fn begin(&self, identity: &SsoIdentity, binder: &str) -> Result; /// Spend the one-time code and give the sign-in step its result, as the password step does: - /// a session, a session limited to enrolment, or the demand for an obop second factor. - fn exchange(&self, code: &str) -> Result; + /// a session, a session limited to enrolment, or the demand for an obop second factor. The + /// code is spent also when `binder` is not the one it was issued for. + fn exchange(&self, code: &str, binder: &str) -> Result; /// Write a line to the security log (`oidc_*`); never a token, a code or a secret. fn audit(&self, user: &str, kind: &str, detail: &str); diff --git a/crates/kernel/obop-http/src/lib.rs b/crates/kernel/obop-http/src/lib.rs index 75741f7a..d94fced2 100644 --- a/crates/kernel/obop-http/src/lib.rs +++ b/crates/kernel/obop-http/src/lib.rs @@ -29,6 +29,7 @@ pub mod payments; pub mod public; pub(crate) mod shared; pub mod shell; +pub(crate) mod sso; pub mod website; #[cfg(test)] @@ -55,6 +56,8 @@ pub struct HttpState { /// The instance default language (`OBOP_DEFAULT_LANGUAGE`), added to the served back office /// shell for the screens that come before sign-in; set once by the composition root. default_language: std::sync::OnceLock<&'static str>, + /// What this process remembers of the Microsoft sign-in provider (D91): caches only. + sso: sso::Cache, } impl HttpState { @@ -86,6 +89,7 @@ impl HttpState { payments: std::sync::OnceLock::new(), ops: ops::Ops::new(), default_language: std::sync::OnceLock::new(), + sso: sso::Cache::default(), }) } @@ -255,6 +259,13 @@ pub(crate) fn api_routes(state: Arc) -> Router { .route("/readyz", get(ops::readyz)) .route("/metrics", get(ops::metrics)) .route("/api/auth/login", post(login)) + .route("/api/auth/sso", get(sso::status)) + .route("/api/auth/oidc/start", get(sso::start)) + .route("/api/auth/oidc/callback", get(sso::callback)) + .route( + "/api/auth/oidc/exchange", + post(sso::exchange).layer(axum::extract::DefaultBodyLimit::max(sso::MAX_BODY)), + ) .route("/api/auth/agent-token", post(agent_token)) .route("/api/commands", get(commands)) .route("/api/meta", get(meta)) diff --git a/crates/kernel/obop-http/src/oauth.rs b/crates/kernel/obop-http/src/oauth.rs index e3d6cfbb..2546bfe0 100644 --- a/crates/kernel/obop-http/src/oauth.rs +++ b/crates/kernel/obop-http/src/oauth.rs @@ -64,7 +64,7 @@ fn pct_decode(s: &str) -> Option { } /// `a=1&b=2` into pairs; `None` when it is not valid percent-encoded UTF-8. -fn parse_pairs(s: &str) -> Option> { +pub(crate) fn parse_pairs(s: &str) -> Option> { s.split('&') .filter(|p| !p.is_empty()) .map(|p| { @@ -74,12 +74,12 @@ fn parse_pairs(s: &str) -> Option> { .collect() } -fn pair<'a>(pairs: &'a [(String, String)], k: &str) -> Option<&'a str> { +pub(crate) fn pair<'a>(pairs: &'a [(String, String)], k: &str) -> Option<&'a str> { pairs.iter().find(|(n, _)| n == k).map(|(_, v)| v.as_str()) } /// Headers every page and every token response carries. -fn harden(r: &mut Response) { +pub(crate) fn harden(r: &mut Response) { let h = r.headers_mut(); for (k, v) in [ ("x-content-type-options", "nosniff"), @@ -130,7 +130,7 @@ fn html(status: StatusCode, page: Rendered, cookie: Option) -> Response r } -fn see_other(location: &str) -> Response { +pub(crate) fn see_other(location: &str) -> Response { let mut r = StatusCode::SEE_OTHER.into_response(); if let Ok(v) = HeaderValue::from_str(location) { r.headers_mut().insert(header::LOCATION, v); @@ -181,7 +181,7 @@ fn cookie_name(flow: &str) -> String { format!("obop_oauth_{}", flow.get(..12).unwrap_or("x")) } -fn cookie_value(h: &HeaderMap, name: &str) -> Option { +pub(crate) fn cookie_value(h: &HeaderMap, name: &str) -> Option { h.get(header::COOKIE) .and_then(|v| v.to_str().ok())? .split(';') diff --git a/crates/kernel/obop-http/src/sso.rs b/crates/kernel/obop-http/src/sso.rs new file mode 100644 index 00000000..31057f98 --- /dev/null +++ b/crates/kernel/obop-http/src/sso.rs @@ -0,0 +1,650 @@ +//! Sign-in with Microsoft Entra over HTTP (D91, Proposed): the browser round trip of the +//! authorization code flow and the two calls this server makes to the provider (its token +//! endpoint, and the discovery document and key set it reads). Everything that decides who the +//! person is lives behind `SsoApi` in `obop-auth`; this module carries requests, keeps the state +//! of a flow, and never mints a session or reads a claim. +//! +//! The routes, all under `/api/auth` and none of them behind a bearer token: +//! +//! - `GET /sso`: `{"enabled": bool}`, whether the sign-in page shows the button. +//! - `GET /oidc/start`: begins a flow and redirects the browser to the provider. The flow's state, +//! nonce and a hash of the PKCE verifier are kept in the shared store for ten minutes, under the +//! hash of the flow id (the `state` parameter). The verifier itself is the value of an HttpOnly +//! cookie of the browser that started the flow, so it is stored nowhere on the server. +//! - `GET /oidc/callback`: the provider's redirect. The flow is spent first (one try per flow), +//! the cookie must be the one of the flow, the code is exchanged at the token endpoint, the id +//! token is verified and dropped (nothing the provider sends is stored), and the browser is +//! sent to the fixed address `/#oidc=`. Every failure is a redirect to +//! `/#oidc_error=` and nothing else: the reason is in the +//! security log, not in the address bar. +//! - `POST /oidc/exchange`: `{"code"}` for the answer of the password step (a bearer token, or the +//! demand for an obop second factor). The code is single use, lives 60 seconds and is worth +//! something only to the browser that holds the `obop_sso_done` cookie the callback set. +//! +//! There is no cookie session: the bearer token goes to the page and stays in `sessionStorage` +//! as after a password. There is no `next` or return address; the target of the redirect is the +//! root of this origin and cannot be chosen by a request. + +use super::oauth::{client_ip, cookie_value, harden, pair, parse_pairs, see_other}; +use super::{HttpState, api_error, blocking, mfa}; +use axum::{ + body::Bytes, + extract::State, + http::{HeaderValue, StatusCode, header, request::Parts}, + response::{IntoResponse, Response}, +}; +use obop_commands::{MfaError, SsoApi, SsoEndpoints, SsoError, SsoLeg}; +use obop_connectors::net::Request; +use obop_connectors::oauth::pkce_pair; +use obop_keys::{hex, random}; +use obop_oauth::util::{ct_eq, pct}; +use serde_json::{Value as J, json}; +use sha2::{Digest, Sha256}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +type Shared = State>; + +/// The size of the exchange body (a one-time code). +pub(crate) const MAX_BODY: usize = 4 * 1024; + +const FLOW_PREFIX: &str = "auth.sso.flow:"; +const FLOW_TTL_MS: u64 = 10 * 60 * 1000; +const FLOW_COOKIE: &str = "obop_sso_"; +const DONE_COOKIE: &str = "obop_sso_done"; +const DONE_SECS: u64 = 60; +const COOKIE_PATH: &str = "/api/auth/oidc"; +const DISCOVERY_TTL: Duration = Duration::from_secs(10 * 60); +const JWKS_TTL: Duration = Duration::from_secs(60 * 60); +/// How often the key set may be fetched again because a token named a key the cache lacks +/// (all server processes together): a stream of tokens with made-up key names cannot make this +/// server hammer the provider. +const JWKS_REFETCH_PER_MIN: u64 = 6; + +/// What this process remembers of the provider: the checked addresses of its discovery document +/// and its key set. Both are only caches; a restart or a change of the configuration empties them. +#[derive(Default)] +pub(crate) struct Cache { + discovery: Mutex>, + jwks: Mutex)>>, +} + +fn cache_key(leg: &SsoLeg) -> String { + format!("{}|{}", leg.authority, leg.tenant) +} + +/// The host of an authority, as the egress client's allow-list wants it: no scheme, no port, no +/// brackets of an IPv6 literal. +fn host_of(authority: &str) -> String { + let rest = authority.split("://").nth(1).unwrap_or(""); + let hostport = rest.split('/').next().unwrap_or(""); + let host = match hostport.strip_prefix('[') { + Some(h) => h.split(']').next().unwrap_or(""), + None => hostport.rsplit_once(':').map_or(hostport, |(h, _)| h), + }; + host.to_ascii_lowercase() +} + +/// A short, log-safe piece of what the provider said about an error (its `error` code; never the +/// description, which the provider may fill with anything). +fn clean(s: &str) -> String { + s.chars() + .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')) + .take(48) + .collect() +} + +fn describe(e: &SsoError) -> String { + match e { + SsoError::Refused(m) | SsoError::Invalid(m) | SsoError::Unavailable(m) => m.clone(), + SsoError::UnknownKey => "the signing key is not known".into(), + SsoError::NotFound => "not allowed".into(), + } +} + +impl Cache { + fn discovery( + &self, + api: &dyn SsoApi, + rt: &obop_connectors::Runtime, + leg: &SsoLeg, + ) -> Result { + let key = cache_key(leg); + if let Ok(g) = self.discovery.lock() + && let Some((k, at, ep)) = g.as_ref() + && *k == key + && at.elapsed() < DISCOVERY_TTL + { + return Ok(ep.clone()); + } + let url = format!( + "{}/{}/v2.0/.well-known/openid-configuration", + leg.authority, leg.tenant + ); + let body = fetch(rt, &url, &host_of(&leg.authority))?; + let ep = api.endpoints(&body).map_err(|e| describe(&e))?; + if let Ok(mut g) = self.discovery.lock() { + *g = Some((key, Instant::now(), ep.clone())); + } + Ok(ep) + } + + fn jwks( + &self, + rt: &obop_connectors::Runtime, + backend: &dyn obop_storage::Backend, + leg: &SsoLeg, + ep: &SsoEndpoints, + force: bool, + ) -> Result, String> { + let key = cache_key(leg); + if !force + && let Ok(g) = self.jwks.lock() + && let Some((k, at, doc)) = g.as_ref() + && *k == key + && at.elapsed() < JWKS_TTL + { + return Ok(doc.clone()); + } + if force + && obop_storage::limit::hit(backend, "rl:sso:jwks", JWKS_REFETCH_PER_MIN, 60_000) + .is_err() + { + return Err("the key set was fetched too often; not fetched again".into()); + } + let doc = fetch(rt, &ep.jwks, &host_of(&leg.authority))?; + if let Ok(mut g) = self.jwks.lock() { + *g = Some((key, Instant::now(), doc.clone())); + } + Ok(doc) + } + + fn forget_jwks(&self) { + if let Ok(mut g) = self.jwks.lock() { + *g = None; + } + } +} + +/// A GET through the hardened egress client. The text of an error has no URL, header or body in it. +fn fetch(rt: &obop_connectors::Runtime, url: &str, host: &str) -> Result, String> { + let r = rt + .client + .send(Request { + method: "GET", + url, + headers: vec![("Accept".into(), "application/json".into())], + body: None, + allowed_hosts: &[host.to_string()], + }) + .map_err(|e| e.to_string())?; + if r.status != 200 { + return Err(format!("the provider answered {}", r.status)); + } + Ok(r.body) +} + +fn limited(s: &HttpState, what: &str, ip: &str, max: u64) -> bool { + obop_storage::limit::hit( + &*s.shared.backend, + &format!("rl:sso:{what}:{ip}"), + max, + 60_000, + ) + .is_err() +} + +fn flow_key(flow: &str) -> String { + format!("{FLOW_PREFIX}{}", hex(&Sha256::digest(flow.as_bytes()))) +} + +fn hash_of(secret: &str) -> String { + hex(&Sha256::digest( + format!("obop-sso-verifier\n{secret}").as_bytes(), + )) +} + +fn flow_cookie(flow: &str) -> String { + format!("{FLOW_COOKIE}{}", flow.get(..12).unwrap_or("x")) +} + +fn set_cookie(name: &str, value: &str, max_age: u64, secure: bool) -> String { + format!( + "{name}={value}; Max-Age={max_age}; Path={COOKIE_PATH}; HttpOnly; SameSite=Lax{}", + if secure { "; Secure" } else { "" } + ) +} + +fn with_cookies(mut r: Response, cookies: &[String]) -> Response { + for c in cookies { + if let Ok(v) = HeaderValue::from_str(c) { + r.headers_mut().append(header::SET_COOKIE, v); + } + } + r +} + +/// The redirect of every failure: the root of this origin and a fixed word. +fn back(error: &str, cookies: &[String]) -> Response { + with_cookies(see_other(&format!("/#oidc_error={error}")), cookies) +} + +fn secure_of(redirect_uri: &str) -> bool { + redirect_uri.starts_with("https://") +} + +fn is_flow_id(s: &str) -> bool { + s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit()) +} + +// ---- GET /api/auth/sso ---------------------------------------------------------------------- + +pub(crate) async fn status(State(s): Shared) -> Response { + blocking(move || { + let enabled = s.connectors.get().is_some() && s.auth.sso().is_some_and(|a| a.offered()); + let mut r = axum::Json(json!({"enabled": enabled})).into_response(); + r.headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + r + }) + .await +} + +// ---- GET /api/auth/oidc/start --------------------------------------------------------------- + +pub(crate) async fn start(State(s): Shared, parts: Parts) -> Response { + blocking(move || start_blocking(&s, &parts)).await +} + +fn start_blocking(s: &HttpState, parts: &Parts) -> Response { + let ip = client_ip(s, parts); + if limited(s, "start", &ip, 30) { + return back("busy", &[]); + } + let (Some(api), Some(rt)) = (s.auth.sso(), s.connectors.get()) else { + return back("unavailable", &[]); + }; + let leg = match api.leg() { + Ok(Some(l)) => l, + Ok(None) => return back("unavailable", &[]), + Err(e) => { + api.audit("-", "oidc_error", &format!("start: {}", describe(&e))); + return back("unavailable", &[]); + } + }; + let ep = match s.sso.discovery(api, rt, &leg) { + Ok(ep) => ep, + Err(e) => { + api.audit( + "-", + "oidc_error", + &format!("start: the discovery document could not be used: {e}"), + ); + return back("unavailable", &[]); + } + }; + let (verifier, challenge) = pkce_pair(); + let flow = hex(&random::<32>()); + let nonce = hex(&random::<24>()); + let redirect_uri = format!("{}/api/auth/oidc/callback", s.mcp.base_url(&parts.headers)); + let rec = json!({ + "nonce": nonce, + "redirect_uri": redirect_uri, + "verifier_hash": hash_of(&verifier), + }); + if s.shared + .backend + .kv_put(&flow_key(&flow), &rec.to_string(), Some(FLOW_TTL_MS)) + .is_err() + { + return back("unavailable", &[]); + } + let url = format!( + "{}?client_id={}&response_type=code&redirect_uri={}&response_mode=query&scope=openid&state={}&nonce={}&code_challenge={}&code_challenge_method=S256", + ep.authorization, + pct(&leg.client_id), + pct(&redirect_uri), + flow, + nonce, + challenge, + ); + api.audit("-", "oidc_start", "a sign-in with Microsoft was begun"); + with_cookies( + see_other(&url), + &[set_cookie( + &flow_cookie(&flow), + &verifier, + FLOW_TTL_MS / 1000, + secure_of(&redirect_uri), + )], + ) +} + +// ---- GET /api/auth/oidc/callback ------------------------------------------------------------ + +pub(crate) async fn callback(State(s): Shared, parts: Parts) -> Response { + blocking(move || callback_blocking(&s, &parts)).await +} + +/// Spend the flow: read it and delete it, and trust it only if the delete was ours. +fn take_flow(s: &HttpState, flow: &str) -> Option { + let key = flow_key(flow); + let raw = s.shared.backend.kv_get(&key).ok().flatten(); + let claimed = s.shared.backend.kv_delete(&key).unwrap_or(false); + if !claimed { + return None; + } + serde_json::from_str(&raw?).ok() +} + +fn callback_blocking(s: &HttpState, parts: &Parts) -> Response { + let ip = client_ip(s, parts); + if limited(s, "cb", &ip, 60) { + return back("busy", &[]); + } + let (Some(api), Some(rt)) = (s.auth.sso(), s.connectors.get()) else { + return back("unavailable", &[]); + }; + let pairs = parts.uri.query().and_then(parse_pairs).unwrap_or_default(); + let Some(state) = pair(&pairs, "state").filter(|f| is_flow_id(f)) else { + api.audit( + "-", + "oidc_refused", + "the provider's redirect carried no valid state", + ); + return back("failed", &[]); + }; + let clear_flow = set_cookie(&flow_cookie(state), "", 0, false); + // One try per flow: spent before anything else is looked at. + let Some(flow) = take_flow(s, state) else { + api.audit( + "-", + "oidc_refused", + "the sign-in flow is unknown, already used or expired", + ); + return back("failed", &[clear_flow]); + }; + let redirect_uri = flow["redirect_uri"].as_str().unwrap_or("").to_string(); + let clear_flow = set_cookie(&flow_cookie(state), "", 0, secure_of(&redirect_uri)); + // The browser that comes back must be the one that left. + let verifier = cookie_value(&parts.headers, &flow_cookie(state)).unwrap_or_default(); + if verifier.is_empty() + || !ct_eq( + &hash_of(&verifier), + flow["verifier_hash"].as_str().unwrap_or(""), + ) + { + api.audit( + "-", + "oidc_refused", + "the browser that came back is not the one that began the sign-in", + ); + return back("failed", &[clear_flow]); + } + if let Some(err) = pair(&pairs, "error") { + let err = clean(err); + api.audit( + "-", + "oidc_cancelled", + &format!("the provider ended the sign-in with {err:?}"), + ); + let word = if err == "access_denied" { + "cancelled" + } else { + "failed" + }; + return back(word, &[clear_flow]); + } + let Some(code) = pair(&pairs, "code").filter(|c| !c.is_empty() && c.len() <= 4096) else { + api.audit( + "-", + "oidc_refused", + "the provider's redirect carried no code", + ); + return back("failed", &[clear_flow]); + }; + let leg = match api.leg() { + Ok(Some(l)) => l, + Ok(None) => { + api.audit( + "-", + "oidc_refused", + "the provider was switched off during the sign-in", + ); + return back("failed", &[clear_flow]); + } + Err(e) => { + api.audit("-", "oidc_error", &format!("callback: {}", describe(&e))); + return back("unavailable", &[clear_flow]); + } + }; + let ep = match s.sso.discovery(api, rt, &leg) { + Ok(ep) => ep, + Err(e) => { + api.audit( + "-", + "oidc_error", + &format!("callback: the discovery document could not be used: {e}"), + ); + return back("unavailable", &[clear_flow]); + } + }; + // The code for the id token: from this server, with the client secret and the verifier. + let form = format!( + "grant_type=authorization_code&code={}&redirect_uri={}&client_id={}&client_secret={}&code_verifier={}", + pct(code), + pct(&redirect_uri), + pct(&leg.client_id), + pct(&leg.client_secret), + pct(&verifier), + ); + let resp = rt.client.send(Request { + method: "POST", + url: &ep.token, + headers: vec![ + ( + "Content-Type".into(), + "application/x-www-form-urlencoded".into(), + ), + ("Accept".into(), "application/json".into()), + ], + body: Some(form.into_bytes()), + allowed_hosts: &[host_of(&leg.authority)], + }); + let resp = match resp { + Ok(r) => r, + Err(e) => { + api.audit( + "-", + "oidc_error", + &format!("the token endpoint could not be reached: {e}"), + ); + return back("unavailable", &[clear_flow]); + } + }; + let answer: J = serde_json::from_slice(&resp.body).unwrap_or(J::Null); + let id_token = answer["id_token"].as_str().unwrap_or(""); + if resp.status != 200 || id_token.is_empty() { + api.audit( + "-", + "oidc_refused", + &format!( + "the token endpoint answered {}{}", + resp.status, + answer["error"] + .as_str() + .map(|e| format!(" {:?}", clean(e))) + .unwrap_or_default() + ), + ); + return back("failed", &[clear_flow]); + } + let nonce = flow["nonce"].as_str().unwrap_or(""); + let mut jwks = match s.sso.jwks(rt, &*s.shared.backend, &leg, &ep, false) { + Ok(d) => d, + Err(e) => { + api.audit( + "-", + "oidc_error", + &format!("the key set could not be fetched: {e}"), + ); + return back("unavailable", &[clear_flow]); + } + }; + let mut verified = api.verify(id_token, &jwks, nonce); + if verified == Err(SsoError::UnknownKey) { + // The provider rotated its keys: fetch the set again, once, and look again. + match s.sso.jwks(rt, &*s.shared.backend, &leg, &ep, true) { + Ok(d) => { + jwks = d; + verified = api.verify(id_token, &jwks, nonce); + } + Err(e) => { + api.audit( + "-", + "oidc_error", + &format!("the key set could not be fetched again: {e}"), + ); + return back("unavailable", &[clear_flow]); + } + } + } + let identity = match verified { + Ok(i) => i, + Err(SsoError::Unavailable(m)) => { + s.sso.forget_jwks(); + api.audit( + "-", + "oidc_error", + &format!("the key set could not be used: {m}"), + ); + return back("unavailable", &[clear_flow]); + } + Err(e) => { + api.audit( + "-", + "oidc_refused", + &format!("the id token was refused: {}", describe(&e)), + ); + return back("failed", &[clear_flow]); + } + }; + // From here only this browser can spend the one-time code. + let done = hex(&random::<32>()); + match api.begin(&identity, &done) { + Ok(one_time) => with_cookies( + see_other(&format!("/#oidc={one_time}")), + &[ + clear_flow, + set_cookie(DONE_COOKIE, &done, DONE_SECS, secure_of(&redirect_uri)), + ], + ), + // The reason is in the log already (`begin` wrote it); the person is told nothing more. + Err(SsoError::Unavailable(m)) => { + api.audit("-", "oidc_error", &format!("callback: {m}")); + back("unavailable", &[clear_flow]) + } + Err(_) => back("failed", &[clear_flow]), + } +} + +// ---- POST /api/auth/oidc/exchange ----------------------------------------------------------- + +pub(crate) async fn exchange(State(s): Shared, parts: Parts, body: Bytes) -> Response { + blocking(move || exchange_blocking(&s, &parts, &body)).await +} + +fn exchange_blocking(s: &HttpState, parts: &Parts, body: &[u8]) -> Response { + let ip = client_ip(s, parts); + let clear = set_cookie(DONE_COOKIE, "", 0, false); + let r = if limited(s, "ex", &ip, 30) { + api_error( + StatusCode::TOO_MANY_REQUESTS, + "too many attempts, try again in a minute", + "rate_limited", + "Wait a minute and sign in with Microsoft again.", + ) + } else { + exchange_inner(s, parts, body) + }; + let mut r = with_cookies(r, &[clear]); + r.headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + r +} + +fn exchange_inner(s: &HttpState, parts: &Parts, body: &[u8]) -> Response { + let failed = || { + api_error( + StatusCode::UNAUTHORIZED, + "the sign-in with Microsoft could not be completed", + "sso_failed", + "Start the sign-in again.", + ) + }; + let Some(api) = s.auth.sso() else { + return failed(); + }; + let code = serde_json::from_slice::(body) + .ok() + .and_then(|v| v["code"].as_str().map(String::from)) + .unwrap_or_default(); + if code.is_empty() || code.len() > 256 { + return failed(); + } + let binder = cookie_value(&parts.headers, DONE_COOKIE).unwrap_or_default(); + match api.exchange(&code, &binder) { + Ok(out) => { + let mut r = mfa::outcome(Some(&*s.auth), out); + harden(&mut r); + r + } + Err(MfaError::Unavailable(_)) => api_error( + StatusCode::SERVICE_UNAVAILABLE, + "try again later", + "unavailable", + "The sign-in could not be completed right now.", + ), + Err(_) => failed(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_host_of_an_authority_is_what_the_egress_allow_list_wants() { + assert_eq!( + host_of("https://login.microsoftonline.com"), + "login.microsoftonline.com" + ); + assert_eq!(host_of("http://127.0.0.1:4455"), "127.0.0.1"); + assert_eq!(host_of("http://[::1]:4455"), "::1"); + assert_eq!(host_of("nonsense"), ""); + } + + #[test] + fn what_the_provider_says_about_an_error_is_cut_down_before_it_is_logged() { + assert_eq!(clean("access_denied"), "access_denied"); + assert_eq!(clean("a b\nc