diff --git a/CLAUDE.md b/CLAUDE.md index 06e6157e..364ea16b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ Open-source operating system for businesses: an Odoo-class modular suite (ERP, CRM, accounting, inventory, manufacturing, HR, projects, POS, e-commerce, website, marketing, helpdesk, documents) on one shared data model. Clean-room re-implementation of Odoo's concepts and behaviour, not a code port. Apache-2.0 and completely free to use and to self-host. -The owner's rulings of 2026-10-01 (OR-1 to OR-21), 2026-10-27 (OR-22 to OR-34), 2026-10-28 and 2026-10-29 (OR-35 to OR-37) are recorded in `docs/DECISIONS.md`, sections "Owner rulings (2026-10-01)" and "Owner rulings (2026-10-27)". They are RATIFIED and win over every Proposed position in every document and in the research. Anything the owner has not ruled stays PROPOSED or open; do not treat it as decided. +The owner's rulings of 2026-10-01 (OR-1 to OR-21), 2026-10-27 (OR-22 to OR-34), 2026-10-28 and 2026-10-29 (OR-35 to OR-37) and 2026-11-02 (OR-38) are recorded in `docs/DECISIONS.md`, sections "Owner rulings (2026-10-01)" and "Owner rulings (2026-10-27)". They are RATIFIED and win over every Proposed position in every document and in the research. Anything the owner has not ruled stays PROPOSED or open; do not treat it as decided. ## Hard tech constraints (set by the owner) @@ -33,7 +33,7 @@ The owner's rulings of 2026-10-01 (OR-1 to OR-21), 2026-10-27 (OR-22 to OR-34), ## Layout - `README.md` — entry point and document map. `LICENSE`, `NOTICE` — Apache-2.0 text and attribution (OR-1). -- `docs/PRODUCT.md` — master product description. `docs/DECISIONS.md` — binding decision register (wins over the research where they differ); its sections "Owner rulings (2026-10-01)" and "Owner rulings (2026-10-27)" hold OR-1 to OR-37 and win over every Proposed position. +- `docs/PRODUCT.md` — master product description. `docs/DECISIONS.md` — binding decision register (wins over the research where they differ); its sections "Owner rulings (2026-10-01)" and "Owner rulings (2026-10-27)" hold OR-1 to OR-38 and win over every Proposed position. - `docs/CONVENTIONS.md` — naming and contract authority (identifiers, events, permission groups, data classes, ports); it ranks below `docs/DECISIONS.md` and above every other document. - `docs/ARCHITECTURE.md`, `docs/ROADMAP.md`, `docs/BUSINESS.md`, `docs/LOCALIZATION.md`, `docs/ENGINEERING.md`, `docs/GLOSSARY.md`. - `docs/spec/` — per-domain product specifications. `docs/spec/cli.md` is the agentic-first command surface: the `obop` CLI, its REST and MCP counterparts and the free plugin registry client (OR-3, OR-4); parts of it are built (the `obop` binary), the rest is PROPOSED. @@ -50,6 +50,7 @@ The owner's rulings of 2026-10-01 (OR-1 to OR-21), 2026-10-27 (OR-22 to OR-34), - Modules and platform: `obop-pkg` (obop.toml X1 packages: parse, gate, semver and SPDX checks, dependency resolution and install plans, runtime install/uninstall, marketplace; admin commands `module.*`, `module_plan.preview` and `marketplace_listing.*`; D55: one core version `obop_types::CORE_VERSION`, module metadata, versioned dependencies), `obop-platform` (control plane: accounts, instances, provisioners local/sliplane/fake). `packages/*.toml` is the bundled catalog; translations (D52) are `[labels.]` tables in a package document, bundled as `packages/labels//.toml` (de, fr, pl, fi, sv, es; also for the built-in modules), checked by `obop modules check --complete FILE`, templated by `obop modules labels TARGET LANG`. A text field can show the label of an option of a select (`shows_option_of` and `when` in `[[model.field]]`, D52 amendment): `ShowsOption` in `obop-registry` `rules.rs`, the gate `expr::check_display_subset`, `shows_option_of` and `shows_option_when` in `/api/meta`; the browser evaluates the condition (`displayCondition.ts`, `shownOption.ts` in the backoffice), and `displayCondition.fixtures.json` is read by both it and `obop-commands/src/shows_option_tests.rs`. See `docs/DEPLOY.md`; Sliplane project `obop` (`project_2x9d8q51m9lt`, account `spacecorps`) exists and is empty. - Updates (D63, `docs/DEPLOY.md` "Updating from inside"): every package declares `requires_core` and a range per dependency; `module.upgrade|upgrade_all|rollback` (atomic batch, `[[migration]]` data steps in `obop-pkg::migrate`, D59 lock, audited, agents propose); `obop-update` (release feed, core compatibility of installed modules, `instance_update.check|status|start|rollback`, `obop update ...`) asks the control plane (`obop-platform::update`: per-instance update token, `GET /api/releases`, rollback to the previous image when the new one does not start). - OAuth sign-in for MCP clients (D64, `docs/DEPLOY.md` "Connecting Claude"): `obop-oauth` (the authorization server: dynamic registration of public clients, authorization code with mandatory PKCE S256 and the RFC 8707 resource, rotating refresh tokens with reuse detection, revocation, the server-rendered sign-in and consent pages in 7 languages; state in the shared storage as `oauth_client`, `oauth_grant`, `oauth_refresh` records and expiring codes and flows, everything secret stored as SHA-256 hashes), `obop-http::oauth` (routes under `/oauth/*`, `/.well-known/oauth-authorization-server`, `/api/auth/oauth/grants`), the `OAuthTokens` trait of `obop-commands` implemented by `obop-auth` (access tokens are one hour scoped agent tokens bound to the `/mcp` audience: refused on `/api`), the Connected apps table in Customize, Security, and `security_event` kinds `oauth_*`. Not yet tried against the real Claude apps. Mobile class (D64 amendment, `docs/DEPLOY.md` "Mobile apps", `docs/spec/mobile.md` 8.5): a client registers with `obop_client_class: "mobile"`, RFC 8252 redirects (`redirect::validate_mobile`: a private-use scheme or an `http` loopback address, never `https`) and the groups it declares (`scope`); the consent page offers only declared groups the person holds that a phone may have (`devices::never_for_mobile` and `NEVER_FOR_MOBILE` exclude administrative and approving groups by pattern and by name; CONVENTIONS 4) and ticks none unasked; its one hour access token has the `/api` audience, is the person narrowed to the groups handed over (stored as an `agent` token with the marker `mobile` and its device; `Principal::is_person` is false for it and `Principal::device` names its device, so the account routes refuse it and it reaches only its own device), and is refused on `/mcp`; the sign-in is bound to the credential epoch (K10); each sign-in is a `device` record (`obop_commands::devices`, `may_see` is the one rule, the model in `obop-pkg::devices`, commands `device.get|list|search|update|revoke`, group `api.device_admin`; at most 20 per person) bound to the refresh family; `obop-oauth::devices` implements `DevicesApi`; the identity provider ends devices with sessions on a credential change and on offboarding (`Auth::end_devices`, each one logged before it is ended). Not tried with a real app or phone. +- Microsoft sign-in (D91, RATIFIED by OR-38 on 2026-11-02, item 3a, `docs/DEPLOY.md` "Sign in with Microsoft"): `obop-auth/src/oidc/` (the RS256 id token verifier on `ring`, the records `auth_sso_provider` and `auth_sso_link`, the flag `sso_only`, the one-time code, the scope `openid profile`, the pinned tenant, the break-glass guard `BREAK_GLASS_MIN = 2`), the `SsoApi` port of `obop-commands`, `obop-http/src/sso.rs` (`/api/auth/sso`, `/api/auth/oidc/*`), the commands `sso_provider.configure|clear` and `user_account.bind_sso|unbind_sso|set_sso_only`, CLI `obop admin sso status|configure|clear|bind|unbind|only`; only an administrator binds an identity (the first-bind rule is 3b: ratified, not built); a Microsoft-only account has its password refused everywhere, also by `Auth::password_ok` for a signed-in session, so a later re-authentication built on it inherits that; the verifier keeps `auth_time` (a step-up must read it from the verified token, never the server clock); nothing ran against a real Entra tenant, test authority `OBOP_OIDC_TEST_AUTHORITY`; the stand-ins are `oidc/soft.rs`, `crates/obop/src/sso_tests.rs` and `web/packages/e2e/support/idp.ts`. - Connectors (D54, `docs/CONNECTORS.md`): `obop-connectors` (the egress and ingress gate with treatment by data class, the one hardened outbound client, signed webhooks, OAuth2 with PKCE, sealed secrets, retention and erasure) and the built-in module `connectors`; every flow in or out is recorded, minimised and revocable, and the docs say plainly that this supports GDPR compliance and is not legal advice. Credentials are sealed with `OBOP_SECRET_KEY`; `OBOP_CONNECTORS_ALLOW_LOOPBACK=1` allows loopback targets and is for tests only; `obop connectors purge` applies retention. - UI: `web/apps/backoffice` renders from `/api/meta` (D46): app launcher, control panel, list/kanban, record side panel, Customize app (theme, apps, the Modules page with cards, dependency panel and plan dialog, module builder). `web/apps/platform` is the sign-up app, `web/apps/site` the multi-page website (VitePress, docs, 7 languages; see docs/site/SITE.md); `deploy/build-site.sh` assembles the site and the sign-up app into `web/site`. Forum and status (`obop-community`, docs/COMMUNITY.md) and each instance's public shop (`obop-site`, docs/STOREFRONT.md, `obop serve --public-site`) are server-rendered Rust. - Tests: `cargo test` (Rust, add `OBOP_TEST_POSTGRES_URL` for PostgreSQL), and `web/packages/e2e` (Playwright over the real built app, about 190 tests; see its README; CI runs it). 23 bundled marketplace packages live in `packages/*.toml` (typed fields, D47). diff --git a/Cargo.lock b/Cargo.lock index beba8299..f438d012 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1302,6 +1302,7 @@ dependencies = [ "obop-commands", "obop-keys", "obop-storage", + "obop-types", "ring", "serde_json", "sha1", diff --git a/crates/kernel/obop-auth/Cargo.toml b/crates/kernel/obop-auth/Cargo.toml index 848e2884..34159f28 100644 --- a/crates/kernel/obop-auth/Cargo.toml +++ b/crates/kernel/obop-auth/Cargo.toml @@ -11,6 +11,7 @@ repository.workspace = true obop-commands.workspace = true obop-storage.workspace = true obop-keys.workspace = true +obop-types.workspace = true argon2 = "0.6" sha2 = "0.11" getrandom = "0.4" @@ -21,7 +22,8 @@ sha1 = "0.11.0" ring = "0.17.14" [features] -# A software authenticator for tests of the WebAuthn flows (never enabled in a release build). +# Test doubles: a software authenticator for the WebAuthn flows, a software Microsoft for the +# sign-in (`oidc::soft`) and `Auth::set_oidc_authority_for_test`. Never enabled in a release build. test-support = [] [lints] diff --git a/crates/kernel/obop-auth/src/account.rs b/crates/kernel/obop-auth/src/account.rs index eeb04bda..890ad7f3 100644 --- a/crates/kernel/obop-auth/src/account.rs +++ b/crates/kernel/obop-auth/src/account.rs @@ -443,6 +443,13 @@ impl Auth { )); } let u = self.user_record(name)?; + // A Microsoft-only account has no password to change (the hash that is left is not + // accepted by `password_ok`); say so instead of "wrong password". + if u["sso_only"].as_bool() == Some(true) { + return Err(MfaError::Invalid( + "this account signs in with Microsoft only: there is no password to change".into(), + )); + } if !self.password_ok(name, current)? { self.record_failure(name).map_err(MfaError::Unavailable)?; self.event( @@ -540,6 +547,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 +568,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), @@ -564,22 +587,6 @@ impl Auth { Ok(json!({"users": users})) } - /// How many enabled administrators there are. A refused list is the store's trouble, not - /// "none": the callers refuse to take the last administrator away, and must not do that (a 400) - /// or skip it (a lockout) because the lookup failed. - pub(crate) fn enabled_admins(&self) -> Result { - Ok(self - .backend - .list("auth_user") - .map_err(MfaError::Unavailable)? - .into_iter() - .filter(|(_, u)| { - str_list(&u["groups"]).iter().any(|g| g == ADMIN_GROUP) - && u.get("disabled").and_then(J::as_bool) != Some(true) - }) - .count()) - } - pub(crate) fn set_user_disabled_impl( &self, admin: &Principal, @@ -611,11 +618,21 @@ impl Auth { let u = self.known_user(&target)?; let is_admin = str_list(&u["groups"]).iter().any(|g| g == ADMIN_GROUP); let was = u["disabled"].as_bool() == Some(true); - if disabled && !was && is_admin && self.enabled_admins()? <= 1 { - return Err(MfaError::Invalid( - "the last enabled administrator cannot be disabled".into(), - )); + // The rule counts the administrators who sign in without Microsoft only (D91): an + // account that has no sign-in of its own does not keep the instance reachable. A refused + // list is the store's trouble, not "none". + if disabled && !was && is_admin && self.other_local_admins(&target)? == 0 { + return Err(MfaError::Invalid(format!( + "the last enabled administrator cannot be disabled{}", + self.microsoft_only_admins_note() + ))); } + // While accounts depend on Microsoft, two administrators keep a sign-in of their own. + let warning = if disabled && !was { + self.guard_break_glass(&target, actor_user.is_none())? + } else { + None + }; let mut patch = Fields::new(); patch.insert("disabled".into(), J::Bool(disabled)); bump_epoch(&mut patch); @@ -653,7 +670,10 @@ impl Auth { &target, "user_disable", actor, - &format!("account disabled; {n} session(s) signed out; its tokens stop working"), + &format!( + "account disabled; {n} session(s) signed out; its tokens stop working{}", + warning.map(|w| format!("; {w}")).unwrap_or_default() + ), ); } else if was_offboarded { self.event( @@ -687,7 +707,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/admin.rs b/crates/kernel/obop-auth/src/admin.rs index 60cd3806..9bb34466 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 { @@ -316,11 +316,17 @@ impl Auth { .into(), )); } - if u["disabled"].as_bool() != Some(true) && self.enabled_admins()? <= 1 { - return Err(MfaError::Invalid( - "the last enabled administrator keeps the group; make another administrator first" - .into(), - )); + if u["disabled"].as_bool() != Some(true) && self.other_local_admins(&target)? == 0 { + return Err(MfaError::Invalid(format!( + "the last enabled administrator keeps the group; make another administrator first{}", + self.microsoft_only_admins_note() + ))); + } + // While accounts depend on Microsoft, two administrators keep a sign-in of their own. + if u["disabled"].as_bool() != Some(true) + && let Some(w) = self.guard_break_glass(&target, actor_user.is_none())? + { + detail.push_str(&format!("; {w}")); } } let mut patch = Fields::new(); @@ -363,11 +369,19 @@ impl Auth { let _g = self.mfa_guard()?; let u = self.known_user(&target)?; let is_admin = str_list(&u["groups"]).iter().any(|g| g == ADMIN_GROUP); - if is_admin && u["disabled"].as_bool() != Some(true) && self.enabled_admins()? <= 1 { - return Err(MfaError::Invalid( - "the last enabled administrator cannot be offboarded".into(), - )); + let enabled_now = u["disabled"].as_bool() != Some(true); + if is_admin && enabled_now && self.other_local_admins(&target)? == 0 { + return Err(MfaError::Invalid(format!( + "the last enabled administrator cannot be offboarded{}", + self.microsoft_only_admins_note() + ))); } + // While accounts depend on Microsoft, two administrators keep a sign-in of their own. + let warning = if enabled_now { + self.guard_break_glass(&target, actor_user.is_none())? + } else { + None + }; let note = clean_note(note); // Disable first: from here no token of the account resolves, whatever happens next. let mut patch = Fields::new(); @@ -420,6 +434,9 @@ impl Auth { if !note.is_empty() { detail.push_str(&format!("; note: {note}")); } + if let Some(w) = warning { + detail.push_str(&format!("; {w}")); + } self.event(&target, "user_offboard", actor, &detail); Ok(json!({ "user": target, diff --git a/crates/kernel/obop-auth/src/lib.rs b/crates/kernel/obop-auth/src/lib.rs index 457ef64f..7a2bf273 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,10 @@ 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`, honoured only together with + /// `OBOP_CONNECTORS_ALLOW_LOOPBACK`). + oidc_authority: std::sync::RwLock>, } fn now_system() -> u64 { @@ -98,6 +103,7 @@ impl Auth { hasher: Argon2::default(), config, fake_now: AtomicU64::new(0), + oidc_authority: std::sync::RwLock::new(oidc::authority_from_env()), } } @@ -111,6 +117,9 @@ impl Auth { let params = Params::new(8, 1, 1, None).expect("valid params"); Self { hasher: Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params), + // A test chooses its authority (`set_oidc_authority_for_test`); the environment of the + // process that runs the tests does not. + oidc_authority: std::sync::RwLock::new(None), ..Self::with_config(backend, config) } } @@ -511,6 +520,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..e3b04654 100644 --- a/crates/kernel/obop-auth/src/mfa.rs +++ b/crates/kernel/obop-auth/src/mfa.rs @@ -210,7 +210,7 @@ impl Auth { Ok(&self.config.keys) } - fn open_secret(&self, user: &str, sealed: &str) -> Result, MfaError> { + pub(crate) fn open_secret(&self, user: &str, sealed: &str) -> Result, MfaError> { seal::open_any(self.ring()?, user, sealed).map_err(MfaError::Unavailable) } @@ -245,10 +245,29 @@ impl Auth { /// Whether `password` is the account's. A read of the account that the store refused is the /// store's trouble (`Unavailable`), not a wrong password: it must not be counted toward the /// lockout of a person who typed the right one. + /// + /// An account that signs in with Microsoft only (D91) has no password: what is left in the + /// record is no proof for any step that asks for it (turning the app off, new recovery codes, + /// adding or removing a passkey, and every re-authentication built on this later), so for such + /// an account the answer is "no", counted like a wrong password. Changing the password does + /// not come here: it says there is no password to change and is not counted. The hash is kept + /// (turning the flag off hands the password back); the sign-in step reads the flag itself and + /// uses [`Auth::password_check`]. pub(crate) fn password_ok(&self, name: &str, password: &str) -> Result { - self.password_check(name, password) + let ok = self + .password_check(name, password) .map(|c| c.0) - .map_err(MfaError::Unavailable) + .map_err(MfaError::Unavailable)?; + if !ok { + return Ok(false); + } + // A read the store refused is its trouble, not "not Microsoft only". + let sso_only = self + .backend + .get("auth_user", name) + .map_err(MfaError::Unavailable)? + .is_some_and(|u| u.get("sso_only").and_then(J::as_bool) == Some(true)); + Ok(!sso_only) } /// As [`Auth::password_ok`], and the credential epoch of the account as it was read together @@ -393,23 +412,48 @@ 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, and counted like one, so that a right password is not told from a + // wrong one by the lockout; the log says what happened. + if self.is_sso_only(&name) { + self.record_failure(&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 +461,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) } @@ -629,6 +673,11 @@ impl Auth { return Err(MfaError::BadPassword); } self.check_second_factor(name, code, recovery)?; + // An administrator who signs in without Microsoft keeps that ability while accounts depend + // on it (D91): turning the app off is refused when it is the last working factor. + if self.passkeys_checked(name)?.is_empty() { + self.guard_break_glass(name, false)?; + } let keep = session.map(token_hash); // Written before the factor goes: if ending the sessions is then refused, the repeat knows. let previous = self.owe_revocation(name, "mfa_disable", keep.as_deref())?; @@ -682,6 +731,11 @@ impl Auth { MfaError::Invalid(m) | MfaError::Unavailable(m) => m, other => format!("{other:?}"), })?; + // The operator is the way out of a lockout and of a compromised account, so this is a + // warning and not a refusal (D91): the line says what the reset leaves. + let warning = self + .guard_break_glass(&name, true) + .map_err(|e| format!("{e:?}"))?; let passkeys = self.clear_passkeys(&name).map_err(|e| format!("{e:?}"))?; self.clear_factor(&name).map_err(|e| format!("{e:?}"))?; let ch = Change::new(&name, "reset", "operator", "reset from the command line"); @@ -691,7 +745,10 @@ impl Auth { &name, "reset", "operator", - &format!("reset from the command line; {passkeys} passkey(s) removed"), + &format!( + "reset from the command line; {passkeys} passkey(s) removed{}", + warning.map(|w| format!("; {w}")).unwrap_or_default() + ), ); Ok(()) } @@ -894,6 +951,9 @@ impl MfaApi for Auth { "{target} has no two-factor to reset" ))); } + // The reset takes every second factor of the account: while accounts depend on Microsoft, + // two administrators keep a sign-in of their own (D91). + self.guard_break_glass(&target, false)?; let previous = self.owe_revocation(&target, "mfa_reset", None)?; let before = obop_storage::fault::change_count(); let passkeys = match self.clear_passkeys(&target) { 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..38414677 --- /dev/null +++ b/crates/kernel/obop-auth/src/oidc/mod.rs @@ -0,0 +1,1223 @@ +//! Sign-in with Microsoft Entra (D91, ratified by OR-38): 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, AnonGroup, AnonLine, 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; +/// How long a line about a sign-in with no account to be named under is written once per source, +/// kind and [`AnonLine`] (`audit_throttled`), in milliseconds. +const THROTTLE_MS: u64 = 10 * 60 * 1000; +/// The most such lines that are written in an hour, for all sources together, in each of the +/// three groups of [`AnonLine::group`] (`audit_throttled`). Past it one summary line is written +/// and the rest are counted. +pub const ANON_LINES_PER_HOUR: u64 = 60; +const ANON_WINDOW_MS: u64 = 60 * 60 * 1000; +/// How long the number of lines that were left out is kept to be reported by the first line of +/// the next hour. +const ANON_DROPPED_MS: u64 = 24 * ANON_WINDOW_MS; + +/// The counter of the lines written in this hour in one group. +pub fn anon_count_key(group: AnonGroup) -> String { + format!("auth.evt:sso:hour:{}", group.word()) +} + +/// The counter of the lines of one group that were left out and not reported yet. +pub fn anon_dropped_key(group: AnonGroup) -> String { + format!("auth.evt:sso:dropped:{}", group.word()) +} + +/// The counter that says a line of this kind and sort from this source was written lately. The +/// text of the line is not part of it: it can carry what the caller chose. +pub fn throttle_key(source: &str, kind: &str, line: AnonLine) -> String { + format!( + "auth.evt:sso:{}", + hex(&sha256( + format!("{source}\n{kind}\n{}", line.word()).as_bytes() + )) + ) +} + +/// The setting that points the sign-in at a test authority. +const TEST_AUTHORITY_VAR: &str = "OBOP_OIDC_TEST_AUTHORITY"; + +/// 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, and only together +/// with `OBOP_CONNECTORS_ALLOW_LOOPBACK` (`1` or `true`, read by `obop_types::loopback_allowed`, +/// the function the outbound client reads it with): without that flag the value is ignored here, +/// with a line on stderr, so that it cannot change the issuer of the bindings of an instance that +/// does not let the outbound client reach a loopback address anyway. A value that is not an +/// `http://127.0.0.1:` or `http://[::1]:` address is ignored with a line on stderr as +/// well; an empty value, and Microsoft's own authority (which is what the sign-in uses anyway), +/// are ignored without one. +pub(crate) fn authority_from_env() -> Option { + authority_from_vars(|name| std::env::var(name).ok()) +} + +/// [`authority_from_env`] with the environment passed in: the names read and the way the flag is +/// taken are what a test of the wiring looks at (the process environment is not changed by a test). +fn authority_from_vars(get: impl Fn(&str) -> Option) -> Option { + authority_from( + get(TEST_AUTHORITY_VAR).as_deref(), + get(obop_types::ALLOW_LOOPBACK_ENV).as_deref(), + ) +} + +/// The checks of [`authority_from_env`] on the two values, so a test can try them without the +/// process environment. `loopback_flag` is the value of `OBOP_CONNECTORS_ALLOW_LOOPBACK`. +fn authority_from(value: Option<&str>, loopback_flag: Option<&str>) -> Option { + let v = value?.trim().trim_end_matches('/').to_string(); + if v.is_empty() || v == verify::AUTHORITY { + return None; + } + if !obop_types::loopback_allowed(loopback_flag) { + eprintln!( + "OBOP_OIDC_TEST_AUTHORITY is ignored: it works only together with OBOP_CONNECTORS_ALLOW_LOOPBACK=1 (tests only)" + ); + 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(), + )) +} + +/// 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()))) +} + +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. Not in a build + /// without the `test-support` feature. + #[cfg(any(test, feature = "test-support"))] + 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 whose secret the key ring + /// of this server really opens (a replaced `OBOP_SECRET_KEY` leaves the flag on and the + /// secret unreadable). A lookup the store refused is "none" (the guard fails closed). + fn working_second_factor(&self, name: &str, u: &J) -> bool { + self.working_totp(name, u) || self.passkeys_checked(name).is_ok_and(|p| !p.is_empty()) + } + + /// An authenticator app that is on and whose secret this server's key ring opens. + pub(crate) fn working_totp(&self, name: &str, u: &J) -> bool { + u["mfa_enabled"].as_bool() == Some(true) + && u["totp_secret"] + .as_str() + .is_some_and(|sealed| self.open_secret(name, sealed).is_ok()) + } + + /// Whether `u` is a break-glass administrator: enabled, in the administrator group, not + /// Microsoft-only, with a working second factor. + fn is_break_glass(&self, name: &str, u: &J) -> bool { + 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) + } + + /// What the refusals of the "last administrator" rule add while enabled administrators sign in + /// with Microsoft only: they are not counted, and how to give one a password again. + pub(crate) fn microsoft_only_admins_note(&self) -> String { + let any = self.backend.list("auth_user").is_ok_and(|us| { + us.iter().any(|(_, u)| { + str_list(&u["groups"]).iter().any(|g| g == ADMIN_GROUP) + && u.get("disabled").and_then(J::as_bool) != Some(true) + && u.get("sso_only").and_then(J::as_bool) == Some(true) + }) + }); + if any { + " (administrators who sign in with Microsoft only are not counted: `obop admin sso only NAME off` gives one of them its password back)".into() + } else { + String::new() + } + } + + /// How many enabled administrators other than `except` sign in without Microsoft only. The + /// "last administrator" rule of disabling, offboarding and removing the group counts these, + /// not the Microsoft-only ones: an administrator who has no sign-in of their own does not + /// keep the instance reachable when Microsoft is not. + pub(crate) fn other_local_admins(&self, except: &str) -> Result { + Ok(self + .backend + .list("auth_user") + .map_err(MfaError::Unavailable)? + .into_iter() + .filter(|(name, u)| { + name != except + && str_list(&u["groups"]).iter().any(|g| g == ADMIN_GROUP) + && u.get("disabled").and_then(J::as_bool) != Some(true) + && u.get("sso_only").and_then(J::as_bool) != Some(true) + }) + .count()) + } + + /// Whether taking away the local sign-in of `target` (the account is disabled, offboarded, + /// loses the administrator group, or its last second factor goes) would leave fewer than + /// [`BREAK_GLASS_MIN`] break-glass administrators while an enabled account signs in with + /// Microsoft only. `Some(why)` when it would. Only an account that is a break-glass + /// administrator now can be the cause, so every other change passes. + pub(crate) fn break_glass_shortfall(&self, target: &str) -> Result, MfaError> { + let users = self + .backend + .list("auth_user") + .map_err(MfaError::Unavailable)?; + let microsoft_only = users + .iter() + .filter(|(n, u)| { + n != target + && u.get("sso_only").and_then(J::as_bool) == Some(true) + && u.get("disabled").and_then(J::as_bool) != Some(true) + }) + .count(); + if microsoft_only == 0 { + return Ok(None); + } + let Some((_, me)) = users.iter().find(|(n, _)| n == target) else { + return Ok(None); + }; + if !self.is_break_glass(target, &J::Object(me.clone())) { + return Ok(None); + } + let rest = self + .break_glass(Some(target)) + .map_err(|e| MfaError::Unavailable(describe_sso(e)))?; + if rest.len() >= BREAK_GLASS_MIN { + return Ok(None); + } + Ok(Some(format!( + "{microsoft_only} account(s) sign in with Microsoft only, and {target} is one of the administrators who can still sign in without it; {} would remain, fewer than {BREAK_GLASS_MIN}. Add another administrator with a password and a second factor first, or give the Microsoft-only accounts their own sign-in back", + rest.len() + ))) + } + + /// The guard for a request of a person (REST, MCP, the sign-in page): refuse. For the + /// operator at the command line (database access is the authority, and the way out when an + /// account is compromised) the same finding is a warning in the log and on screen. + pub(crate) fn guard_break_glass( + &self, + target: &str, + operator: bool, + ) -> Result, MfaError> { + match self.break_glass_shortfall(target)? { + None => Ok(None), + Some(why) if operator => { + eprintln!("WARNING: {why}"); + Ok(Some(format!("WARNING: {why}"))) + } + Some(why) => Err(MfaError::Invalid(why)), + } + } + + /// 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 binding a one-time code was issued for, if it is still the account's and the provider + /// that vouched for it is still on and still the tenant of that binding. Read again when the + /// code is spent: unbinding, switching the provider off or clearing it ends a code that is + /// in the air (the person has up to 60 seconds). + fn binding_holds(&self, user: &str, link_id: &str) -> Result, MfaError> { + let link = self + .backend + .get(LINK_TABLE, link_id) + .map_err(MfaError::Unavailable)?; + let Some(link) = link.map(J::Object) else { + return Ok(Some("the account was unbound before the sign-in finished")); + }; + if link["user"].as_str() != Some(user) { + return Ok(Some("the binding belongs to another account now")); + } + let provider = self + .provider_record() + .map_err(|e| MfaError::Unavailable(describe_sso(e)))?; + match provider.map(|p| Self::view(&p)) { + Some(v) if v.enabled && link["tid"].as_str() == Some(v.tenant.as_str()) => Ok(None), + Some(v) if !v.enabled => Ok(Some( + "the provider was switched off before the sign-in finished", + )), + Some(_) => Ok(Some( + "the provider is of another tenant than the binding, since the sign-in began", + )), + None => Ok(Some("the provider was removed before the sign-in finished")), + } + } + + // ---- 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: Option, + ) -> 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: Option, + ) -> 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")); + } + // Whatever the record says (it may have been cleared): no binding of another tenant stays + // under a provider of this one. Such a binding could never match a sign-in, and an account + // made Microsoft-only on it would be locked out. + let foreign = self + .backend + .list(LINK_TABLE) + .map_err(unavailable)? + .into_iter() + .filter(|(_, l)| l.get("tid").and_then(J::as_str) != Some(tenant.as_str())) + .count(); + if foreign > 0 { + return Err(invalid(format!( + "{foreign} account(s) are bound to another tenant: unbind them before this tenant is set" + ))); + } + // A first configuration is switched on; a later one keeps the switch unless it is given. + let enabled = enabled.unwrap_or_else(|| { + before + .as_ref() + .is_none_or(|b| b["enabled"].as_bool() == Some(true)) + }); + 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) cannot sign in until it is configured again or an administrator turns Microsoft-only off for them (the break-glass administrators can)"), + ); + 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 _g = self + .mfa_guard() + .map_err(|e| unavailable(format!("{e:?}")))?; + // Read under the lock: a provider changed in between cannot get a binding of its old tenant. + 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 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 ch = Change::new(&user, "oidc_unbind", actor, "unbound from Microsoft"); + let Some((id, link)) = self.link_of(&user)? else { + // The repeat of an unbinding that could not end the sessions finishes that (D74). + if let Some(n) = self + .finish_owed(&user, "sso_unbind", &ch) + .map_err(|e| unavailable(format!("{e:?}")))? + { + self.sso_event( + &user, + "oidc_unbind", + actor, + &format!("unbound; {n} session(s) signed out (repeated)"), + ); + return Ok(json!({"user": user, "changed": false, "sessions_ended": n})); + } + return Err(invalid(format!( + "{user} is not bound to a Microsoft identity" + ))); + }; + // What a binding gave the account (sessions, and a sign-in in the air) ends with it. The + // note is written first, the credential epoch must change (a code issued before this + // cannot be spent after it), then the binding goes and the sessions end. + let previous = self + .owe_revocation(&user, "sso_unbind", None) + .map_err(|e| unavailable(format!("{e:?}")))?; + let before = obop_storage::fault::change_count(); + let mut p = Fields::new(); + bump_epoch(&mut p); + if let Err(e) = self.put(&user, &p) { + self.retract_owed(&user, "sso_unbind", &e, before, previous); + return Err(unavailable(format!("{e:?}"))); + } + self.backend.delete(LINK_TABLE, &id).map_err(unavailable)?; + let n = self + .revoke_user_sessions(&user, None, &ch) + .map_err(|e| unavailable(format!("{e:?}")))?; + self.clear_owed(&user, "sso_unbind"); + self.sso_event( + &user, + "oidc_unbind", + actor, + &format!( + "unbound from object id {}; {n} session(s) signed out, and the phone apps of the account with them; agent tokens made in those sessions and apps connected through the OAuth sign-in for MCP clients (D64: their grants and refresh tokens) keep working until the account is offboarded", + link["oid"].as_str().unwrap_or("?") + ), + ); + Ok(json!({"user": user, "oid": link["oid"], "changed": true, "sessions_ended": n})) + } + + 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)); + // A password that has been unused since the account was made Microsoft-only is not + // handed back as it was: the person has to choose a new one. + p.insert("must_change_password".into(), J::Bool(true)); + 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 (it must be changed at the next sign-in)", + ); + 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")); + } + let Some((_, link)) = self.link_of(&user)? else { + return Err(invalid(format!( + "{user} is not bound to a Microsoft identity: bind it first, or the person could not sign in" + ))); + }; + let current = provider.as_ref().map(|p| Self::view(p).tenant); + if link["tid"].as_str() != current.as_deref() + || link["issuer"].as_str() + != current + .as_deref() + .map(|t| verify::issuer(&self.oidc_authority(), t)) + .as_deref() + { + return Err(invalid(format!( + "the binding of {user} is not to the tenant of the provider (the provider was changed after it was made): unbind and bind again" + ))); + } + // The first real use of a binding proves the object id is the person's and that the + // registration sends the profile scope (a token with an object id): without it a wrong id + // would lock the person out. It does not prove the optional claims: a token without `amr` + // signs in as well. The `oidc_login` line says whether the provider reported a second + // factor, which the operator reads before turning this on (docs/DEPLOY.md). + if link["last_login_at"].as_str().is_none() { + return Err(invalid(format!( + "{user} has never signed in with Microsoft on this binding: ask them to sign in once with the button first, so that a wrong object id shows now and not as a lockout" + ))); + } + 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, and the phone apps of the account with them; agent tokens and apps connected through the OAuth sign-in for MCP clients keep working until the account is offboarded; {} 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 provider_tenant = self.provider_record()?.map(|r| Self::view(&r).tenant); + 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())); + let current = provider_tenant.as_deref(); + 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), + // A binding of another tenant than the provider's can never match a sign-in. + "binding_current": link.map(|(_, l)| l.get("tid").and_then(J::as_str) == current), + "last_login_at": link.and_then(|(_, l)| l.get("last_login_at")).cloned().unwrap_or(J::Null), + })); + } + 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, 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()) + }; + // 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, + "link": id, + "epoch": epoch_of(&u), + "mfa": identity.mfa(), + "binder": binder_hash(binder), + "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, binder: &str) -> Result<(String, LoginOutcome), MfaError> { + 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. + 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); + } + // The binding and the provider as they are now, not as they were when the code was made. + if let Some(why) = self.binding_holds(&name, rec["link"].as_str().unwrap_or(""))? { + self.event(&name, "oidc_refused", "-", why); + return Err(MfaError::Expired); + } + // The first sign-in on a binding is what lets an administrator make the account + // Microsoft-only. A refused write only means the administrator is asked to sign in again. + if let Ok(Some((id, _))) = self.link_of(&name) { + let mut p = Fields::new(); + p.insert( + "last_login_at".into(), + J::from(obop_commands::audit::rfc3339(self.now())), + ); + let _ = self.backend.update(LINK_TABLE, &id, &p); + } + 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 { + // A refusal at this point (the account went while the sign-in finished) is answered + // like any other expired sign-in; only the store's own trouble is a 503. + self.after_first_factor(&name, &pending).map_err(|e| { + if e == "invalid username or password" { + MfaError::Expired + } else { + MfaError::Unavailable(e) + } + })? + }; + 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((name, 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 audit_throttled(&self, source: &str, kind: &str, line: AnonLine, detail: &str) { + let kind = match kind { + "oidc_refused" | "oidc_start" | "oidc_cancelled" | "oidc_error" => kind, + _ => "oidc_error", + }; + // One line per source, kind and sort in ten minutes, whichever process sees it first (the + // text is not in the key). An unreadable counter writes the line (an unthrottled line is + // better than a missing one). + let first = self + .backend + .counter_add(&throttle_key(source, kind, line), THROTTLE_MS, false) + .map_or(true, |n| n == 1); + if !first { + return; + } + // And at most ANON_LINES_PER_HOUR in an hour for all sources together in the group of the + // line, so that many addresses do not fill the log either, and so that the harmless lines + // of the begun sign-ins and the lines of requests that belong to no sign-in do not use up + // the budget of the lines that say why a sign-in went wrong. The first line past the + // ceiling says so; the number that was not written is reported by the first line of the + // next hour of the group. + let group = line.group(); + let (count_key, dropped_key) = (anon_count_key(group), anon_dropped_key(group)); + let what = match group { + AnonGroup::Start => "sign-ins begun", + AnonGroup::NoFlow => "callbacks that belong to no sign-in of this server", + AnonGroup::Failed => "sign-ins that went wrong", + }; + let n = self + .backend + .counter_add(&count_key, ANON_WINDOW_MS, false) + .unwrap_or(0); + if n > ANON_LINES_PER_HOUR { + let _ = self + .backend + .counter_add(&dropped_key, ANON_DROPPED_MS, false); + if n == ANON_LINES_PER_HOUR + 1 { + self.event( + "-", + "oidc_error", + "-", + &format!("the lines about {what} reached the ceiling of {ANON_LINES_PER_HOUR} an hour: the others of this hour are not written, their number is written by the first line of the next hour"), + ); + } + return; + } + if n == 1 + && let Ok(dropped) = self.backend.counter_get(&dropped_key) + && dropped > 0 + { + let _ = self.backend.counter_reset(&dropped_key); + self.event( + "-", + "oidc_error", + "-", + &format!("{dropped} line(s) about {what} were not written because the ceiling of {ANON_LINES_PER_HOUR} an hour was reached"), + ); + } + self.event( + "-", + kind, + "-", + &format!("{detail} (the next lines like this one from this address are not written for 10 minutes)"), + ); + } + + fn configure( + &self, + admin: &Principal, + tenant: &str, + client_id: &str, + client_secret: Option<&str>, + enabled: Option, + ) -> 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..cca9df33 --- /dev/null +++ b/crates/kernel/obop-auth/src/oidc/soft.rs @@ -0,0 +1,220 @@ +//! 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"); +/// A second, unrelated test key: a token signed with it verifies against no key set of the first. +const RSA_FIXTURE_B: &[u8] = include_bytes!("../../testdata/rsa2048-test-key-b.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(), + } + } + + /// A stand-in with the other key, under the same `kid`: what a forger who knows the key's name + /// but not the key would have. + pub fn with_other_key() -> Self { + Self { + key: RsaKeyPair::from_der(RSA_FIXTURE_B).expect("the second fixture key"), + kid: KID.into(), + } + } + + fn jwk(&self, kid: &str) -> J { + let (n, e) = split_rsa_der(self.key.public().as_ref()); + // The shape of the keys of Microsoft's key set: no `alg`, and the thumbprint, the + // certificate chain and the cloud name next to the key itself. + json!({ + "kty": "RSA", "use": "sig", "kid": kid, "x5t": kid, + "n": b64url(n), "e": b64url(e), + "x5c": ["MIIC-stand-in-certificate-not-parsed-by-anyone"], + "cloud_instance_name": "microsoftonline.com", + "issuer": "https://login.microsoftonline.com/{tenantid}/v2.0", + }) + } + + /// 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` with everything Entra can put in + /// one: the `profile` scope was granted and the optional claims `amr` and `auth_time` are + /// configured. [`StandIn::claims_for`] makes the claims of a request that got less. + pub fn claims( + authority: &str, + tenant: &str, + client_id: &str, + nonce: &str, + oid: &str, + now: u64, + ) -> J { + Self::claims_for( + authority, + tenant, + client_id, + nonce, + oid, + now, + &Entra::configured("openid profile"), + ) + } + + /// The claims of an id token as Microsoft's documentation describes them for `entra`: the + /// default claims always (issuer, `tid`, audience, `sub`, nonce, times); `oid`, + /// `preferred_username` and `name` only when the `profile` scope was granted; `amr` and + /// `auth_time` only when they are configured as optional claims of the app registration. A + /// test that asks for less sees what a real tenant would send, so the code cannot lean on a + /// claim the stand-in hands out for free. + pub fn claims_for( + authority: &str, + tenant: &str, + client_id: &str, + nonce: &str, + oid: &str, + now: u64, + entra: &Entra, + ) -> J { + let mut c = json!({ + "iss": format!("{authority}/{tenant}/v2.0"), + "tid": tenant, + "aud": client_id, + "sub": "standin-subject-per-application", + "nonce": nonce, + "ver": "2.0", + "iat": now, + "nbf": now, + "exp": now + 3600, + }); + let m = c.as_object_mut().expect("an object"); + if entra.scope.split(' ').any(|s| s == "profile") { + m.insert("oid".into(), J::from(oid)); + m.insert("preferred_username".into(), J::from("ada@example.test")); + m.insert("name".into(), J::from("Ada Example")); + } + if entra.optional_amr { + m.insert("amr".into(), json!(["pwd", "mfa"])); + } + if entra.optional_auth_time { + m.insert("auth_time".into(), J::from(now)); + } + c + } + + /// 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, + ) + } +} + +/// What an app registration and a request get from Entra: the scope that was granted and whether +/// the optional claims `amr` and `auth_time` are configured on the registration. +#[derive(Debug, Clone)] +pub struct Entra { + pub scope: String, + pub optional_amr: bool, + pub optional_auth_time: bool, +} + +impl Entra { + /// A registration with the optional claims `amr` and `auth_time` added to the ID token. + pub fn configured(scope: &str) -> Self { + Self { + scope: scope.into(), + optional_amr: true, + optional_auth_time: true, + } + } + + /// A registration with no optional claims. + pub fn plain(scope: &str) -> Self { + Self { + scope: scope.into(), + optional_amr: false, + optional_auth_time: false, + } + } +} 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..ac26e58d --- /dev/null +++ b/crates/kernel/obop-auth/src/oidc/tests.rs @@ -0,0 +1,2163 @@ +//! 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::{Auth, AuthConfig, SESSION_SECS, bump_epoch, token_hash}; +use obop_commands::{ + ADMIN_GROUP, AccountsApi, AnonGroup, AnonLine, 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 ROOT2_OID: &str = "0c0c0c0c-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 { + 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), Some(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:?}"), + } +} + +/// The person signs in once with the button on their binding, as the administrator asks before +/// making the account Microsoft-only. (An account with an obop second factor is then asked for +/// it; nothing is finished, and the binding still records the sign-in.) +fn signed_in_once(r: &Rig, oid: &str) { + let code = sso(r).begin(&identity(r, oid), BINDER).unwrap(); + sso(r).exchange(&code, BINDER).unwrap(); +} + +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, Some(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, Some(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, Some(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), Some(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), Some(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, BINDER), + 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, Some(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"), + Some(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, 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, BINDER).unwrap().1); + 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, 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", BINDER), + Err(MfaError::Expired) + )); + let late = sso(&r).begin(&id, BINDER).unwrap(); + r.a.set_clock_for_test(T0 + CODE_SECS + 1); + 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, 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. + 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 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, BINDER), + 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, BINDER).is_err()); + let mut forged = identity(&r, ADA_OID); + forged.issuer = format!("{AUTHORITY}/{OTHER_TENANT}/v2.0"); + assert!(sso(&r).begin(&forged, BINDER).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, 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, BINDER).unwrap(); + r.a.operator_set_disabled("ada", true).unwrap(); + 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, BINDER).unwrap(); + r.a.operator_offboard("ada", "left").unwrap(); + 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, 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, BINDER), + Err(MfaError::Expired) + )); + // Refused by the exchange itself, and the line says why (the first factor would refuse it + // later as well, with a word of its own). + assert!( + events(&r, "oidc_refused") + .iter() + .any(|d| d.contains("credentials of the account changed before the sign-in finished")), + "{:?}", + events(&r, "oidc_refused") + ); + // Unbound in between. + let code = sso(&r).begin(&id, BINDER).unwrap(); + sso(&r).unbind(&admin("root1"), "ada").unwrap(); + 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, Some(false)) + .unwrap(); + assert!(matches!( + sso(&r).begin(&id, BINDER), + Err(SsoError::Refused(_)) + )); + assert!(!events(&r, "oidc_refused").is_empty()); +} + +/// `binding_holds` is the defence for a code that is made in the moment between the epoch bump of +/// an unbind and its delete of the link (the comparison of the epochs cannot see that moment). The +/// record is changed here without a bump, as that moment leaves it. +#[test] +fn a_code_for_a_binding_that_is_gone_or_now_another_accounts_is_not_spent() { + let r = world(); + let id = identity(&r, ADA_OID); + let link = link_key(&format!("{AUTHORITY}/{TENANT}/v2.0"), TENANT, ADA_OID); + // The link of the account belongs to somebody else now. + let code = sso(&r).begin(&id, BINDER).unwrap(); + let mut f = Fields::new(); + f.insert("user".into(), json!("bob")); + r.b.update(LINK_TABLE, &link, &f).unwrap(); + assert!(matches!( + sso(&r).exchange(&code, BINDER), + Err(MfaError::Expired) + )); + assert!( + events(&r, "oidc_refused") + .iter() + .any(|d| d.contains("belongs to another account")), + "{:?}", + events(&r, "oidc_refused") + ); + // The link is gone, and the account's credentials were not touched. + f.insert("user".into(), json!("ada")); + r.b.update(LINK_TABLE, &link, &f).unwrap(); + let code = sso(&r).begin(&id, BINDER).unwrap(); + r.b.delete(LINK_TABLE, &link).unwrap(); + assert!(matches!( + sso(&r).exchange(&code, BINDER), + Err(MfaError::Expired) + )); + assert!( + events(&r, "oidc_refused") + .iter() + .any(|d| d.contains("unbound before the sign-in finished")), + "{:?}", + events(&r, "oidc_refused") + ); +} + +/// An unbind and a bind of the same object id make the same link again: a code that was made +/// before finds a link that is there, and only the change of the credential epoch refuses it. +#[test] +fn a_code_made_before_an_unbind_is_not_spent_after_the_same_identity_is_bound_again() { + let r = world(); + let id = identity(&r, ADA_OID); + let code = sso(&r).begin(&id, BINDER).unwrap(); + sso(&r).unbind(&admin("root1"), "ada").unwrap(); + sso(&r).bind(&admin("root1"), "ada", ADA_OID).unwrap(); + assert!(matches!( + sso(&r).exchange(&code, BINDER), + Err(MfaError::Expired) + )); + // A code made after the new binding is spent. + let fresh = sso(&r).begin(&id, BINDER).unwrap(); + assert!(sso(&r).exchange(&fresh, BINDER).is_ok()); +} + +/// The lines about a sign-in that has no account to be named under: one per source, kind and sort +/// in ten minutes whatever the text says. +#[test] +fn an_anonymous_line_is_one_per_source_kind_and_sort_in_ten_minutes_whatever_its_text() { + let r = world(); + let lines = |kind: &str| events(&r, kind); + let src = "203.0.113.1"; + // The text is not in the key: forty texts, one line. + for i in 0..40 { + sso(&r).audit_throttled( + src, + "oidc_error", + AnonLine::Provider, + &format!("text number {i}"), + ); + } + assert_eq!(lines("oidc_error").len(), 1); + assert!(lines("oidc_error")[0].contains("text number 0")); + assert!(lines("oidc_error")[0].contains("not written for 10 minutes")); + // Another sort of line of the same kind from the same source is a line of its own (the + // slot is the reason too: a failure is not swallowed by an earlier line of the kind)... + sso(&r).audit_throttled(src, "oidc_error", AnonLine::TokenUnwell, "x"); + assert_eq!(lines("oidc_error").len(), 2); + // ... and a repeat of either is not. + sso(&r).audit_throttled(src, "oidc_error", AnonLine::TokenUnwell, "x"); + sso(&r).audit_throttled(src, "oidc_error", AnonLine::Provider, "x"); + assert_eq!(lines("oidc_error").len(), 2); + // Another kind, and another source, are lines of their own. + sso(&r).audit_throttled(src, "oidc_refused", AnonLine::Provider, "x"); + sso(&r).audit_throttled("203.0.113.2", "oidc_error", AnonLine::Provider, "x"); + assert_eq!( + (lines("oidc_refused").len(), lines("oidc_error").len()), + (1, 3) + ); + // The slot lasts ten minutes (the counter says how long it has to live). + let ttl = + r.b.counter_ttl_ms(&super::throttle_key(src, "oidc_error", AnonLine::Provider)) + .unwrap() + .expect("the slot is a counter that expires"); + assert!( + (10 * 60 * 1000 - 10_000..=10 * 60 * 1000).contains(&ttl), + "{ttl} ms" + ); + // Only the sign-in kinds can be written through the throttled port as well: another kind + // becomes an `oidc_error` line and is never a line of its own kind. + let other = world(); + sso(&other).audit_throttled( + "203.0.113.9", + "login_ok", + AnonLine::Provider, + "pretending to be a password sign-in", + ); + assert!(events(&other, "login_ok").is_empty()); + assert_eq!(events(&other, "oidc_error").len(), 1); +} + +/// A hundred sources write a line of one sort: sixty are written, the first one past that says so +/// (and no other), the next hour says how many were left out, once. +#[test] +fn a_group_of_anonymous_lines_has_an_hourly_ceiling_of_sixty_that_says_so_and_reports_what_it_left_out() + { + let r = world(); + let lines = |kind: &str| events(&r, kind); + let ceiling = super::ANON_LINES_PER_HOUR as usize; + assert_eq!(ceiling, 60); + let write = |i: usize| { + sso(&r).audit_throttled( + &format!("198.51.100.{i}"), + "oidc_error", + AnonLine::TokenUnwell, + "the token endpoint answered 503", + ); + }; + let said = || -> Vec { + lines("oidc_error") + .into_iter() + .filter(|l| l.contains("ceiling")) + .collect() + }; + // Exactly the ceiling: all written, nothing said. + for i in 0..ceiling { + write(i); + } + assert_eq!(lines("oidc_error").len(), ceiling); + assert!(said().is_empty()); + // The first line past it is the line that says so, and nothing else is written. + write(ceiling); + assert_eq!(lines("oidc_error").len(), ceiling + 1); + assert_eq!(said().len(), 1, "{:?}", said()); + assert!( + said()[0].contains("sign-ins that went wrong") && said()[0].contains("60 an hour"), + "{:?}", + said() + ); + for i in ceiling + 1..100 { + write(i); + } + assert_eq!( + lines("oidc_error").len(), + ceiling + 1, + "the rest is not written" + ); + // The hour is an hour, and what was left out is kept to be reported (a day at most). + let ttl = |key: String| { + r.b.counter_ttl_ms(&key) + .unwrap() + .expect("a counter that expires") + }; + let hour = 60 * 60 * 1000; + let t = ttl(super::anon_count_key(AnonGroup::Failed)); + assert!((hour - 10_000..=hour).contains(&t), "{t} ms"); + let t = ttl(super::anon_dropped_key(AnonGroup::Failed)); + assert!((23 * hour..=24 * hour).contains(&t), "{t} ms"); + // The hour ends: the first line of the next one says how many were left out, once. + let left_out = 100 - ceiling; + r.b.counter_reset(&super::anon_count_key(AnonGroup::Failed)) + .unwrap(); + sso(&r).audit_throttled("192.0.2.1", "oidc_error", AnonLine::TokenUnwell, "z"); + let reports = || -> Vec { + lines("oidc_error") + .into_iter() + .filter(|l| l.contains("were not written")) + .collect() + }; + assert_eq!(reports().len(), 1, "{:?}", reports()); + assert!( + reports()[0].starts_with(&format!( + "{left_out} line(s) about sign-ins that went wrong" + )), + "{:?}", + reports() + ); + sso(&r).audit_throttled("192.0.2.2", "oidc_error", AnonLine::TokenUnwell, "z"); + assert_eq!(reports().len(), 1, "reported once"); + // Another hour: the report reset what it reported, so nothing is left to say. + r.b.counter_reset(&super::anon_count_key(AnonGroup::Failed)) + .unwrap(); + sso(&r).audit_throttled("192.0.2.3", "oidc_error", AnonLine::TokenUnwell, "z"); + assert_eq!(reports().len(), 1, "not reported again"); +} + +/// R2H-1: the harmless line of a begun sign-in and the lines of callbacks that belong to no +/// sign-in are counted against ceilings of their own, so a busy hour of sign-ins, or anybody who +/// sends callbacks with a made-up state, cannot use up the budget of the lines that say why a +/// sign-in went wrong (an expired client secret shows as a refusal of the token endpoint). +#[test] +fn begun_sign_ins_and_callbacks_with_no_flow_cannot_use_up_the_lines_that_say_why_a_sign_in_failed() +{ + let r = world(); + let ceiling = super::ANON_LINES_PER_HOUR as usize; + // A busy hour: a hundred addresses begin a sign-in. + for i in 0..100 { + sso(&r).audit_throttled( + &format!("198.51.100.{i}"), + "oidc_start", + AnonLine::Start, + "a sign-in with Microsoft was begun", + ); + } + assert_eq!(events(&r, "oidc_start").len(), ceiling); + // And a hundred send a callback with a state that is no flow of ours. + for i in 0..100 { + sso(&r).audit_throttled( + &format!("203.0.113.{i}"), + "oidc_refused", + AnonLine::NoFlow, + "the sign-in flow is unknown, already used or expired", + ); + } + let refused = events(&r, "oidc_refused"); + assert_eq!(refused.len(), ceiling); + // Each ceiling says what it counts. + let said: Vec = events(&r, "oidc_error") + .into_iter() + .filter(|l| l.contains("ceiling")) + .collect(); + assert_eq!(said.len(), 2, "{said:?}"); + assert!( + said.iter().any(|l| l.contains("about sign-ins begun")), + "{said:?}" + ); + assert!( + said.iter() + .any(|l| l.contains("about callbacks that belong to no sign-in of this server")), + "{said:?}" + ); + // The expired secret: the lines that say so are still written, from a new address and from an + // address that has been sending noise of the same kind. + sso(&r).audit_throttled( + "192.0.2.50", + "oidc_refused", + AnonLine::TokenRefused, + "the token endpoint answered 401 \"invalid_client\"", + ); + sso(&r).audit_throttled( + "203.0.113.5", + "oidc_refused", + AnonLine::IdToken, + "the id token was refused: the audience is not this client", + ); + let refused = events(&r, "oidc_refused"); + assert_eq!(refused.len(), ceiling + 2, "{}", refused.len()); + assert!( + refused.iter().any(|l| l.contains("401")), + "the failure line is written" + ); + assert!( + refused.iter().any(|l| l.contains("audience")), + "a failure line of the kind the address's noise line has is written" + ); + // They were counted against the budget of failures, which has room for sixty of them. + assert_eq!( + r.b.counter_get(&super::anon_count_key(AnonGroup::Failed)) + .unwrap(), + 2 + ); + assert_eq!( + r.b.counter_get(&super::anon_count_key(AnonGroup::Start)) + .unwrap(), + 100 + ); +} + +#[test] +fn the_disabled_flag_alone_ends_a_half_finished_sign_in() { + // Every path that disables an account also changes its credential epoch today; the exchange + // reads the flag itself so that a path that does not still ends the sign-in. + let r = world(); + let code = sso(&r).begin(&identity(&r, ADA_OID), BINDER).unwrap(); + let mut patch = Fields::new(); + patch.insert("disabled".into(), J::Bool(true)); + r.b.update("auth_user", "ada", &patch).unwrap(); + assert!(matches!( + sso(&r).exchange(&code, BINDER), + Err(MfaError::Expired) + )); + assert!( + events(&r, "oidc_refused") + .iter() + .any(|d| d.contains("disabled")), + "{:?}", + events(&r, "oidc_refused") + ); +} + +#[test] +fn the_one_time_code_lives_sixty_seconds_and_not_a_second_more() { + // The lifetime is stated in D91 and in docs/DEPLOY.md: pinned here by its number, not by the + // constant that sets it. + let r = world(); + let id = identity(&r, ADA_OID); + let early = sso(&r).begin(&id, BINDER).unwrap(); + let late = sso(&r).begin(&id, BINDER).unwrap(); + r.a.set_clock_for_test(T0 + 59); + assert!(sso(&r).exchange(&early, BINDER).is_ok()); + r.a.set_clock_for_test(T0 + 60); + assert!(matches!( + sso(&r).exchange(&late, BINDER), + Err(MfaError::Expired) + )); + assert_eq!(CODE_SECS, 60); +} + +#[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), BINDER).unwrap(); + let (who, LoginOutcome::MfaRequired { mfa_token: t }) = + sso(&r).exchange(&code, BINDER).unwrap() + else { + panic!("an obop second factor was expected") + }; + assert_eq!(who, "ada", "the page is told who is signing in"); + 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, BINDER).unwrap(); + assert!(matches!( + sso(&r).exchange(&code, BINDER).unwrap().1, + 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"); + signed_in_once(&r, BOB_OID); + 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), BINDER).unwrap(); + let (token, limited) = session_of(sso(&r).exchange(&code, BINDER).unwrap().1); + 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), BINDER) + .unwrap(); + let (token, limited) = session_of(sso(&r).exchange(&code, BINDER).unwrap().1); + assert!(limited); + assert!(r.a.resolve(&token).unwrap().mfa_limited); + let line = events(&r, "oidc_login"); + // The first one is the sign-in made before the account was Microsoft-only. + assert_eq!(line.len(), 3); + assert!(line[1].contains("reports a second factor") && line[2].contains("does not report")); +} + +#[test] +fn an_sso_only_account_refuses_its_password_and_ends_its_sessions() { + let r = world(); + signed_in_once(&r, ADA_OID); + 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 { .. }))); + signed_in_once(&r, ADA_OID); + 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), Some(true)) + .unwrap(); + // Not bound. + assert!(invalid(sso_only(&r, "ada", true)).contains("not bound")); + r.a.operator_sso_bind("ada", ADA_OID).unwrap(); + // Bound, but the person never signed in with the button: the object id is untried. + assert!(invalid(sso_only(&r, "ada", true)).contains("never signed in")); + signed_in_once(&r, ADA_OID); + // 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(); + signed_in_once(&r, BOB_OID); + assert_eq!(sso_only(&r, "root1", true).unwrap()["changed"], true); + r.a.operator_sso_bind("root2", ROOT2_OID).unwrap(); + signed_in_once(&r, ROOT2_OID); + 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, Some(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(); + signed_in_once(&r, ADA_OID); + 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, 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]); + // 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(_)) + )); +} + +/// D91 Decision 1: the variable has no effect without `OBOP_CONNECTORS_ALLOW_LOOPBACK`. It must +/// not change the issuer of a binding (and so the identity of a person) on an instance that is not +/// a test instance. +#[test] +fn the_test_authority_is_ignored_without_the_loopback_flag() { + use super::authority_from; + let lo = "http://127.0.0.1:4455"; + // The variable alone, and the flag in any spelling the outbound client does not read. + assert_eq!(authority_from(Some(lo), None), None); + for flag in ["", "0", "yes", "TRUE", "on", " 1"] { + assert_eq!(authority_from(Some(lo), Some(flag)), None, "flag {flag:?}"); + } + // The two spellings of `obop_connectors::runtime::Config::from_env`. + for flag in ["1", "true"] { + assert_eq!(authority_from(Some(lo), Some(flag)).as_deref(), Some(lo)); + } + // An instance that started with the variable alone trusts Microsoft's own authority, and a + // binding made under it holds the real issuer; with the flag too, the stand-in's. + let issuer_of_a_binding = |authority: Option, user: &str, oid: &str| { + let r = world(); + *r.a.oidc_authority.write().unwrap() = authority; + user_with(&r.a, user, &["base.user"]); + r.a.operator_sso_bind(user, oid).unwrap(); + let rows: Vec = + r.b.list(LINK_TABLE) + .unwrap() + .into_iter() + .filter(|(_, f)| f["user"] == user) + .map(|(_, f)| f["issuer"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(rows.len(), 1); + rows[0].clone() + }; + assert_eq!( + issuer_of_a_binding(authority_from(Some(lo), None), "eve", BOB_OID), + format!("{AUTHORITY}/{TENANT}/v2.0") + ); + assert_eq!( + issuer_of_a_binding(authority_from(Some(lo), Some("1")), "eve", BOB_OID), + format!("{lo}/{TENANT}/v2.0") + ); +} + +/// The wiring of that rule: `Auth::with_config` reads the process environment through +/// `authority_from_vars`. The test passes the environment in, so that it sees which two settings +/// are read, that the flag is taken from `OBOP_CONNECTORS_ALLOW_LOOPBACK` (and not assumed) and +/// that the variable alone, whatever the other setting says, changes nothing. The same read of the +/// real environment of a real process is in `crates/obop/tests/sso_cli.rs`. +#[test] +fn the_test_authority_is_read_from_the_two_settings_it_names_and_the_flag_from_its_own() { + use super::authority_from_vars; + use std::cell::RefCell; + const VAR: &str = "OBOP_OIDC_TEST_AUTHORITY"; + const FLAG: &str = "OBOP_CONNECTORS_ALLOW_LOOPBACK"; + let lo = "http://127.0.0.1:4455"; + let read = |pairs: &[(&str, &str)]| -> (Option, Vec) { + let asked = RefCell::new(Vec::new()); + let got = authority_from_vars(|name| { + asked.borrow_mut().push(name.to_string()); + pairs + .iter() + .find(|(k, _)| *k == name) + .map(|(_, v)| v.to_string()) + }); + let mut asked = asked.into_inner(); + asked.sort(); + (got, asked) + }; + // Both set: honoured, and exactly these two names are read. + let (got, asked) = read(&[(VAR, lo), (FLAG, "1")]); + assert_eq!(got.as_deref(), Some(lo)); + assert_eq!(asked, [FLAG, VAR]); + assert_eq!(read(&[(VAR, lo), (FLAG, "true")]).0.as_deref(), Some(lo)); + // The variable alone is ignored; so is the flag alone; and a flag in a spelling that the + // outbound client does not read. + assert_eq!(read(&[(VAR, lo)]).0, None); + assert_eq!(read(&[(FLAG, "1")]).0, None); + assert_eq!(read(&[]).0, None); + for flag in ["yes", "0", "", "TRUE", "on", " 1", "false"] { + assert_eq!(read(&[(VAR, lo), (FLAG, flag)]).0, None, "flag {flag:?}"); + } + // The flag is judged by the function the outbound client uses. + for flag in ["1", "true", "yes", "0", "", "TRUE"] { + assert_eq!( + read(&[(VAR, lo), (FLAG, flag)]).0.is_some(), + obop_types::loopback_allowed(Some(flag)), + "flag {flag:?}" + ); + } + // Other settings do not stand in for either of them. + assert_eq!( + read(&[("OBOP_OIDC_TEST_AUTHORITY_2", lo), (FLAG, "1")]).0, + None + ); + assert_eq!(read(&[(VAR, lo), ("OBOP_ALLOW_LOOPBACK", "1")]).0, None); +} + +#[test] +fn the_stand_in_authority_is_loopback_over_http_or_nothing() { + use super::authority_from; + let on = Some("1"); + assert_eq!(authority_from(None, on), None); + assert_eq!(authority_from(Some(""), on), None); + assert_eq!(authority_from(Some(AUTHORITY), on), None); + assert_eq!( + authority_from(Some(" http://127.0.0.1:4455/ "), on).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), on), 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), Some(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 + ); +} + +// ---- the break-glass rule on every way of taking a local sign-in away (D91 Decision 6) -------- + +/// root1, root2 and root3 (administrators with an authenticator app), `ada` bound, signed in once +/// and Microsoft-only, and root3 disabled: exactly [`BREAK_GLASS_MIN`] local administrators are +/// left, root1 and root2. Returns the rig and the secret of root2's app. +fn depending_world() -> (Rig, Vec) { + let r = rig(); + let mut secret2 = Vec::new(); + for n in ["root1", "root2", "root3"] { + user_with(&r.a, n, &[ADMIN_GROUP]); + let (secret, _) = enrol(&r.a, n); + if n == "root2" { + secret2 = secret; + } + } + user_with(&r.a, "ada", &["base.user"]); + r.a.operator_sso_configure(TENANT, CLIENT, Some(SECRET), Some(true)) + .unwrap(); + r.a.operator_sso_bind("ada", ADA_OID).unwrap(); + signed_in_once(&r, ADA_OID); + sso_only(&r, "ada", true).unwrap(); + // Three local administrators, one may go. + r.a.set_user_disabled(&admin("root1"), "root3", true) + .unwrap(); + assert_eq!( + sso(&r).bindings(&admin("root1")).unwrap()["break_glass"], + json!(["root1", "root2"]) + ); + (r, secret2) +} + +fn shortfall(r: Result) -> String { + match r { + Err(MfaError::Invalid(m)) => { + assert!( + m.contains("sign in with Microsoft only") && m.contains("fewer than 2"), + "{m}" + ); + m + } + other => panic!("expected the break-glass refusal, got {other:?}"), + } +} + +fn known() -> Vec { + vec!["base.user".into(), ADMIN_GROUP.into(), "sales.user".into()] +} + +#[test] +fn an_administrator_is_not_disabled_offboarded_or_demoted_below_two_local_ones() { + let (r, _) = depending_world(); + let root = admin("root1"); + shortfall(r.a.set_user_disabled(&root, "root2", true)); + shortfall(r.a.offboard_user(&root, "root2", "left")); + shortfall(r.a.set_user_groups(&root, "root2", &["base.user".to_string()], &known())); + // Nothing was done by any of them. + let u = r.b.get("auth_user", "root2").unwrap().unwrap(); + assert_ne!(u.get("disabled"), Some(&json!(true))); + assert!( + u["groups"] + .as_array() + .unwrap() + .iter() + .any(|g| g == ADMIN_GROUP) + ); + // Changes that take no local sign-in away pass: another group for the same administrator, + // and an account that is not a break-glass administrator. + let with_sales = vec![ + "base.user".to_string(), + ADMIN_GROUP.to_string(), + "sales.user".to_string(), + ]; + r.a.set_user_groups(&root, "root2", &with_sales, &known()) + .unwrap(); + user_with(&r.a, "carol", &["base.user"]); + r.a.set_user_disabled(&root, "carol", true).unwrap(); + r.a.offboard_user(&root, "carol", "left").unwrap(); + // A third local administrator makes room for one of them again. + user_with(&r.a, "root4", &[ADMIN_GROUP]); + enrol(&r.a, "root4"); + r.a.set_user_disabled(&root, "root2", true).unwrap(); +} + +#[test] +fn the_last_administrator_rule_does_not_count_microsoft_only_administrators() { + let r = rig(); + user_with(&r.a, "solo", &[ADMIN_GROUP]); + user_with(&r.a, "boss", &[ADMIN_GROUP]); + // `boss` signs in with Microsoft only (the state, as the flag leaves it). + let mut f = Fields::new(); + f.insert("sso_only".into(), json!(true)); + r.b.update("auth_user", "boss", &f).unwrap(); + let boss = admin("boss"); + for refusal in [ + r.a.set_user_disabled(&boss, "solo", true), + r.a.offboard_user(&boss, "solo", "left").map(|_| ()), + r.a.set_user_groups(&boss, "solo", &["base.user".to_string()], &known()) + .map(|_| ()), + ] { + match refusal { + Err(MfaError::Invalid(m)) => { + assert!(m.contains("last enabled administrator"), "{m}"); + // The way out is named, because the list of administrators shows others. + assert!( + m.contains("not counted") && m.contains("obop admin sso only NAME off"), + "{m}" + ); + } + other => panic!("expected the last-administrator refusal, got {other:?}"), + } + } + // A second local administrator and it is allowed. + user_with(&r.a, "root2", &[ADMIN_GROUP]); + r.a.set_user_disabled(&boss, "solo", true).unwrap(); +} + +#[test] +fn turning_the_authenticator_app_off_or_an_administrators_reset_keeps_two_local_ones() { + let (r, secret2) = depending_world(); + // root2 turns the app off for itself: it is the only second factor it has. + r.a.set_clock_for_test(T0 + 60); + let t = mfa_token(&r.a, "root2"); + let (token, _) = session_of( + r.a.login_mfa(&t, Some(&code_at(&secret2, T0 + 60)), None) + .unwrap(), + ); + let p = r.a.resolve(&token).unwrap(); + r.a.set_clock_for_test(T0 + 120); + shortfall(r.a.disable(&p, PW, Some(&code_at(&secret2, T0 + 120)), None)); + // Another administrator resets it for them. + shortfall(r.a.admin_reset(&admin("root1"), "root2")); + // root2 still has the app and still counts. + assert_eq!( + sso(&r).bindings(&admin("root1")).unwrap()["break_glass"], + json!(["root1", "root2"]) + ); + // With a third local administrator the same request goes through. + user_with(&r.a, "root4", &[ADMIN_GROUP]); + enrol(&r.a, "root4"); + r.a.admin_reset(&admin("root1"), "root2").unwrap(); +} + +#[test] +fn removing_the_last_passkey_of_a_local_administrator_keeps_two_local_ones() { + let r = rig(); + for n in ["root1", "root3"] { + user_with(&r.a, n, &[ADMIN_GROUP]); + enrol(&r.a, n); + } + // An administrator whose only second factor is a passkey. + user_with(&r.a, "keys", &[ADMIN_GROUP]); + let (token, p) = session(&r.a, "keys"); + let mut key = SoftAuthenticator::es256(); + add(&r, &token, &p, &mut key, "Key").unwrap(); + let id = r.a.list(&p, &ctx()).unwrap()["passkeys"][0]["id"] + .as_str() + .unwrap() + .to_string(); + user_with(&r.a, "ada", &["base.user"]); + r.a.operator_sso_configure(TENANT, CLIENT, Some(SECRET), Some(true)) + .unwrap(); + r.a.operator_sso_bind("ada", ADA_OID).unwrap(); + signed_in_once(&r, ADA_OID); + sso_only(&r, "ada", true).unwrap(); + r.a.set_user_disabled(&admin("root1"), "root3", true) + .unwrap(); + let prove = |key: &mut SoftAuthenticator| { + let opts = r.a.proof_options(&p, &ctx()).unwrap(); + r.a.proof(&p, &ctx(), &key.get(&opts, ORIGIN)).unwrap(); + }; + prove(&mut key); + shortfall(r.a.remove(&p, &token, &id, &pw())); + assert_eq!( + r.a.list(&p, &ctx()).unwrap()["passkeys"] + .as_array() + .unwrap() + .len(), + 1 + ); + // With Microsoft no longer needed by anyone, it is allowed. + sso_only(&r, "ada", false).unwrap(); + prove(&mut key); + r.a.remove(&p, &token, &id, &pw()).unwrap(); +} + +#[test] +fn the_operator_is_warned_not_stopped_and_the_line_says_what_was_left() { + let (r, _) = depending_world(); + r.a.operator_reset_mfa("root2").unwrap(); + let reset = events(&r, "reset"); + assert!( + reset + .iter() + .any(|d| d.contains("WARNING") && d.contains("fewer than 2")), + "{reset:?}" + ); + r.a.operator_set_disabled("root1", true).unwrap(); + assert!( + events(&r, "user_disable") + .iter() + .any(|d| d.contains("WARNING")) + ); + // The operator's groups command and offboarding warn the same way. + user_with(&r.a, "root5", &[ADMIN_GROUP]); + enrol(&r.a, "root5"); + user_with(&r.a, "root6", &[ADMIN_GROUP]); + enrol(&r.a, "root6"); + r.a.operator_set_groups("root5", &["base.user".to_string()], &known()) + .unwrap(); + assert!( + events(&r, "user_groups") + .iter() + .any(|d| d.contains("WARNING")), + "{:?}", + events(&r, "user_groups") + ); + r.a.operator_offboard("root6", "left").unwrap(); + assert!( + events(&r, "user_offboard") + .iter() + .any(|d| d.contains("WARNING")) + ); +} + +#[test] +fn a_replaced_server_key_leaves_an_authenticator_app_that_does_not_count() { + // The flag is on, the secret is sealed under a key this server no longer holds: that + // administrator cannot sign in with the app and is not a break-glass administrator. + let (r, _) = depending_world(); + let mut f = Fields::new(); + f.insert("totp_secret".into(), json!("not-a-sealed-secret")); + r.b.update("auth_user", "root2", &f).unwrap(); + assert_eq!( + sso(&r).bindings(&admin("root1")).unwrap()["break_glass"], + json!(["root1"]), + "an app whose secret cannot be opened is no way in" + ); + assert_eq!( + sso(&r).bindings(&admin("root1")).unwrap()["break_glass_enough"], + false + ); +} + +#[test] +fn an_account_made_microsoft_only_needs_a_sign_in_on_its_binding_and_the_current_tenant() { + let r = world(); + // Bound but never used. + assert!(invalid(sso_only(&r, "ada", true)).contains("never signed in")); + // A sign-in of someone else's binding does not count. + r.a.operator_sso_bind("bob", BOB_OID).unwrap(); + signed_in_once(&r, BOB_OID); + assert!(invalid(sso_only(&r, "ada", true)).contains("never signed in")); + signed_in_once(&r, ADA_OID); + sso_only(&r, "ada", true).unwrap(); + let status = sso(&r).bindings(&admin("root1")).unwrap(); + let row = status["accounts"] + .as_array() + .unwrap() + .iter() + .find(|a| a["name"] == "ada") + .cloned() + .unwrap(); + assert!(row["last_login_at"].is_string() && row["binding_current"] == true); + // Turning it off hands the password back, but the old one is not: it must be changed. + sso_only(&r, "ada", false).unwrap(); + let u = r.b.get("auth_user", "ada").unwrap().unwrap(); + assert_eq!(u["must_change_password"], true); +} + +#[test] +fn the_tenant_pin_holds_after_the_provider_was_cleared() { + let r = world(); + let root = admin("root1"); + signed_in_once(&r, ADA_OID); + sso(&r).clear(&root).unwrap(); + // No provider record, a binding of the old tenant: another tenant is refused all the same. + assert!( + invalid(sso(&r).configure(&root, OTHER_TENANT, CLIENT, Some(SECRET), Some(true))) + .contains("unbind") + ); + // The same tenant again is fine, and the binding still works. + sso(&r) + .configure(&root, TENANT, CLIENT, Some(SECRET), Some(true)) + .unwrap(); + sso_only(&r, "ada", true).unwrap(); + // Unbound, the tenant can change. + sso_only(&r, "ada", false).unwrap(); + sso(&r).unbind(&root, "ada").unwrap(); + sso(&r).clear(&root).unwrap(); + sso(&r) + .configure(&root, OTHER_TENANT, CLIENT, Some(SECRET), Some(true)) + .unwrap(); +} + +#[test] +fn a_binding_of_another_tenant_than_the_providers_is_not_made_microsoft_only() { + let r = world(); + signed_in_once(&r, ADA_OID); + // The binding is rewritten as if it was made under another tenant (a provider that was + // changed behind the administrators' back): it can never match a sign-in. + let id = link_key(&format!("{AUTHORITY}/{TENANT}/v2.0"), TENANT, ADA_OID); + let mut f = Fields::new(); + f.insert("tid".into(), json!(OTHER_TENANT)); + r.b.update(LINK_TABLE, &id, &f).unwrap(); + assert!(invalid(sso_only(&r, "ada", true)).contains("not to the tenant")); + let status = sso(&r).bindings(&admin("root1")).unwrap(); + let row = status["accounts"] + .as_array() + .unwrap() + .iter() + .find(|a| a["name"] == "ada") + .cloned() + .unwrap(); + assert_eq!(row["binding_current"], false); +} + +/// What an unbind ends and what it leaves, as the line of the log says it: the sessions, and with +/// them the phone apps (a device is a sign-in like a session); not the agent tokens the account +/// made, and not an application connected through the OAuth sign-in for MCP clients. +#[test] +fn an_unbind_ends_the_sessions_and_the_phone_apps_but_not_agent_tokens_or_mcp_apps() { + use obop_commands::OAuthTokens; + let r = world(); + signed_in_once(&r, ADA_OID); + let (session_token, p) = session(&r.a, "ada"); + let scopes = vec!["base.user".to_string()]; + let agent = r.a.issue_agent_token(&p, &scopes, "bot").unwrap(); + // Two connected applications of the account: a grant that no device record belongs to (an MCP + // client), and one that a device record names (a phone app). + let connect = |grant: &str| -> String { + let mut g = Fields::new(); + g.insert("owner".into(), J::from("ada")); + g.insert("client_id".into(), J::from("c1")); + g.insert("revoked".into(), J::Bool(false)); + g.insert("expires_at".into(), J::from(T0 + 86_400)); + r.b.insert("oauth_grant", grant, &g).unwrap(); + let mut rf = Fields::new(); + rf.insert("grant".into(), J::from(grant)); + r.b.insert("oauth_refresh", &format!("refresh-{grant}"), &rf) + .unwrap(); + let (token, _) = + r.a.issue_oauth_access("ada", &scopes, "app", 3600, "https://x.test/mcp", grant) + .unwrap(); + let mut patch = Fields::new(); + patch.insert("access_id".into(), J::from(token_hash(&token))); + r.b.update("oauth_grant", grant, &patch).unwrap(); + token + }; + let mcp = connect("grant-mcp"); + let phone = connect("grant-phone"); + let mut d = Fields::new(); + for (k, v) in [ + ("user", "ada"), + ("state", "active"), + ("grant", "grant-phone"), + ("registered_at", "2026-10-31T10:00:00.000000Z"), + ("name", "Ada's phone"), + ("platform", "ios"), + ("client_name", "obop"), + ] { + d.insert(k.into(), J::from(v)); + } + r.b.insert("device", "dev-1", &d).unwrap(); + + sso(&r).unbind(&admin("root1"), "ada").unwrap(); + + let revoked = |table: &str, id: &str| r.b.get(table, id).unwrap().unwrap()["revoked"] == true; + // Ended: the session, and the phone (its device, grant, access token and refresh token). + assert!(r.a.resolve(&session_token).is_none()); + assert_eq!( + r.b.get("device", "dev-1").unwrap().unwrap()["state"], + "revoked" + ); + assert!(revoked("oauth_grant", "grant-phone")); + assert!(revoked("auth_token", &token_hash(&phone))); + assert!( + r.b.get("oauth_refresh", "refresh-grant-phone") + .unwrap() + .is_none() + ); + // Not ended: the agent token and the MCP application, which the line of the log says. + assert!(r.a.resolve(&agent).is_some(), "the agent token works"); + assert!(!revoked("oauth_grant", "grant-mcp")); + assert!(!revoked("auth_token", &token_hash(&mcp))); + assert!( + r.b.get("oauth_refresh", "refresh-grant-mcp") + .unwrap() + .is_some() + ); + let line = events(&r, "oidc_unbind").pop().unwrap(); + assert!( + line.contains("the phone apps of the account with them") + && line.contains("agent tokens") + && line.contains("for MCP clients") + && line.contains("keep working"), + "{line}" + ); +} + +#[test] +fn unbinding_ends_the_sessions_and_a_code_in_the_air_and_says_how_many() { + let r = world(); + signed_in_once(&r, ADA_OID); + let (one, _) = session(&r.a, "ada"); + let (two, _) = session(&r.a, "ada"); + let code = sso(&r).begin(&identity(&r, ADA_OID), BINDER).unwrap(); + let done = sso(&r).unbind(&admin("root1"), "ada").unwrap(); + assert_eq!(done["changed"], true); + assert!( + done["sessions_ended"].as_u64().unwrap() >= 2, + "the two sessions made here at least: {done}" + ); + assert!(r.a.resolve(&one).is_none() && r.a.resolve(&two).is_none()); + assert!(matches!( + sso(&r).exchange(&code, BINDER), + Err(MfaError::Expired) + )); + let line = events(&r, "oidc_unbind"); + assert!(line[0].contains("session(s) signed out") && line[0].contains("agent tokens")); + // The account is still there with its password, and the binding is gone. + assert!(r.a.login_step("ada", PW).is_ok()); + assert!(invalid(sso(&r).unbind(&admin("root1"), "ada")).contains("not bound")); +} + +#[test] +fn an_unbinding_that_could_not_end_the_sessions_is_finished_by_repeating_it() { + use obop_storage::testing::Mode; + let (r, flaky) = crate::admin_tests::flaky_rig(); + for n in ["root1", "root2", "root3"] { + user_with(&r.a, n, &[ADMIN_GROUP]); + } + user_with(&r.a, "ada", &["base.user"]); + r.a.operator_sso_configure(TENANT, CLIENT, Some(SECRET), Some(true)) + .unwrap(); + r.a.operator_sso_bind("ada", ADA_OID).unwrap(); + signed_in_once(&r, ADA_OID); + let (stolen, _) = session(&r.a, "ada"); + flaky.refuse_model("auth_token", Mode::Reads); + let (done, out) = crate::admin_tests::request(|| sso(&r).unbind(&admin("root1"), "ada")); + flaky.allow_models(); + assert!(done.is_err() && out.partial, "{done:?} {out:?}"); + // The repeat ends what is left and reports it; the binding is gone either way. + let again = sso(&r).unbind(&admin("root1"), "ada").unwrap(); + assert_eq!(again["changed"], false); + assert!(again["sessions_ended"].as_u64().unwrap() >= 1, "{again}"); + assert!(r.a.resolve(&stolen).is_none()); +} + +#[test] +fn an_unbinding_refused_at_any_call_never_leaves_a_session_alive_when_it_says_done() { + let mut met = 0; + for skip in 0..80 { + let (r, flaky) = crate::admin_tests::flaky_rig(); + for n in ["root1", "root2"] { + user_with(&r.a, n, &[ADMIN_GROUP]); + } + user_with(&r.a, "ada", &["base.user"]); + r.a.operator_sso_configure(TENANT, CLIENT, Some(SECRET), Some(true)) + .unwrap(); + r.a.operator_sso_bind("ada", ADA_OID).unwrap(); + let (old, _) = session(&r.a, "ada"); + flaky.refuse_call_after(skip); + let (done, _) = crate::admin_tests::request(|| sso(&r).unbind(&admin("root1"), "ada")); + met += i32::from(flaky.refused() > 0); + flaky.allow_models(); + if done.is_ok() { + assert!( + r.a.resolve(&old).is_none(), + "skip {skip}: said done, session alive" + ); + } + // Repeating finishes it, whatever stopped it. + let _ = sso(&r).unbind(&admin("root1"), "ada"); + assert!(r.a.resolve(&old).is_none(), "skip {skip}: {done:?}"); + let id = link_key(&format!("{AUTHORITY}/{TENANT}/v2.0"), TENANT, ADA_OID); + assert!(r.b.get(LINK_TABLE, &id).unwrap().is_none(), "skip {skip}"); + } + assert!(met >= 8, "the sweep met a refusal at {met} positions"); +} + +// ---- more than one process: a code made on one server is spent on another, once -------------- + +fn second_auth(r: &Rig) -> Auth { + let a = Auth::with_test_config(r.b.clone(), AuthConfig::insecure_dev()); + a.set_clock_for_test(T0); + a +} + +#[test] +fn a_code_begun_on_one_server_is_spent_on_another_and_by_only_one_of_two_racers() { + let r = world(); + let other = second_auth(&r); + let id = identity(&r, ADA_OID); + // Begun here, spent there (two server processes on one database). + let code = sso(&r).begin(&id, BINDER).unwrap(); + let (who, _) = other.sso().unwrap().exchange(&code, BINDER).unwrap(); + assert_eq!(who, "ada"); + assert!(matches!( + sso(&r).exchange(&code, BINDER), + Err(MfaError::Expired) + )); + // Two racers for one code, one on each server, released together: exactly one wins, every + // time. (The sharper race, eight racers and 300 rounds, is the next test.) + for round in 0..25 { + let code = sso(&r).begin(&id, BINDER).unwrap(); + let gate = std::sync::Barrier::new(2); + let (x, y) = std::thread::scope(|s| { + let a = s.spawn(|| { + gate.wait(); + sso(&r).exchange(&code, BINDER).is_ok() + }); + let b = s.spawn(|| { + gate.wait(); + other.sso().unwrap().exchange(&code, BINDER).is_ok() + }); + (a.join().unwrap(), b.join().unwrap()) + }); + assert_eq!( + usize::from(x) + usize::from(y), + 1, + "round {round}: one winner" + ); + } +} + +/// Many readers of one code: only the one whose delete removed it may go on (D59: several server +/// processes on one database). `code_take` reads the code and then deletes it, and only the delete +/// is the claim, so the racers are released at once by a barrier, eight of them, in many rounds; +/// without the barrier a thread that starts first has finished before the next one is running. +#[test] +fn eight_racers_for_one_code_leave_exactly_one_winner_in_every_round() { + let (_flaky, db) = obop_storage::testing::Flaky::memory(); + let a = Auth::with_test_config(db.clone(), AuthConfig::insecure_dev()); + a.set_clock_for_test(T0); + for round in 0..300 { + let code = format!("obopsso_race_{round}"); + let rec = json!({ + "user": "ada", + "binder": "-", + "expires_at": T0 + CODE_SECS, + }); + db.kv_put(&code_key(&code), &rec.to_string(), Some(CODE_SECS * 1000)) + .unwrap(); + let gate = std::sync::Barrier::new(8); + let winners = std::thread::scope(|s| { + let racers: Vec<_> = (0..8) + .map(|_| { + s.spawn(|| { + gate.wait(); + a.code_take(&code).is_some() + }) + }) + .collect(); + racers + .into_iter() + .map(|h| h.join().unwrap()) + .filter(|won| *won) + .count() + }); + assert_eq!(winners, 1, "round {round}: one winner"); + assert!(db.kv_get(&code_key(&code)).unwrap().is_none()); + } +} + +/// The same flow on a real PostgreSQL: `OBOP_TEST_POSTGRES_URL=postgres://... cargo test -p obop-auth`. +/// The provider and its bindings are one set per database, so the test clears what it made. +#[test] +fn sign_in_with_microsoft_works_on_postgres_when_available() { + let Ok(url) = std::env::var("OBOP_TEST_POSTGRES_URL") else { + eprintln!("skipped: set OBOP_TEST_POSTGRES_URL to run against PostgreSQL"); + return; + }; + let b = obop_storage::open(&url).unwrap(); + let a = Auth::with_test_config(b.clone(), AuthConfig::insecure_dev()); + a.set_clock_for_test(T0); + let pid = std::process::id(); + let (user, oid) = ( + format!("pgsso{pid}"), + format!("{pid:08x}-1111-2222-3333-444444444444"), + ); + let tenant = format!("{pid:08x}-2222-3333-4444-555555555555"); + // Whatever an earlier, interrupted run left. + for (id, f) in b.list(LINK_TABLE).unwrap() { + if f["user"].as_str().is_some_and(|u| u.starts_with("pgsso")) { + b.delete(LINK_TABLE, &id).unwrap(); + } + } + let _ = a.operator_sso_clear(); + a.create_user(&user, PW, &["base.user".to_string()]) + .unwrap(); + a.operator_sso_configure(&tenant, CLIENT, Some(SECRET), Some(true)) + .unwrap(); + a.operator_sso_bind(&user, &oid).unwrap(); + let s = StandIn::new(); + let claims = StandIn::claims(AUTHORITY, &tenant, CLIENT, NONCE, &oid, a.now()); + let api = a.sso().unwrap(); + let id = api + .verify(&s.token(&claims), s.jwks().as_bytes(), NONCE) + .unwrap(); + // Begun on one connection pool, spent on another, once. + let other = Auth::with_test_config( + obop_storage::open(&url).unwrap(), + AuthConfig::insecure_dev(), + ); + other.set_clock_for_test(T0); + // Several rounds, the racers released together; a round's winner is the one whose delete + // removed the code. + for round in 0..10 { + let code = api.begin(&id, BINDER).unwrap(); + let gate = std::sync::Barrier::new(2); + let (x, y) = std::thread::scope(|sc| { + let h1 = sc.spawn(|| { + gate.wait(); + api.exchange(&code, BINDER).is_ok() + }); + let h2 = sc.spawn(|| { + gate.wait(); + other.sso().unwrap().exchange(&code, BINDER).is_ok() + }); + (h1.join().unwrap(), h2.join().unwrap()) + }); + assert_eq!(usize::from(x) + usize::from(y), 1, "round {round}"); + } + // Unbinding ends the session this made. + let admin = Principal::user("root", &[ADMIN_GROUP]); + let done = api.unbind(&admin, &user).unwrap(); + assert!(done["sessions_ended"].as_u64().unwrap() >= 1); + api.clear(&admin).unwrap(); +} + +#[test] +fn a_right_password_on_a_microsoft_only_account_locks_it_like_a_wrong_one() { + // Counted like a wrong password, a right one cannot be told from a wrong one by the lockout. + let r = world(); + signed_in_once(&r, ADA_OID); + sso_only(&r, "ada", true).unwrap(); + let said = |r: &Rig, pw: &str| r.a.login_step("ada", pw).unwrap_err(); + let refused = said(&r, PW); + assert_eq!(refused, said(&r, "not the password")); + for _ in 0..4 { + said(&r, PW); + } + // Five failures lock the account whatever was typed: the two attempts above and the first + // three of the loop are five (right passwords count like wrong ones), and the attempts after + // them get the lockout's answer, as after five wrong ones (the sixth is the last of the loop, + // the seventh and the eighth are the two that are asserted). + assert!(said(&r, PW).contains("too many attempts"), "locked"); + assert!(said(&r, "not the password").contains("too many attempts")); +} + +#[test] +fn the_provider_is_checked_again_when_the_code_is_spent_not_only_when_it_is_made() { + let r = world(); + let id = identity(&r, ADA_OID); + // Switched off between the callback and the exchange. + let code = sso(&r).begin(&id, BINDER).unwrap(); + r.a.operator_sso_configure(TENANT, CLIENT, None, Some(false)) + .unwrap(); + assert!(matches!( + sso(&r).exchange(&code, BINDER), + Err(MfaError::Expired) + )); + assert!( + events(&r, "oidc_refused") + .iter() + .any(|d| d.contains("switched off before the sign-in finished")) + ); + r.a.operator_sso_configure(TENANT, CLIENT, None, Some(true)) + .unwrap(); + // Cleared between the callback and the exchange. + let code = sso(&r).begin(&id, BINDER).unwrap(); + sso(&r).clear(&admin("root1")).unwrap(); + assert!(matches!( + sso(&r).exchange(&code, BINDER), + Err(MfaError::Expired) + )); + assert!( + events(&r, "oidc_refused") + .iter() + .any(|d| d.contains("removed before the sign-in finished")) + ); + // Moved to another tenant (the binding's rewritten to model it) between the two. + sso(&r) + .configure(&admin("root1"), TENANT, CLIENT, Some(SECRET), Some(true)) + .unwrap(); + let code = sso(&r).begin(&id, BINDER).unwrap(); + let link = link_key(&format!("{AUTHORITY}/{TENANT}/v2.0"), TENANT, ADA_OID); + let mut f = Fields::new(); + f.insert("tid".into(), json!(OTHER_TENANT)); + r.b.update(LINK_TABLE, &link, &f).unwrap(); + assert!(matches!( + sso(&r).exchange(&code, BINDER), + Err(MfaError::Expired) + )); + assert!( + events(&r, "oidc_refused") + .iter() + .any(|d| d.contains("another tenant than the binding")) + ); +} + +/// Seconds later: onto a new step of the authenticator app, with the lockout counter of `ada` +/// emptied (it runs on the real clock, not on the test clock). +fn later(r: &Rig) -> u64 { + r.a.set_clock_for_test(r.a.now() + 61); + r.a.clear_failures_for_test("ada"); + r.a.now() +} + +/// How many wrong passwords the lockout has counted for `ada`. +fn counted(r: &Rig) -> u64 { + r.b.counter_get(&Auth::failure_key("ada")).unwrap() +} + +#[test] +fn a_leftover_password_proves_nothing_for_a_microsoft_only_account_on_any_path() { + // The hash is kept while the flag is on (turning it off gives the password back), so no step + // that asks for the password of a signed-in person may accept it: changing the password, + // turning the app off, new recovery codes, and the re-authentication that adding, renaming + // and removing a passkey and the passwordless switch are built on (and any later one). + let r = world(); + let (token0, p0) = session(&r.a, "ada"); + let mut key = SoftAuthenticator::es256(); + add(&r, &token0, &p0, &mut key, "Phone").unwrap(); + let begun = r.a.begin(&p0).unwrap(); + let secret = crate::totp::base32_decode(begun["secret"].as_str().unwrap()).unwrap(); + r.a.confirm(&p0, &token0, &code_at(&secret, T0)).unwrap(); + let id = r.a.list(&p0, &ctx()).unwrap()["passkeys"][0]["id"] + .as_str() + .unwrap() + .to_string(); + signed_in_once(&r, ADA_OID); + sso_only(&r, "ada", true).unwrap(); + // The whole session Microsoft's second factor gives. + let code = sso(&r).begin(&identity(&r, ADA_OID), BINDER).unwrap(); + let (token, limited) = session_of(sso(&r).exchange(&code, BINDER).unwrap().1); + assert!(!limited); + let p = r.a.resolve(&token).unwrap(); + let prove = |r: &Rig, key: &mut SoftAuthenticator| { + let opts = r.a.proof_options(&p, &ctx()).unwrap(); + r.a.proof(&p, &ctx(), &key.get(&opts, ORIGIN)).unwrap(); + }; + // Each refusal is a wrong password for the lockout, so that the leftover cannot be guessed at. + let bad = |what: &str, got: Result<(), MfaError>| { + assert!(matches!(got, Err(MfaError::BadPassword)), "{what}: {got:?}"); + assert_eq!(counted(&r), 1, "{what}: counted like a wrong password"); + }; + + // The right password, a right code, a right passkey proof: refused on every path. + let now = later(&r); + match r.a.change_password( + &p, + &token, + PW, + "a brand new password", + Some(&code_at(&secret, now)), + None, + ) { + Err(MfaError::Invalid(m)) => { + assert!(m.contains("Microsoft only") && m.contains("no password")) + } + other => panic!("expected a refusal in words, got {other:?}"), + } + assert_eq!(counted(&r), 0, "the answer does not depend on the password"); + let now = later(&r); + bad( + "disable", + r.a.disable(&p, PW, Some(&code_at(&secret, now)), None), + ); + let now = later(&r); + bad( + "regenerate_recovery", + r.a.regenerate_recovery(&p, PW, Some(&code_at(&secret, now)), None) + .map(|_| ()), + ); + later(&r); + prove(&r, &mut key); + bad( + "register_options", + r.a.register_options(&p, &ctx(), &pw()).map(|_| ()), + ); + later(&r); + prove(&r, &mut key); + bad("rename", r.a.rename(&p, &id, "Renamed", &pw())); + later(&r); + prove(&r, &mut key); + bad("remove", r.a.remove(&p, &token, &id, &pw())); + later(&r); + prove(&r, &mut key); + bad("set_passwordless", r.a.set_passwordless(&p, true, &pw())); + // Nothing changed, and each refusal of the password was counted and written down. + assert!(r.a.mfa_enabled("ada") && r.a.passkey_count("ada") == 1); + assert!(events(&r, "recovery_regenerate").is_empty()); + assert!(events(&r, "disable").is_empty()); + assert_eq!(events(&r, "passkey_failed").len(), 4); + later(&r); + assert!( + r.a.resolve(&token).is_some(), + "the session itself is untouched" + ); + + // Turned off, the password is the person's again, and every path works with it. + sso_only(&r, "ada", false).unwrap(); + let now = later(&r); + let t = mfa_token(&r.a, "ada"); + let (token, _) = session_of( + r.a.login_mfa(&t, Some(&code_at(&secret, now)), None) + .unwrap(), + ); + let p = r.a.resolve(&token).unwrap(); + let now = later(&r); + r.a.regenerate_recovery(&p, PW, Some(&code_at(&secret, now)), None) + .unwrap(); + later(&r); + 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 now = later(&r); + r.a.change_password( + &p, + &token, + PW, + "a brand new password", + Some(&code_at(&secret, now)), + None, + ) + .unwrap(); + // The password is the new one now, and turning the app off works with it. + let now = later(&r); + r.a.disable( + &p, + "a brand new password", + Some(&code_at(&secret, now)), + None, + ) + .unwrap(); + assert!(!r.a.mfa_enabled("ada")); +} + +#[test] +fn a_store_that_cannot_be_read_is_never_a_yes_for_the_password_of_a_microsoft_only_account() { + // The answer is "no" for the account and "unavailable" for a refused read; at no position of + // the call does a refused store make the leftover hash count. + let (r, flaky) = crate::admin_tests::flaky_rig(); + for n in ["root1", "root2", "root3"] { + user_with(&r.a, n, &[ADMIN_GROUP]); + enrol(&r.a, n); + } + user_with(&r.a, "ada", &["base.user"]); + r.a.operator_sso_configure(TENANT, CLIENT, Some(SECRET), Some(true)) + .unwrap(); + r.a.operator_sso_bind("ada", ADA_OID).unwrap(); + signed_in_once(&r, ADA_OID); + assert!(r.a.password_ok("ada", PW).unwrap(), "an ordinary account"); + sso_only(&r, "ada", true).unwrap(); + assert!(!r.a.password_ok("ada", PW).unwrap()); + let mut unavailable = 0; + for skip in 0..6 { + flaky.refuse_call_after(skip); + match r.a.password_ok("ada", PW) { + Ok(true) => panic!("a refused read at position {skip} made the password count"), + Ok(false) => {} + Err(MfaError::Unavailable(_)) => unavailable += 1, + Err(other) => panic!("position {skip}: {other:?}"), + } + } + flaky.refuse_call_after(1_000); + assert!( + unavailable >= 2, + "both reads of the account are checked: {unavailable}" + ); +} 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..96a42ac0 --- /dev/null +++ b/crates/kernel/obop-auth/src/oidc/verify.rs @@ -0,0 +1,397 @@ +//! The checks of an OpenID Connect id token from Microsoft Entra (D91, ratified by OR-38), 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 non-negative whole number that fits in 64 bits. +//! +//! 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(), + }) +} + +/// Microsoft's documented tenant id of personal accounts (the GUID that `consumers` stands for). +const CONSUMERS_TENANT: &str = "9188040d-6c67-4c5b-b112-36a304b66dad"; + +/// 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)" + )), + // The consumer (personal Microsoft account) tenant by its id: the GUID form of `consumers`. + CONSUMERS_TENANT => Err(format!( + "{t:?} is the tenant of personal Microsoft accounts, the same as consumers: sign-in is pinned to one organisation's tenant" + )), + _ 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); + // The size is that of the number: zeros in front do not make a 1024-bit key a 2048-bit one. + let n = n.map(|n| { + let lead = n.iter().take_while(|b| **b == 0).count(); + n[lead..].to_vec() + }); + 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"), + } + } +} + +// ---- 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 < 18_446_744_073_709_551_616.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 (oid); Entra sends it only when the profile scope is asked for and granted, and obop always asks for openid and profile, so the permissions of the app registration are the usual cause", + ); + }; + // `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..a457e720 --- /dev/null +++ b/crates/kernel/obop-auth/src/oidc/verify_tests.rs @@ -0,0 +1,823 @@ +//! 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 its row fail (the mutations that were tried, each with the tests that then +//! fail, are listed in docs/ISSUES.md, "Mutation checks of D91"). + +use super::soft::{Entra, 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); +} + +/// A GUID is the same GUID in any case: the pinned tenant and the binding key are made from the +/// lower-case form, so a token that spells the tenant or the object id in capitals is the same +/// sign-in (and an identity is never two bindings). The tenant here has letters in it, so that +/// capitals are not the same text. +#[test] +fn the_tenant_and_the_object_id_are_compared_as_guids_not_as_text() { + const LETTERS: &str = "abcdef01-2222-3333-4444-555555555555"; + let s = StandIn::new(); + let mut c = StandIn::claims(AUTHORITY, LETTERS, CLIENT, NONCE, OID, NOW); + c["tid"] = J::from(LETTERS.to_uppercase()); + c["oid"] = J::from(OID.to_uppercase()); + assert_ne!(c["tid"], LETTERS); + assert_ne!(c["oid"], OID); + let exp = Expect { + tenant: LETTERS, + ..expect() + }; + let id = verify_id_token(&s.token(&c), &keys(&s), &exp).unwrap(); + assert_eq!(id.tenant, LETTERS); + assert_eq!(id.oid, OID); +} + +#[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", + // The GUID form of `consumers`: every personal Microsoft account. + "9188040d-6c67-4c5b-b112-36a304b66dad", + "9188040D-6C67-4C5B-B112-36A304B66DAD", + "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); +} + +/// A time that does not fit in 64 bits is not turned into the largest time (a saturating cast +/// would make an expiry of `1e30` "never"): it is not a time claim at all. +#[test] +fn a_time_claim_that_does_not_fit_in_64_bits_is_refused_as_the_expiry() { + check(|c| c["exp"] = json!(1e30), "a time claim is not a number"); + check( + |c| c["exp"] = json!(18_446_744_073_709_551_616.0f64), + "a time claim is not a number", + ); + check(|c| c["exp"] = json!(-1), "a time claim is not a number"); +} + +#[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 + ); + // Only a non-negative whole number is kept. + assert_eq!(id(|c| c["auth_time"] = json!(-1)).auth_time, None); + assert_eq!(id(|c| c["auth_time"] = json!(1.5)).auth_time, None); + assert_eq!(id(|c| c["auth_time"] = json!(true)).auth_time, None); + // A number that does not fit in 64 bits is not saturated into the largest time: it is dropped + // (and as the expiry it is refused, below). + assert_eq!(id(|c| c["auth_time"] = json!(1e30)).auth_time, None); + assert_eq!( + id(|c| c["auth_time"] = json!(18_446_744_073_709_551_616.0f64)).auth_time, + None + ); + assert_eq!(id(|c| c["auth_time"] = J::Null).auth_time, None); + assert_eq!( + id(|c| c["auth_time"] = json!(1_700_000_000u64)).auth_time, + Some(1_700_000_000) + ); +} + +#[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) + ); + // 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) + ); + // A key set that publishes another modulus 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 mut other_alg = s.jwk_for("rs384"); + other_alg["alg"] = J::from("RS384"); + let mut encryption = s.jwk_for("enc"); + encryption["use"] = J::from("enc"); + // The key as Microsoft publishes it carries no `alg` and a certificate chain: it stays. + let good = { + assert!(good.get("alg").is_none() && good.get("x5c").is_some()); + good + }; + let set = Jwks::parse( + StandIn::jwks_of(&[ + good, short, ec, noverify, bigexp, noid, other_alg, encryption, + ]) + .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()); +} + +#[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()); + // The right issuer with something after it is another issuer (a prefix is not a match). + assert!(doc(&|d| d["issuer"] = J::from(format!("{AUTHORITY}/{TENANT}/v2.0/"))).is_err()); + assert!(doc(&|d| d["issuer"] = J::from(format!("{AUTHORITY}/{TENANT}/v2.0x"))).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()); +} + +/// What an app registration that asks for `openid` only gets from Entra, by Microsoft's +/// documentation: no `oid`. The refusal says what to look at. +#[test] +fn a_token_without_the_profile_scope_has_no_object_id_and_the_refusal_says_what_to_check() { + let s = StandIn::new(); + let c = StandIn::claims_for( + AUTHORITY, + TENANT, + CLIENT, + NONCE, + OID, + NOW, + &Entra::configured("openid"), + ); + assert!( + c.get("oid").is_none() && c.get("preferred_username").is_none() && c.get("name").is_none() + ); + refused( + verify_id_token(&s.token(&c), &keys(&s), &expect()), + "profile scope", + ); + // With the scope it is a sign-in, and none of the profile claims is part of the identity. + let c = StandIn::claims_for( + AUTHORITY, + TENANT, + CLIENT, + NONCE, + OID, + NOW, + &Entra::configured("openid profile"), + ); + assert!(c["preferred_username"].is_string() && c["name"].is_string()); + let id = verify_id_token(&s.token(&c), &keys(&s), &expect()).unwrap(); + assert_eq!(id.oid, OID); +} + +/// An app registration with no optional claims gets no `amr` and no `auth_time`: no second factor +/// is reported, and the sign-in is still a sign-in. +#[test] +fn without_the_optional_claims_there_is_no_amr_and_no_auth_time() { + let s = StandIn::new(); + let c = StandIn::claims_for( + AUTHORITY, + TENANT, + CLIENT, + NONCE, + OID, + NOW, + &Entra::plain("openid profile"), + ); + assert!(c.get("amr").is_none() && c.get("auth_time").is_none()); + let id = verify_id_token(&s.token(&c), &keys(&s), &expect()).unwrap(); + assert!(!id.mfa()); + assert_eq!(id.auth_time, None); + assert_eq!(id.oid, OID); +} + +/// Microsoft documents more values than `mfa`: `ngcmfa` is "Equivalent to `mfa`, used for +/// provisioning of certain advanced credential types" and `wiaormfa` is "The user used Windows or +/// an MFA credential to authenticate" (the access token claims reference). D91 accepts the exact +/// string `mfa` and nothing that merely contains it, as its own conservative choice. In +/// Microsoft's table of values per method the methods that carry `ngcmfa` carry `mfa` too (the +/// optional claims reference), so they count; the last check pins that. +#[test] +fn only_the_exact_value_mfa_counts_as_a_second_factor() { + let id = |amr: J| { + let (s, t) = with(|c| c["amr"] = amr); + verify_id_token(&t, &keys(&s), &expect()).unwrap() + }; + assert!(id(json!(["mfa"])).mfa()); + assert!(id(json!(["pwd", "mfa"])).mfa()); + assert!(id(json!(["pwd", "otp", "mfa", "fed"])).mfa()); + for other in [ + "wiaormfa", + "ngcmfa", + "nomfa", + "mfa ", + " mfa", + "mfa\n", + "multifactor", + "otp", + "pwd", + "fido", + ] { + assert!( + !id(json!(["pwd", other])).mfa(), + "{other:?} is not the value mfa" + ); + } + // The sets Microsoft lists for Authenticator push and for Windows Hello for Business carry + // `mfa` next to `ngcmfa`: they count. `ngcmfa` alone does not. + assert!(id(json!(["rsa", "ngcmfa", "mfa"])).mfa()); + assert!(id(json!(["hwk", "mfa", "ngcmfa"])).mfa()); + assert!(!id(json!(["hwk", "ngcmfa"])).mfa()); +} + +/// The e-mail address, the user principal name, the display name and `sub` are not the identity: +/// the same `oid` is the same person whatever they say, and an address with another `oid` is +/// somebody else. +#[test] +fn the_identity_is_the_object_id_and_no_address_or_name_changes_it() { + let who = |f: fn(&mut J)| { + let (s, t) = with(f); + verify_id_token(&t, &keys(&s), &expect()).unwrap() + }; + let base = who(|_| {}); + let changed = who(|c| { + c["email"] = J::from("someone.else@example.test"); + c["upn"] = J::from("someone.else@example.test"); + c["preferred_username"] = J::from("someone.else@example.test"); + c["unique_name"] = J::from("someone.else@example.test"); + c["name"] = J::from("Somebody Else"); + c["sub"] = J::from("another-pairwise-subject"); + }); + assert_eq!(base, changed); + // The bound person's address, and another object id: another identity (the binding decides). + let other = who(|c| { + c["preferred_username"] = J::from("ada@example.test"); + c["email"] = J::from("ada@example.test"); + c["oid"] = J::from("0e0e0e0e-1111-2222-3333-444444444444"); + }); + assert_ne!(base.oid, other.oid); + // An address and a name and no object id at all are no identity. + check( + |c| { + remove(c, "oid"); + c["email"] = J::from("ada@example.test"); + c["upn"] = J::from("ada@example.test"); + c["preferred_username"] = J::from("ada@example.test"); + }, + "object id", + ); +} + +/// A token another key signed under the right key name: the signature is checked with a real +/// second RSA key, so the refusal is a failed verification and not a rejected key. +#[test] +fn a_token_signed_by_another_key_under_the_right_name_is_refused() { + let published = StandIn::new(); + let forger = StandIn::with_other_key(); + assert_eq!(forger.kid, published.kid); + refused( + verify_id_token(&forger.token(&claims()), &keys(&published), &expect()), + "signature", + ); + // The forger's own key set accepts it, so the refusal above is about the key and nothing else. + assert!(verify_id_token(&forger.token(&claims()), &keys(&forger), &expect()).is_ok()); +} + +/// The size of a key is the size of the number, not the length of its encoding: a 1024-bit +/// modulus padded with zeros up to 2048 bits is not a 2048-bit key. +#[test] +fn a_short_key_padded_with_zeros_is_not_a_2048_bit_key() { + let s = StandIn::new(); + let mut n = vec![0u8; 128]; + let mut short = vec![0xB5u8; 128]; + short[127] |= 1; + n.extend_from_slice(&short); + assert_eq!(n.len(), 256); + let jwk = json!({"kty": "RSA", "use": "sig", "alg": "RS256", "kid": "padded", "n": b64url(&n), "e": b64url(&[1, 0, 1])}); + assert!(Jwks::parse(StandIn::jwks_of(std::slice::from_ref(&jwk)).as_bytes()).is_err()); + // Next to a real key it is left out, not trusted. + let set = Jwks::parse(StandIn::jwks_of(&[jwk, s.jwk_for("real")]).as_bytes()).unwrap(); + let t = s.sign(&json!({"alg": "RS256", "kid": "padded"}), &claims()); + assert_eq!( + verify_id_token(&t, &set, &expect()), + Err(VerifyError::UnknownKey) + ); +} + +#[test] +fn rows_the_review_of_the_mutations_found_missing() { + // aud as a list that does not hold the client, with an azp that is the client (M6a). + check( + |c| { + c["aud"] = json!(["someone-else"]); + c["azp"] = J::from(CLIENT); + }, + "audience", + ); + // The nonce is compared as it is, not case-insensitively (M9a). + check(|c| c["nonce"] = J::from(NONCE.to_uppercase()), "nonce"); + // The tenant id of the token is read in lower case, so an upper-case one is the same tenant (M18b). + let (s, t) = with(|c| c["tid"] = J::from(TENANT.to_uppercase())); + assert_eq!( + verify_id_token(&t, &keys(&s), &expect()).unwrap().tenant, + TENANT + ); +} diff --git a/crates/kernel/obop-auth/src/passkey.rs b/crates/kernel/obop-auth/src/passkey.rs index a268d6eb..b756939f 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, @@ -817,6 +827,11 @@ impl PasskeyApi for Auth { "your administrator requires a second factor, and this is your last one: add another passkey or an authenticator app first".into(), )); } + // The last passkey of an administrator who has no authenticator app that works: while + // accounts depend on Microsoft, two administrators keep a sign-in of their own (D91). + if left == 0 && !self.working_totp(name, &self.user_record(name)?) { + self.guard_break_glass(name, false)?; + } let keep = token_hash(session); let mut patch = Fields::new(); if !all 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-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-auth/testdata/rsa2048-test-key-b.der b/crates/kernel/obop-auth/testdata/rsa2048-test-key-b.der new file mode 100644 index 00000000..dfd189a9 Binary files /dev/null and b/crates/kernel/obop-auth/testdata/rsa2048-test-key-b.der differ diff --git a/crates/kernel/obop-commands/src/lib.rs b/crates/kernel/obop-commands/src/lib.rs index 8821575e..1ee54232 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, ratified by OR-38), 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,9 @@ pub use public::{ Adjust, Lookup, PublicApi, PublicFilter, PublicRead, PublicReceipt, PublicRecord, PublicSpec, PublicSubmit, Receipt, Submission, SubmitError, }; +pub use sso::{ + AnonGroup, AnonLine, SsoApi, SsoEndpoints, SsoError, SsoIdentity, SsoLeg, SsoProvider, +}; pub use store::Store; pub mod names { @@ -1756,6 +1765,8 @@ mod limits_tests; #[cfg(test)] mod shows_option_tests; #[cfg(test)] +mod sso_anon_tests; +#[cfg(test)] mod sysfields_tests; #[cfg(test)] mod tests; diff --git a/crates/kernel/obop-commands/src/sso.rs b/crates/kernel/obop-commands/src/sso.rs new file mode 100644 index 00000000..97701770 --- /dev/null +++ b/crates/kernel/obop-commands/src/sso.rs @@ -0,0 +1,308 @@ +//! Sign-in with an external identity provider (D91, ratified by OR-38): 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}; + +/// What a line of [`SsoApi::audit_throttled`] is about. A fixed set: it is part of the key under +/// which a line is counted, so a caller cannot choose it, and it decides which hourly ceiling +/// the line is counted against (`group`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AnonLine { + /// A sign-in was begun: the harmless line of the success path of the start. + Start, + /// A callback with a state that is not a flow of this server (unknown, spent or expired): + /// nobody has signed in for it, and anybody can send it. + NoFlow, + // A sign-in went wrong, at its start or after it was begun; each word is what stopped it. + /// The provider's settings could not be read. + Settings, + /// The discovery document could not be used. + Discovery, + /// The browser that came back is not the one that left. + Browser, + /// The provider ended the sign-in with an error (or the person said no). + Provider, + /// The provider's redirect carried no code. + NoCode, + /// The provider was switched off meanwhile. + SwitchedOff, + /// Too many calls to the provider were in the air. + Busy, + /// The token endpoint could not be reached. + TokenUnreachable, + /// The token endpoint answered 429 or 5xx. + TokenUnwell, + /// The token endpoint refused (an expired client secret shows here). + TokenRefused, + /// The key set could not be fetched. + KeySet, + /// The key set could not be used. + KeySetUnusable, + /// The id token was refused. + IdToken, + /// The account side could not finish the sign-in. + Begin, +} + +/// The groups of [`AnonLine`] that have an hourly ceiling each. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AnonGroup { + /// The lines of begun sign-ins. + Start, + /// The lines of callbacks that carry no flow of this server. + NoFlow, + /// The lines of sign-ins that went wrong, at their start or after they were begun. + Failed, +} + +impl AnonGroup { + pub const ALL: [AnonGroup; 3] = [AnonGroup::Start, AnonGroup::NoFlow, AnonGroup::Failed]; + + pub fn word(self) -> &'static str { + match self { + AnonGroup::Start => "start", + AnonGroup::NoFlow => "no_flow", + AnonGroup::Failed => "failed", + } + } +} + +impl AnonLine { + /// Every line, so that a test can forget the counters of all of them. + pub const ALL: [AnonLine; 16] = [ + AnonLine::Start, + AnonLine::NoFlow, + AnonLine::Settings, + AnonLine::Discovery, + AnonLine::Browser, + AnonLine::Provider, + AnonLine::NoCode, + AnonLine::SwitchedOff, + AnonLine::Busy, + AnonLine::TokenUnreachable, + AnonLine::TokenUnwell, + AnonLine::TokenRefused, + AnonLine::KeySet, + AnonLine::KeySetUnusable, + AnonLine::IdToken, + AnonLine::Begin, + ]; + + /// The ceiling the line is counted against: a begun sign-in, a callback that belongs to no + /// sign-in of this server, or a sign-in that went wrong after it was begun. + pub fn group(self) -> AnonGroup { + match self { + AnonLine::Start => AnonGroup::Start, + AnonLine::NoFlow => AnonGroup::NoFlow, + _ => AnonGroup::Failed, + } + } + + /// The word the line is keyed by. + pub fn word(self) -> &'static str { + match self { + AnonLine::Start => "start", + AnonLine::NoFlow => "no_flow", + AnonLine::Settings => "settings", + AnonLine::Discovery => "discovery", + AnonLine::Browser => "browser", + AnonLine::Provider => "provider", + AnonLine::NoCode => "no_code", + AnonLine::SwitchedOff => "switched_off", + AnonLine::Busy => "busy", + AnonLine::TokenUnreachable => "token_unreachable", + AnonLine::TokenUnwell => "token_unwell", + AnonLine::TokenRefused => "token_refused", + AnonLine::KeySet => "key_set", + AnonLine::KeySetUnusable => "key_set_unusable", + AnonLine::IdToken => "id_token", + AnonLine::Begin => "begin", + } + } +} + +/// 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. `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. The + /// code is spent also when `binder` is not the one it was issued for. The account's name + /// comes with the outcome, so the page can show who is signed in (also while a second factor + /// is still asked for). + fn exchange(&self, code: &str, binder: &str) -> Result<(String, LoginOutcome), MfaError>; + + /// 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); + + /// A line about a sign-in that has no account to be named under (a begun sign-in, a callback + /// that belongs to none, a sign-in that went wrong on the way): at most one security event per + /// `source`, kind and [`AnonLine`] in ten minutes (the text is not part of that, it can be + /// the caller's), and at most sixty lines an hour for all sources together in each of the + /// three groups of [`AnonLine::group`], so that a flood of one group does not hide another. + /// The first line past the ceiling of a group says so, and the first line of its next hour + /// says how many were not written. `source` is the caller's address as the HTTP layer buckets + /// it (an IPv6 address by its /64). `detail` must be a fixed text: nothing the caller sent + /// goes into it, except a word out of a fixed set. + fn audit_throttled(&self, source: &str, kind: &str, line: AnonLine, 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, + /// and `enabled` `None` keeps the stored switch (a first configuration is switched on). + fn configure( + &self, + admin: &Principal, + tenant: &str, + client_id: &str, + client_secret: Option<&str>, + enabled: Option, + ) -> 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; +} diff --git a/crates/kernel/obop-commands/src/sso_anon_tests.rs b/crates/kernel/obop-commands/src/sso_anon_tests.rs new file mode 100644 index 00000000..abe987cd --- /dev/null +++ b/crates/kernel/obop-commands/src/sso_anon_tests.rs @@ -0,0 +1,108 @@ +//! The sorts of anonymous line of the Microsoft sign-in (D91 Decision 9) and the hourly budget +//! each is counted against. The budget of a sort is a decision of the record (a begun sign-in, a +//! callback that belongs to no sign-in, or a sign-in that went wrong), so it is pinned for every +//! sort here, not only through the call sites that happen to have a test. + +use crate::{AnonGroup, AnonLine}; + +/// A place for every sort, from a `match` without a wildcard: a sort that is added does not +/// compile until it is placed here, and the test below then asks for it in `AnonLine::ALL`. +fn place(line: AnonLine) -> usize { + match line { + AnonLine::Start => 0, + AnonLine::NoFlow => 1, + AnonLine::Settings => 2, + AnonLine::Discovery => 3, + AnonLine::Browser => 4, + AnonLine::Provider => 5, + AnonLine::NoCode => 6, + AnonLine::SwitchedOff => 7, + AnonLine::Busy => 8, + AnonLine::TokenUnreachable => 9, + AnonLine::TokenUnwell => 10, + AnonLine::TokenRefused => 11, + AnonLine::KeySet => 12, + AnonLine::KeySetUnusable => 13, + AnonLine::IdToken => 14, + AnonLine::Begin => 15, + } +} + +/// Every sort and the budget it was given: the two named ones, and the fourteen failures (no_code +/// among them: a redirect with no code is a sign-in that went wrong, not a callback with no flow). +const DECIDED: [(AnonLine, AnonGroup); 16] = [ + (AnonLine::Start, AnonGroup::Start), + (AnonLine::NoFlow, AnonGroup::NoFlow), + (AnonLine::Settings, AnonGroup::Failed), + (AnonLine::Discovery, AnonGroup::Failed), + (AnonLine::Browser, AnonGroup::Failed), + (AnonLine::Provider, AnonGroup::Failed), + (AnonLine::NoCode, AnonGroup::Failed), + (AnonLine::SwitchedOff, AnonGroup::Failed), + (AnonLine::Busy, AnonGroup::Failed), + (AnonLine::TokenUnreachable, AnonGroup::Failed), + (AnonLine::TokenUnwell, AnonGroup::Failed), + (AnonLine::TokenRefused, AnonGroup::Failed), + (AnonLine::KeySet, AnonGroup::Failed), + (AnonLine::KeySetUnusable, AnonGroup::Failed), + (AnonLine::IdToken, AnonGroup::Failed), + (AnonLine::Begin, AnonGroup::Failed), +]; + +#[test] +fn the_list_of_sorts_holds_each_sort_once_and_each_sort_has_the_budget_that_was_decided() { + // The list that a test forgets the counters by holds every sort, once. + let mut listed = [0u8; 16]; + for line in AnonLine::ALL { + listed[place(line)] += 1; + } + assert_eq!( + listed, [1; 16], + "AnonLine::ALL lists a sort twice or leaves one out" + ); + // The decisions above name every sort once, too. + let mut named = [0u8; 16]; + for (line, _) in DECIDED { + named[place(line)] += 1; + } + assert_eq!( + named, [1; 16], + "the table of decisions misses a sort or names one twice" + ); + // And the code agrees with them, sort by sort. + for (line, group) in DECIDED { + assert_eq!(line.group(), group, "the budget of {}", line.word()); + } +} + +#[test] +fn the_words_that_key_the_sorts_and_the_budgets_differ() { + let mut words: Vec<&str> = AnonLine::ALL.iter().map(|l| l.word()).collect(); + words.sort_unstable(); + let before = words.len(); + words.dedup(); + assert_eq!( + words.len(), + before, + "two sorts share a word, so they would share a slot" + ); + let mut groups: Vec<&str> = AnonGroup::ALL.iter().map(|g| g.word()).collect(); + groups.sort_unstable(); + groups.dedup(); + assert_eq!( + groups.len(), + AnonGroup::ALL.len(), + "two budgets share a word" + ); + // Every budget has a sort, and a sort belongs to a budget of the list. + for group in AnonGroup::ALL { + assert!( + AnonLine::ALL.iter().any(|l| l.group() == group), + "no sort is counted against the budget {}", + group.word() + ); + } + for line in AnonLine::ALL { + assert!(AnonGroup::ALL.contains(&line.group()), "{}", line.word()); + } +} diff --git a/crates/kernel/obop-connectors/src/checklist.rs b/crates/kernel/obop-connectors/src/checklist.rs index 9563df00..a7e9a468 100644 --- a/crates/kernel/obop-connectors/src/checklist.rs +++ b/crates/kernel/obop-connectors/src/checklist.rs @@ -18,6 +18,27 @@ fn item(id: &str, status: &str, title: &str, detail: String, subjects: Vec, loopback: bool) -> Option { + if value?.trim().is_empty() { + return None; + } + let detail = if loopback { + "OBOP_OIDC_TEST_AUTHORITY is set and loopback addresses are allowed: if it is http://127.0.0.1: or http://[::1]: the Microsoft sign-in trusts that address instead of Microsoft and the bindings made now carry it in their key; any other value is ignored. Use it for tests only." + } else { + "OBOP_OIDC_TEST_AUTHORITY is set. Without OBOP_CONNECTORS_ALLOW_LOOPBACK=1 the Microsoft sign-in ignores it; remove it from an instance people use." + }; + Some(item( + "oidc_test_authority", + "warn", + "The Microsoft sign-in is pointed at a test authority", + detail.into(), + vec![], + )) +} + impl Runtime { pub fn checklist(&self) -> J { let inst = InstancePolicy::load(&*self.backend); @@ -182,6 +203,10 @@ impl Runtime { if self.cfg.allow_loopback { items.push(item("loopback", "warn", "Loopback addresses are allowed for connections", "OBOP_CONNECTORS_ALLOW_LOOPBACK is set. Use it for tests and local development only.".into(), vec![])); } + items.extend(test_authority_item( + std::env::var("OBOP_OIDC_TEST_AUTHORITY").ok().as_deref(), + self.cfg.allow_loopback, + )); if self.cfg.public_url.is_none() { items.push(item("public_url", "info", "OBOP_PUBLIC_URL is not set", "Webhook addresses are shown relative to this server and OAuth connections cannot be authorised until it is set.".into(), vec![])); } @@ -208,6 +233,33 @@ mod tests { use serde_json::json; use std::sync::Arc; + #[test] + fn the_test_authority_of_the_microsoft_sign_in_is_a_warning_when_it_is_set() { + assert!(test_authority_item(None, true).is_none()); + assert!(test_authority_item(Some(" "), true).is_none()); + for loopback in [true, false] { + let i = test_authority_item(Some("http://127.0.0.1:4455"), loopback).unwrap(); + assert_eq!(i["id"], "oidc_test_authority"); + assert_eq!(i["status"], "warn"); + assert!( + i["detail"] + .as_str() + .unwrap() + .contains("OBOP_OIDC_TEST_AUTHORITY") + ); + } + let ignored = test_authority_item(Some("http://127.0.0.1:4455"), false).unwrap(); + assert!(ignored["detail"].as_str().unwrap().contains("ignores it")); + // With loopback allowed the text does not say that every value is trusted: only a + // loopback address is, any other value is ignored (also Microsoft's own, a typo). + let on = test_authority_item(Some("http://localhost:4455"), true).unwrap(); + let on = on["detail"].as_str().unwrap(); + assert!( + on.contains("http://127.0.0.1:") && on.contains("any other value is ignored") + ); + assert!(on.contains("if it is http://"), "{on}"); + } + #[test] fn checklist_flags_what_is_missing_and_always_carries_the_disclaimer() { let backend = obop_storage::open("memory:").unwrap(); diff --git a/crates/kernel/obop-connectors/src/net.rs b/crates/kernel/obop-connectors/src/net.rs index b7004cf8..f5f5f7b4 100644 --- a/crates/kernel/obop-connectors/src/net.rs +++ b/crates/kernel/obop-connectors/src/net.rs @@ -380,6 +380,17 @@ impl Client { } pub fn send(&self, req: Request) -> Result { + self.send_timed(req, self.cfg.timeout) + } + + /// Like [`Client::send`] with a limit of its own for the whole call, never longer than the + /// client's (a caller that must not hold a thread for twenty seconds). The resolution of the + /// host name is not part of the limit. + pub fn send_within(&self, req: Request, limit: Duration) -> Result { + self.send_timed(req, limit.min(self.cfg.timeout)) + } + + fn send_timed(&self, req: Request, timeout: Duration) -> Result { if req .body .as_ref() @@ -393,7 +404,7 @@ impl Client { .max_redirects(0) .max_redirects_will_error(false) .https_only(false) - .timeout_global(Some(self.cfg.timeout)) + .timeout_global(Some(timeout)) .user_agent(USER_AGENT) .build(); let agent = ureq::Agent::with_parts( @@ -877,6 +888,42 @@ mod tests { }) } + #[test] + fn a_call_with_its_own_limit_gives_up_at_that_limit_and_never_waits_longer_than_the_client() { + // A server that takes its time: the one limit is the caller's, the other the client's. + let s = serve(|_| { + std::thread::sleep(Duration::from_millis(2500)); + ok("{}") + }); + let allowed = vec!["127.0.0.1".to_string()]; + let url = format!("http://127.0.0.1:{}/slow", s.port); + let req = || Request { + method: "GET", + url: &url, + headers: vec![], + body: None, + allowed_hosts: &allowed, + }; + let began = std::time::Instant::now(); + let r = local().send_within(req(), Duration::from_millis(300)); + assert!(matches!(r, Err(NetError::Timeout)), "{r:?}"); + assert!( + began.elapsed() < Duration::from_millis(2000), + "gave up at its own limit, not the client's five seconds: {:?}", + began.elapsed() + ); + // A caller cannot ask for more than the client allows. + let short = Client::new(ClientConfig { + allow_loopback: true, + timeout: Duration::from_millis(300), + ..ClientConfig::default() + }); + let began = std::time::Instant::now(); + let r = short.send_within(req(), Duration::from_secs(30)); + assert!(matches!(r, Err(NetError::Timeout)), "{r:?}"); + assert!(began.elapsed() < Duration::from_millis(2000)); + } + #[test] fn sends_and_reads_a_response_and_never_follows_redirects() { let s = serve(|req| { diff --git a/crates/kernel/obop-connectors/src/runtime.rs b/crates/kernel/obop-connectors/src/runtime.rs index 67c54655..656ee6e9 100644 --- a/crates/kernel/obop-connectors/src/runtime.rs +++ b/crates/kernel/obop-connectors/src/runtime.rs @@ -36,14 +36,19 @@ pub struct Config { impl Config { pub fn from_env() -> Self { + Self::from_vars(|name| std::env::var(name).ok()) + } + + /// [`Config::from_env`] with the environment passed in, so that a test can try the read + /// without the environment of its process. The spelling of the loopback setting is read by + /// `obop_types::loopback_allowed`, which the Microsoft sign-in uses too. + pub fn from_vars(get: impl Fn(&str) -> Option) -> Self { Self { - public_url: std::env::var("OBOP_PUBLIC_URL") - .ok() + public_url: get("OBOP_PUBLIC_URL") .map(|s| s.trim_end_matches('/').to_string()) .filter(|s| !s.is_empty()), - allow_loopback: matches!( - std::env::var("OBOP_CONNECTORS_ALLOW_LOOPBACK").as_deref(), - Ok("1" | "true") + allow_loopback: obop_types::loopback_allowed( + get(obop_types::ALLOW_LOOPBACK_ENV).as_deref(), ), } } @@ -1617,3 +1622,38 @@ impl From for String { r.to_string() } } + +#[cfg(test)] +mod config_tests { + use super::Config; + + fn with(pairs: &[(&str, &str)]) -> Config { + Config::from_vars(|name| { + pairs + .iter() + .find(|(k, _)| *k == name) + .map(|(_, v)| v.to_string()) + }) + } + + /// The Microsoft sign-in reads the same setting through the same function + /// (`obop_types::loopback_allowed`): what turns loopback on here is what turns its test + /// authority on there. + #[test] + fn loopback_is_allowed_by_the_setting_named_for_it_and_by_one_and_true_only() { + assert!(!with(&[]).allow_loopback); + assert!(with(&[("OBOP_CONNECTORS_ALLOW_LOOPBACK", "1")]).allow_loopback); + assert!(with(&[("OBOP_CONNECTORS_ALLOW_LOOPBACK", "true")]).allow_loopback); + for v in ["", "0", "yes", "on", "TRUE", " 1"] { + assert!( + !with(&[("OBOP_CONNECTORS_ALLOW_LOOPBACK", v)]).allow_loopback, + "{v:?}" + ); + } + // Another setting does not turn it on. + assert!(!with(&[("OBOP_OIDC_TEST_AUTHORITY", "1")]).allow_loopback); + let c = with(&[("OBOP_PUBLIC_URL", "https://obop.example/"), ("X", "1")]); + assert_eq!(c.public_url.as_deref(), Some("https://obop.example")); + assert_eq!(with(&[("OBOP_PUBLIC_URL", "")]).public_url, None); + } +} diff --git a/crates/kernel/obop-http/src/lib.rs b/crates/kernel/obop-http/src/lib.rs index cd715c42..5bcfe605 100644 --- a/crates/kernel/obop-http/src/lib.rs +++ b/crates/kernel/obop-http/src/lib.rs @@ -30,6 +30,7 @@ pub mod payments; pub mod public; pub(crate) mod shared; pub mod shell; +pub(crate) mod sso; pub mod website; #[cfg(test)] @@ -59,6 +60,12 @@ 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, + /// Where the browser is sent after the Microsoft round trip: the root of this origin, or the + /// back office's own address under a public site. Set by the composition root, never by a + /// request. + sso_return: std::sync::OnceLock, } impl HttpState { @@ -91,6 +98,8 @@ impl HttpState { drive: std::sync::OnceLock::new(), ops: ops::Ops::new(), default_language: std::sync::OnceLock::new(), + sso: sso::Cache::default(), + sso_return: std::sync::OnceLock::new(), }) } @@ -128,6 +137,18 @@ impl HttpState { self.default_language.get().copied() } + /// Where the back office is when it is not at `/`: the browser comes back from Microsoft to + /// `/` (the prefix of `--public-site`, one safe path segment such as `/admin`). + pub fn set_sso_return_prefix(&self, prefix: &str) { + if public::admin_prefix_ok(prefix) { + let _ = self.sso_return.set(format!("{prefix}/")); + } + } + + pub(crate) fn sso_return(&self) -> &str { + self.sso_return.get().map_or("/", String::as_str) + } + /// Who may read `/metrics` (D67). The default is nobody: the route answers 404. pub fn set_metrics_access(&self, access: ops::MetricsAccess) { self.ops.set_access(access); @@ -265,6 +286,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..c87dce81 --- /dev/null +++ b/crates/kernel/obop-http/src/sso.rs @@ -0,0 +1,1220 @@ +//! Sign-in with Microsoft Entra over HTTP (D91, ratified by OR-38): 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::{AnonLine, 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 long a failed fetch of the discovery document or the key set is answered from memory +/// before Microsoft is asked again: a provider that does not answer is not asked once per request. +const NEGATIVE_TTL: Duration = Duration::from_secs(45); +/// The most calls to Microsoft that one server process has in the air at once (the fetches of +/// the discovery document and the key set, and the exchange of a code; each holds one place only +/// while its own call is in the air). More are refused at once and hold no thread: the exchange of +/// a code answers "busy", the fetches fail as "unavailable". +const MAX_IN_FLIGHT: usize = 8; +/// The most requests that wait for the fetch of the discovery document or the key set that +/// another request has in the air. More are refused at once ("unavailable"), holding no thread. +const MAX_WAITING: usize = 8; +/// How long a call to Microsoft may take once its connection is being made, and how long a +/// request waits for the fetch another request has in the air (the egress client's own limit is +/// 20 seconds, which is for connectors; a person is not kept waiting that long). The system +/// resolver is not under this limit: `Client::send_within` resolves the host name first, and a +/// resolver that hangs holds the thread of the request that is resolving it. Of those there are +/// at most `MAX_IN_FLIGHT`, and `MAX_WAITING` more that wait up to this long: at most sixteen +/// blocking threads of the server belong to the Microsoft sign-in at any time, plus, for the +/// moment between taking the right to fetch and asking for a place (which is refused at once when +/// none is free), the two requests that hold the right of the discovery document and of the key +/// set: at most eighteen. +const SSO_TIMEOUT: Duration = Duration::from_secs(6); +/// How often the key set may be fetched again because a token named a key the cache lacks +/// (all server processes together). The token comes only in the provider's own answer to the +/// exchange of a code, so no caller can bring a key name of its own: the cap is for a provider +/// that rotates its keys or misbehaves, so that this server does not hammer it. +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)>>, + /// The last failure of each fetch, with the time and the key it was for. + discovery_failed: Mutex>, + jwks_failed: Mutex>, + /// One fetch of each at a time: the others wait for its answer (and find it in the cache). + discovery_flight: Mutex<()>, + jwks_flight: Mutex<()>, + in_flight: std::sync::atomic::AtomicUsize, + /// Requests that wait for a fetch another request has in the air. + waiting: std::sync::atomic::AtomicUsize, + /// The limit of a call, in milliseconds; 0 is `SSO_TIMEOUT` (a test shortens it). + timeout_ms: std::sync::atomic::AtomicU64, +} + +/// A place among the requests that wait for a fetch; given back when dropped. +struct Waiting<'a>(&'a std::sync::atomic::AtomicUsize); + +impl Drop for Waiting<'_> { + fn drop(&mut self) { + self.0.fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + } +} + +/// One of the calls to Microsoft this process may have in the air; given back when dropped. +pub(crate) struct Permit<'a>(&'a std::sync::atomic::AtomicUsize); + +impl Drop for Permit<'_> { + fn drop(&mut self) { + self.0.fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + } +} + +impl HttpState { + /// For tests: shorten the limit of a call to Microsoft (and of the wait for another request's + /// call), so that a slow stand-in does not make a test wait six seconds. + #[doc(hidden)] + pub fn set_sso_timeout_for_test(&self, limit: Duration) { + self.sso.timeout_ms.store( + limit.as_millis().max(1) as u64, + std::sync::atomic::Ordering::SeqCst, + ); + } + + /// For tests: take the right to fetch the discovery document, as a fetch that is stuck in the + /// air holds it (the limit of a call does not cover the system resolver, so a fetch can be + /// stuck for longer than the limit). Given back when the guard is dropped. + #[doc(hidden)] + pub fn hold_sso_discovery_fetch_for_test(&self) -> std::sync::MutexGuard<'_, ()> { + self.sso + .discovery_flight + .lock() + .unwrap_or_else(|p| p.into_inner()) + } + + /// For tests: the same for the fetch of the key set. + #[doc(hidden)] + pub fn hold_sso_key_set_fetch_for_test(&self) -> std::sync::MutexGuard<'_, ()> { + self.sso + .jwks_flight + .lock() + .unwrap_or_else(|p| p.into_inner()) + } + + /// For tests: how many requests wait now for a fetch another request has in the air (a test + /// that holds a fetch lets go of it when the places for waiting are taken, not after a guess + /// of how long that takes on a busy machine). + #[doc(hidden)] + pub fn sso_waiting_for_test(&self) -> usize { + self.sso.waiting.load(std::sync::atomic::Ordering::SeqCst) + } + + /// For tests: forget the discovery document, the key set and the remembered failures, as ten + /// minutes (or a restart) would. + #[doc(hidden)] + pub fn forget_sso_cache_for_test(&self) { + for slot in [&self.sso.discovery_failed, &self.sso.jwks_failed] { + if let Ok(mut g) = slot.lock() { + *g = None; + } + } + if let Ok(mut g) = self.sso.discovery.lock() { + *g = None; + } + if let Ok(mut g) = self.sso.jwks.lock() { + *g = None; + } + } +} + +fn failed_recently(slot: &Mutex>, key: &str) -> Option { + let g = slot.lock().ok()?; + let (k, at, why) = g.as_ref()?; + (k == key && at.elapsed() < NEGATIVE_TTL).then(|| why.clone()) +} + +fn remember_failure(slot: &Mutex>, key: &str, why: &str) { + if let Ok(mut g) = slot.lock() { + *g = Some((key.to_string(), Instant::now(), why.to_string())); + } +} + +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() +} + +/// The twelve `error` codes that the log keeps by name, taken from those of OAuth 2.0 (RFC 6749 +/// sections 4.1.2.1 and 5.2), of OpenID Connect and of the errors that Microsoft documents for +/// its two endpoints. Any other code, also a documented one such as `unsupported_response_type` +/// or `invalid_resource`, is logged as `other`. +const KNOWN_ERRORS: [&str; 12] = [ + "access_denied", + "interaction_required", + "login_required", + "consent_required", + "invalid_request", + "invalid_scope", + "invalid_client", + "invalid_grant", + "unauthorized_client", + "unsupported_grant_type", + "server_error", + "temporarily_unavailable", +]; + +/// What the log keeps of an `error` that came back to the callback or from the token endpoint: one +/// word out of [`KNOWN_ERRORS`], or `other`. The code in the query of the callback is whatever +/// the caller wrote, so no text of it reaches a line or a key; the description is never read. +fn error_class(code: &str) -> &'static str { + KNOWN_ERRORS + .iter() + .find(|k| **k == code) + .copied() + .unwrap_or("other") +} + +/// The address a request is counted and logged under: an IPv6 address by its /64 (one subscriber +/// has 2^64 of them), an IPv4-mapped one as the IPv4 address, anything else as it is. +fn source_bucket(addr: &str) -> String { + match addr.parse::() { + Ok(a) => match a.to_ipv4_mapped() { + Some(v4) => v4.to_string(), + None => { + let g = a.segments(); + format!("{:x}:{:x}:{:x}:{:x}::/64", g[0], g[1], g[2], g[3]) + } + }, + Err(_) => addr.to_string(), + } +} + +fn source_of(s: &HttpState, parts: &Parts) -> String { + source_bucket(&client_ip(s, parts)) +} + +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 { + /// A place among the calls to Microsoft this process has in the air, or `Err`: too many at + /// once, and nothing waits for one. + pub(crate) fn permit(&self) -> Result, String> { + use std::sync::atomic::Ordering; + if self.in_flight.fetch_add(1, Ordering::SeqCst) >= MAX_IN_FLIGHT { + self.in_flight.fetch_sub(1, Ordering::SeqCst); + return Err("too many calls to the provider are in the air".into()); + } + Ok(Permit(&self.in_flight)) + } + + /// The limit of a call to Microsoft, and of the wait for another request's call. + pub(crate) fn timeout(&self) -> Duration { + match self.timeout_ms.load(std::sync::atomic::Ordering::SeqCst) { + 0 => SSO_TIMEOUT, + ms => Duration::from_millis(ms), + } + } + + /// The right to fetch: one request at a time holds `lock`. A request that finds it taken waits + /// for the fetch in the air, at most `wait`, and at most `MAX_WAITING` requests wait at all; + /// the others get an error at once and hold no thread. (A request that gets in finds the + /// answer of the fetch it waited for in the cache, good or bad.) + fn one_at_a_time<'a>( + &self, + lock: &'a Mutex<()>, + wait: Duration, + ) -> Result, String> { + use std::sync::TryLockError; + use std::sync::atomic::Ordering; + match lock.try_lock() { + Ok(g) => return Ok(g), + Err(TryLockError::Poisoned(p)) => return Ok(p.into_inner()), + Err(TryLockError::WouldBlock) => {} + } + if self.waiting.fetch_add(1, Ordering::SeqCst) >= MAX_WAITING { + self.waiting.fetch_sub(1, Ordering::SeqCst); + return Err("too many requests are waiting for the provider".into()); + } + let _place = Waiting(&self.waiting); + let until = Instant::now() + wait; + loop { + match lock.try_lock() { + Ok(g) => return Ok(g), + Err(TryLockError::Poisoned(p)) => return Ok(p.into_inner()), + Err(TryLockError::WouldBlock) => { + if Instant::now() >= until { + return Err( + "the provider's answer to another request did not come in time".into(), + ); + } + std::thread::sleep(Duration::from_millis(10)); + } + } + } + } + + fn discovery( + &self, + api: &dyn SsoApi, + rt: &obop_connectors::Runtime, + leg: &SsoLeg, + ) -> Result { + let key = cache_key(leg); + let cached = || { + self.discovery.lock().ok().and_then(|g| { + g.as_ref() + .filter(|(k, at, _)| *k == key && at.elapsed() < DISCOVERY_TTL) + .map(|(_, _, ep)| ep.clone()) + }) + }; + if let Some(ep) = cached() { + return Ok(ep); + } + if let Some(why) = failed_recently(&self.discovery_failed, &key) { + return Err(format!("{why} (not tried again yet)")); + } + // One fetch at a time: the requests that wait find its answer, good or bad, when they get in. + let _one = self.one_at_a_time(&self.discovery_flight, self.timeout())?; + if let Some(ep) = cached() { + return Ok(ep); + } + if let Some(why) = failed_recently(&self.discovery_failed, &key) { + return Err(format!("{why} (not tried again yet)")); + } + let _permit = self.permit()?; + let url = format!( + "{}/{}/v2.0/.well-known/openid-configuration", + leg.authority, leg.tenant + ); + let got = fetch(rt, &url, &host_of(&leg.authority), self.timeout()) + .and_then(|body| api.endpoints(&body).map_err(|e| describe(&e))); + match got { + Ok(ep) => { + if let Ok(mut g) = self.discovery.lock() { + *g = Some((key, Instant::now(), ep.clone())); + } + Ok(ep) + } + Err(e) => { + remember_failure(&self.discovery_failed, &key, &e); + Err(e) + } + } + } + + 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); + let cached = || { + if force { + return None; + } + self.jwks.lock().ok().and_then(|g| { + g.as_ref() + .filter(|(k, at, _)| *k == key && at.elapsed() < JWKS_TTL) + .map(|(_, _, doc)| doc.clone()) + }) + }; + if let Some(doc) = cached() { + return Ok(doc); + } + if let Some(why) = failed_recently(&self.jwks_failed, &key) { + return Err(format!("{why} (not tried again yet)")); + } + let _one = self.one_at_a_time(&self.jwks_flight, self.timeout())?; + if let Some(doc) = cached() { + return Ok(doc); + } + if let Some(why) = failed_recently(&self.jwks_failed, &key) { + return Err(format!("{why} (not tried again yet)")); + } + 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 _permit = self.permit()?; + match fetch(rt, &ep.jwks, &host_of(&leg.authority), self.timeout()) { + Ok(doc) => { + if let Ok(mut g) = self.jwks.lock() { + *g = Some((key, Instant::now(), doc.clone())); + } + Ok(doc) + } + Err(e) => { + remember_failure(&self.jwks_failed, &key, &e); + Err(e) + } + } + } + + 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, + limit: Duration, +) -> Result, String> { + let r = rt + .client + .send_within( + Request { + method: "GET", + url, + headers: vec![("Accept".into(), "application/json".into())], + body: None, + allowed_hosts: &[host.to_string()], + }, + limit, + ) + .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(s: &HttpState, error: &str, cookies: &[String]) -> Response { + with_cookies( + see_other(&format!("{}#oidc_error={error}", s.sso_return())), + cookies, + ) +} + +fn secure_of(redirect_uri: &str) -> bool { + redirect_uri.starts_with("https://") +} + +/// Whether the request is a navigation of the browser (a click, a typed address, a redirect), and +/// not an image, a script or a `fetch` that some page made. A browser says so in `Sec-Fetch-Mode` +/// and `Sec-Fetch-Dest`; a client that sends neither (a script, an older browser) passes, as it +/// could not be told from the person's own click. +fn is_navigation(parts: &Parts) -> bool { + let get = |n: &str| parts.headers.get(n).and_then(|v| v.to_str().ok()); + get("sec-fetch-mode").is_none_or(|m| m == "navigate") + && get("sec-fetch-dest").is_none_or(|d| d == "document") +} + +fn not_a_navigation() -> Response { + let mut r = ( + StatusCode::BAD_REQUEST, + "this address is for the sign-in page only\n", + ) + .into_response(); + r.headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + r +} + +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 { + if !is_navigation(parts) { + return not_a_navigation(); + } + let ip = source_of(s, parts); + s.shared.maybe_purge(); + if limited(s, "start", &ip, 30) { + return back(s, "busy", &[]); + } + let (Some(api), Some(rt)) = (s.auth.sso(), s.connectors.get()) else { + return back(s, "unavailable", &[]); + }; + let leg = match api.leg() { + Ok(Some(l)) => l, + Ok(None) => return back(s, "unavailable", &[]), + Err(e) => { + api.audit_throttled( + &ip, + "oidc_error", + AnonLine::Settings, + &format!("start: {}", describe(&e)), + ); + return back(s, "unavailable", &[]); + } + }; + let ep = match s.sso.discovery(api, rt, &leg) { + Ok(ep) => ep, + Err(e) => { + api.audit_throttled( + &ip, + "oidc_error", + AnonLine::Discovery, + &format!("start: the discovery document could not be used: {e}"), + ); + return back(s, "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)); + // The row expires in the store after ten minutes and says so itself: a store that keeps an + // expired row (a lazy clean-up) does not make an old flow good again. + let rec = json!({ + "nonce": nonce, + "redirect_uri": redirect_uri, + "verifier_hash": hash_of(&verifier), + "expires_at_ms": now_ms() + u128::from(FLOW_TTL_MS), + }); + if s.shared + .backend + .kv_put(&flow_key(&flow), &rec.to_string(), Some(FLOW_TTL_MS)) + .is_err() + { + return back(s, "unavailable", &[]); + } + let url = format!( + "{}?client_id={}&response_type=code&redirect_uri={}&response_mode=query&scope=openid%20profile&state={}&nonce={}&code_challenge={}&code_challenge_method=S256", + ep.authorization, + pct(&leg.client_id), + pct(&redirect_uri), + flow, + nonce, + challenge, + ); + // One line per address and ten minutes, not one per request: a start is a free call. + api.audit_throttled( + &ip, + "oidc_start", + AnonLine::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 { + take_flow_from(&*s.shared.backend, flow) +} + +/// [`take_flow`] on a store: the delete is the claim, so of several processes that read the same +/// row (D59) exactly one is told it spent the flow. +fn take_flow_from(backend: &dyn obop_storage::Backend, flow: &str) -> Option { + let key = flow_key(flow); + let raw = backend.kv_get(&key).ok().flatten(); + let claimed = backend.kv_delete(&key).unwrap_or(false); + if !claimed { + return None; + } + let rec: J = serde_json::from_str(&raw?).ok()?; + // A row with no expiry, or past it, is no flow. + let until = u128::from(rec["expires_at_ms"].as_u64()?); + (until > now_ms()).then_some(rec) +} + +fn now_ms() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_millis()) +} + +fn callback_blocking(s: &HttpState, parts: &Parts) -> Response { + if !is_navigation(parts) { + return not_a_navigation(); + } + let ip = source_of(s, parts); + s.shared.maybe_purge(); + let (Some(api), Some(rt)) = (s.auth.sso(), s.connectors.get()) else { + return back(s, "unavailable", &[]); + }; + let pairs = parts.uri.query().and_then(parse_pairs).unwrap_or_default(); + // A request with no valid state does nothing, costs nothing and is not written down: it is + // not counted against the address either, so it cannot use up the allowance of a colleague. + let Some(state) = pair(&pairs, "state").filter(|f| is_flow_id(f)) else { + return back(s, "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 { + // A well-formed state that is not a flow of ours costs a read and a delete: counted, with + // a ceiling of its own (a real callback counts against the lower one below). + if limited(s, "cbx", &ip, 120) { + return back(s, "busy", &[clear_flow]); + } + api.audit_throttled( + &ip, + "oidc_refused", + AnonLine::NoFlow, + "the sign-in flow is unknown, already used or expired", + ); + return back(s, "failed", &[clear_flow]); + }; + // Only a callback that spent a flow of this server counts against the address: it is the one + // that goes on to call Microsoft. + if limited(s, "cb", &ip, 60) { + return back(s, "busy", &[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_throttled( + &ip, + "oidc_refused", + AnonLine::Browser, + "the browser that came back is not the one that began the sign-in", + ); + return back(s, "failed", &[clear_flow]); + } + if let Some(err) = pair(&pairs, "error") { + let err = error_class(err); + // Only the person's own refusal is a cancellation; any other answer is the provider's + // error, and is logged and worded as one. + let cancelled = err == "access_denied"; + api.audit_throttled( + &ip, + if cancelled { + "oidc_cancelled" + } else { + "oidc_error" + }, + AnonLine::Provider, + &format!("the provider ended the sign-in with {err:?}"), + ); + let word = if cancelled { "cancelled" } else { "failed" }; + return back(s, word, &[clear_flow]); + } + let Some(code) = pair(&pairs, "code").filter(|c| !c.is_empty() && c.len() <= 4096) else { + api.audit_throttled( + &ip, + "oidc_refused", + AnonLine::NoCode, + "the provider's redirect carried no code", + ); + return back(s, "failed", &[clear_flow]); + }; + let leg = match api.leg() { + Ok(Some(l)) => l, + Ok(None) => { + api.audit_throttled( + &ip, + "oidc_refused", + AnonLine::SwitchedOff, + "the provider was switched off during the sign-in", + ); + return back(s, "failed", &[clear_flow]); + } + Err(e) => { + api.audit_throttled( + &ip, + "oidc_error", + AnonLine::Settings, + &format!("callback: {}", describe(&e)), + ); + return back(s, "unavailable", &[clear_flow]); + } + }; + let ep = match s.sso.discovery(api, rt, &leg) { + Ok(ep) => ep, + Err(e) => { + api.audit_throttled( + &ip, + "oidc_error", + AnonLine::Discovery, + &format!("callback: the discovery document could not be used: {e}"), + ); + return back(s, "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), + ); + // The place among the calls in the air is held for this call only: the key set, which may + // need a place of its own, is fetched after it is given back. + let resp = { + let Ok(_permit) = s.sso.permit() else { + api.audit_throttled( + &ip, + "oidc_error", + AnonLine::Busy, + "too many calls to the provider are in the air; this sign-in was not tried", + ); + return back(s, "busy", &[clear_flow]); + }; + rt.client.send_within( + 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)], + }, + s.sso.timeout(), + ) + }; + let resp = match resp { + Ok(r) => r, + Err(e) => { + api.audit_throttled( + &ip, + "oidc_error", + AnonLine::TokenUnreachable, + &format!("the token endpoint could not be reached: {e}"), + ); + return back(s, "unavailable", &[clear_flow]); + } + }; + // 429 and 5xx: the provider is not well, it has not said no. Like the other outages (the + // fetches, an endpoint that cannot be reached) that is "try again", not "ask the administrator". + if resp.status == 429 || resp.status >= 500 { + api.audit_throttled( + &ip, + "oidc_error", + AnonLine::TokenUnwell, + &format!("the token endpoint answered {}", resp.status), + ); + return back(s, "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_throttled( + &ip, + "oidc_refused", + AnonLine::TokenRefused, + &format!( + "the token endpoint answered {}{}", + resp.status, + answer["error"] + .as_str() + .map(|e| format!(" {:?}", error_class(e))) + .unwrap_or_default() + ), + ); + return back(s, "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_throttled( + &ip, + "oidc_error", + AnonLine::KeySet, + &format!("the key set could not be fetched: {e}"), + ); + return back(s, "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_throttled( + &ip, + "oidc_error", + AnonLine::KeySet, + &format!("the key set could not be fetched again: {e}"), + ); + return back(s, "unavailable", &[clear_flow]); + } + } + } + let identity = match verified { + Ok(i) => i, + Err(SsoError::Unavailable(m)) => { + s.sso.forget_jwks(); + api.audit_throttled( + &ip, + "oidc_error", + AnonLine::KeySetUnusable, + &format!("the key set could not be used: {m}"), + ); + return back(s, "unavailable", &[clear_flow]); + } + Err(e) => { + api.audit_throttled( + &ip, + "oidc_refused", + AnonLine::IdToken, + &format!("the id token was refused: {}", describe(&e)), + ); + return back(s, "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}", s.sso_return())), + &[ + 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_throttled( + &ip, + "oidc_error", + AnonLine::Begin, + &format!("callback: {m}"), + ); + back(s, "unavailable", &[clear_flow]) + } + Err(_) => back(s, "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 = source_of(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((user, out)) => { + let mut body = mfa::outcome_json(Some(&*s.auth), out); + body.0["user"] = J::from(user); + let mut r = body.into_response(); + 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 a_request_waits_for_another_requests_fetch_a_little_and_only_so_many_wait() { + use std::sync::atomic::Ordering; + let c = Cache::default(); + assert_eq!(MAX_WAITING, 8, "the number the documents give"); + let lock = Mutex::new(()); + // Nobody has the fetch in the air: the place is given at once. + drop(c.one_at_a_time(&lock, Duration::from_millis(50)).unwrap()); + let held = lock.lock().unwrap(); + // A request waits for the fetch that is in the air, at most the limit it was given. + let t = Instant::now(); + assert!(c.one_at_a_time(&lock, Duration::from_millis(150)).is_err()); + let waited = t.elapsed(); + assert!( + waited >= Duration::from_millis(120) && waited < Duration::from_secs(3), + "{waited:?}" + ); + assert_eq!( + c.waiting.load(Ordering::SeqCst), + 0, + "the place was given back" + ); + // MAX_WAITING requests wait; the next one is refused at once and holds no thread. + std::thread::scope(|s| { + let waiters: Vec<_> = (0..MAX_WAITING) + .map(|_| s.spawn(|| c.one_at_a_time(&lock, Duration::from_millis(1500)).is_err())) + .collect(); + let t = Instant::now(); + while c.waiting.load(Ordering::SeqCst) < MAX_WAITING { + assert!( + t.elapsed() < Duration::from_secs(5), + "the waiters did not arrive" + ); + std::thread::sleep(Duration::from_millis(5)); + } + let t = Instant::now(); + let refused = c.one_at_a_time(&lock, Duration::from_millis(1500)); + assert!(refused.is_err()); + assert!( + t.elapsed() < Duration::from_millis(300), + "{:?}", + t.elapsed() + ); + assert_eq!(c.waiting.load(Ordering::SeqCst), MAX_WAITING); + for w in waiters { + assert!(w.join().unwrap(), "each waiter gave up at its limit"); + } + }); + assert_eq!(c.waiting.load(Ordering::SeqCst), 0); + // The fetch ends: the next request gets in. + drop(held); + assert!(c.one_at_a_time(&lock, Duration::from_millis(50)).is_ok()); + } + + #[test] + fn a_flow_is_spent_by_exactly_one_of_several_racing_callbacks() { + // Many readers of one row: only the one whose delete removed it may go on (D59: several + // server processes on one database). Many rounds, many threads released at once. + let (_flaky, db) = obop_storage::testing::Flaky::memory(); + for round in 0..300 { + let flow = hex(&random::<32>()); + let rec = json!({ + "nonce": "n", + "redirect_uri": "http://127.0.0.1/cb", + "verifier_hash": "h", + "expires_at_ms": now_ms() + 60_000, + }); + db.kv_put(&flow_key(&flow), &rec.to_string(), Some(60_000)) + .unwrap(); + let gate = std::sync::Barrier::new(8); + let winners = std::thread::scope(|s| { + let racers: Vec<_> = (0..8) + .map(|_| { + s.spawn(|| { + gate.wait(); + take_flow_from(&*db, &flow).is_some() + }) + }) + .collect(); + racers + .into_iter() + .map(|h| h.join().unwrap()) + .filter(|won| *won) + .count() + }); + assert_eq!(winners, 1, "round {round}: one winner"); + } + } + + #[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_one_word_of_a_fixed_set_before_it_is_logged() { + for known in KNOWN_ERRORS { + assert_eq!(error_class(known), known); + } + assert_eq!(KNOWN_ERRORS.len(), 12); + // Anything else, whatever it looks like, is "other": no text of the caller's survives. + for other in [ + "", + "a b\nc