crates/gunnar-core/src/sso.rs
//! **Company single sign-on: the rows** (owner, 2026-10-05: "SSO for
//! companies in gunnar, OIDC and SAML, so a company's people log in with their
//! own identity system" — and SCIM provisioning beside it).
//!
//! The account model is the owner's and does not move:
//!
//! * one account type; its creator is its admin;
//! * an **enterprise** account is an account whose admin enrolled employees,
//! and an employee is a NEW user, created by and bound to the account
//! ([`crate::AuthTable::enroll_employee`]) — an existing principal never
//! becomes one;
//! * private by default; only the account admin makes anything public.
//!
//! SSO changes none of that. An account admin points the account at the
//! company's identity provider; a person the IdP vouches for is mapped to an
//! EMPLOYEE of THAT account — created just in time on the first login (a new
//! employee, never an attached principal), or provisioned beforehand by the
//! company's directory (SCIM, a CSV import) and claimed by its directory user
//! name on its first login. Every row here lives in the identity table
//! (`refs/gunnar/auth`), in the same compare-and-swap as the principal it
//! names, and reaches the twin with it.
//!
//! **No secret is in any row.** The OIDC client secret is sealed in the
//! secret store under [`client_secret_key`] (the box's KEK, replicated sealed);
//! a SCIM bearer is kept only as its SHA-256.
use crate::model::UnixMillis;
/// The protocol an account's identity provider speaks.
#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SsoProtocol {
/// OpenID Connect.
Oidc,
/// SAML 2.0.
Saml,
}
/// An OIDC identity provider (authorization code + PKCE).
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OidcSettings {
/// The issuer, exactly as its discovery document names it.
pub issuer: String,
/// The client id the IdP issued for this account's console.
pub client_id: String,
/// Beside `openid`, which is always asked.
#[serde(default)]
pub scopes: Vec<String>,
/// The claim that names the person in the company's directory
/// (`preferred_username` when empty; `email` when that is absent).
#[serde(default)]
pub username_claim: String,
/// The claim shown as the person's name (`name` when empty).
#[serde(default)]
pub display_name_claim: String,
/// **The IdP's own CA** (DER, base64, standard alphabet): the account
/// admin's trust anchor for a company IdP behind a private CA — used for
/// THIS provider's https calls alone (discovery, keys, token), beside the
/// public roots (`gunnar_grpc::sso::idp_ca::parse_idp_ca` decides what is one). Written only when
/// set, so a gunnar from before it reads every provider that has none.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub idp_ca: Vec<String>,
}
/// An IdP CA as the identity table keeps it (base64 DER, [`OidcSettings::idp_ca`]).
pub fn idp_ca_stored(ders: &[Vec<u8>]) -> Vec<String> {
use base64ct::{Base64, Encoding as _};
ders.iter().map(|d| Base64::encode_string(d)).collect()
}
/// [`idp_ca_stored`] read back; an entry that does not decode is dropped.
pub fn idp_ca_from_stored(stored: &[String]) -> Vec<Vec<u8>> {
use base64ct::{Base64, Encoding as _};
stored
.iter()
.filter_map(|s| Base64::decode_vec(s.trim()).ok())
.collect()
}
/// A SAML 2.0 identity provider (HTTP-Redirect out, HTTP-POST back).
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SamlSettings {
/// The IdP's entity id (the assertion's issuer).
pub idp_entity_id: String,
/// Where the AuthnRequest goes (HTTP-Redirect binding).
pub idp_sso_url: String,
/// The IdP's signing certificates, DER, base64 (standard alphabet). The
/// ONLY keys a response is verified with — a key in the message is never
/// trusted.
pub idp_certificates: Vec<String>,
/// Where the metadata was read from, when it was (an https URL).
#[serde(default)]
pub metadata_url: String,
/// The attribute that names the person (the NameID when empty).
#[serde(default)]
pub username_attribute: String,
#[serde(default)]
/// The attribute shown as the person's name.
pub display_name_attribute: String,
}
/// **An account's identity provider**, as its admin configured it. Keyed by
/// the account.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SsoProvider {
/// The account (its principal id and namespace).
pub account: String,
/// OIDC or SAML.
pub protocol: SsoProtocol,
/// "Sign in with <display_name>".
pub display_name: String,
/// Email domains that lead the login page to this account (lowercase,
/// unique on the server: one domain, one company).
#[serde(default)]
pub domains: Vec<String>,
#[serde(default)]
/// Set when `protocol` is OIDC.
pub oidc: Option<OidcSettings>,
#[serde(default)]
/// Set when `protocol` is SAML.
pub saml: Option<SamlSettings>,
/// The account's EMPLOYEES log in through the IdP only: a passkey alone
/// is refused for them. The account admin is not an employee and keeps
/// the passkey (the break-glass).
#[serde(default)]
pub require_sso: bool,
/// After the IdP, a passkey of the same person (registered on the first
/// login when there is none yet).
#[serde(default)]
pub require_passkey: bool,
/// The console the redirect URI and the SAML ACS hang off
/// (`https://gunnar.example`), as the admin's console named it.
pub console_origin: String,
#[serde(default)]
/// When it was last configured.
pub updated: UnixMillis,
#[serde(default)]
/// Who configured it last.
pub updated_by: String,
/// The `domains` whose DNS proof was read (A34-5): only these lead a sign-in
/// here, count for a claim or a JIT employee, and are held against other
/// accounts. Written only when set, as `idp_ca`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub verified_domains: Vec<String>,
/// Who may become a new employee just in time (A34-25): `open` (empty),
/// `domains` or `off` (`nornir_identity_sso::rules::JitPolicy`).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub jit: String,
}
impl SsoProvider {
/// The OIDC redirect URI the admin registers at the IdP.
pub fn redirect_uri(&self) -> String {
redirect_uri(&self.console_origin)
}
/// The SAML SP entity id (the audience).
pub fn sp_entity_id(&self) -> String {
sp_entity_id(&self.console_origin, &self.account)
}
/// The SAML Assertion Consumer Service URL.
pub fn acs_url(&self) -> String {
acs_url(&self.console_origin)
}
/// The issuer a subject is bound under: the OIDC issuer, or the SAML
/// IdP's entity id.
pub fn issuer(&self) -> &str {
match self.protocol {
SsoProtocol::Oidc => self.oidc.as_ref().map_or("", |o| o.issuer.as_str()),
SsoProtocol::Saml => self.saml.as_ref().map_or("", |s| s.idp_entity_id.as_str()),
}
}
}
/// `<origin>/auth/sso/oidc/callback`.
pub fn redirect_uri(origin: &str) -> String {
format!("{}/auth/sso/oidc/callback", origin.trim_end_matches('/'))
}
/// `<origin>/auth/sso/saml/<account>` — the SP entity id, one per account so
/// two companies' IdPs never share an audience.
pub fn sp_entity_id(origin: &str, account: &str) -> String {
format!("{}/auth/sso/saml/{account}", origin.trim_end_matches('/'))
}
/// `<origin>/auth/sso/saml/acs`.
pub fn acs_url(origin: &str) -> String {
format!("{}/auth/sso/saml/acs", origin.trim_end_matches('/'))
}
/// Where a directory user came from.
#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SsoSource {
/// Created just in time by its first SSO login.
Jit,
/// Provisioned by the company's directory over SCIM.
Scim,
/// Provisioned by the account admin's CSV import.
Csv,
}
impl SsoSource {
/// The word a listing shows.
pub fn as_str(self) -> &'static str {
match self {
SsoSource::Jit => "jit",
SsoSource::Scim => "scim",
SsoSource::Csv => "csv",
}
}
}
/// **One person of a company's directory**: an EMPLOYEE of `account`, keyed
/// by its principal id. Written in the same compare-and-swap as the employee
/// it describes, removed with it.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SsoUser {
/// The employee.
pub principal_id: String,
/// The account it is an employee of.
pub account: String,
/// The directory's user name, lowercase (SCIM `userName`, the mapped
/// claim or attribute). Unique within the account.
pub user_name: String,
#[serde(default)]
/// The person's name, as the directory says it.
pub display_name: String,
#[serde(default)]
/// The person's address, as the directory says it (never mailed by gunnar).
pub email: String,
/// The directory's own id for the person (SCIM `externalId`).
#[serde(default)]
pub external_id: String,
/// [`subject_key`] of the IdP subject this person logs in as, once bound
/// (by the first login). `None`: provisioned, never logged in yet.
#[serde(default)]
pub subject: Option<String>,
/// False: deactivated — gated, sessions revoked, data kept.
pub active: bool,
/// Where the row came from.
pub source: SsoSource,
/// When it was created.
pub created: UnixMillis,
#[serde(default)]
/// When its last SSO login was.
pub last_login: Option<UnixMillis>,
/// The web session keys SSO logins enrolled (fingerprints), so a
/// deactivation ends them. Pruned at each login.
#[serde(default)]
pub sessions: Vec<String>,
/// Removed from the directory (SCIM DELETE): still gated, data kept, no
/// longer listed or claimable; its user name is free for a NEW person.
#[serde(default)]
pub deleted: bool,
/// The account admin opened a rebind (A34-11, an IdP migration): unbound
/// from its old subject, its next sign-in binds it afresh on the claim rules.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub rebind: bool,
}
/// A SCIM bearer, by the SHA-256 of its secret.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScimToken {
/// The public id a listing and a revocation name.
pub id: String,
/// The account the bearer provisions.
pub account: String,
/// Lowercase hex SHA-256 of the secret. Never the secret.
pub secret_hash: String,
#[serde(default)]
/// What the account admin called it.
pub label: String,
/// When it was created.
pub created: UnixMillis,
/// Who created it.
pub created_by: String,
#[serde(default)]
/// When it was last presented (to a minute).
pub last_used: Option<UnixMillis>,
/// When it stops working (A34-27); `None` never. Not written when `None`,
/// so a row without one reads as before.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires: Option<UnixMillis>,
/// It may only read the directory (A34-27). Not written when false.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub read_only: bool,
}
/// A SCIM group of an account's directory. Kept as the directory sent it;
/// what a group may reach is granted by the account admin.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SsoGroup {
/// The group's id (SCIM `id`).
pub id: String,
/// The account whose directory it is in.
pub account: String,
/// The group's name (SCIM `displayName`), unique within the account.
pub display_name: String,
#[serde(default)]
/// The directory's own id for the group (SCIM `externalId`).
pub external_id: String,
/// Principal ids (employees of the same account only).
#[serde(default)]
pub members: Vec<String>,
/// When it was created.
pub created: UnixMillis,
}
/// The secret-store key of an account's OIDC client secret.
pub fn client_secret_key(account: &str) -> String {
format!("sso/{account}/oidc-client-secret")
}
/// The gate holder a directory deactivation writes, so a reactivation lifts
/// only its own gate and never an operator's suspension.
pub fn gate_holder(account: &str) -> String {
format!("sso:{account}")
}
/// **The key a subject is bound under**: SHA-256 of `issuer ␟ subject`, hex.
/// Hashed so a listing of the identity table carries no IdP identifiers.
pub fn subject_key(issuer: &str, subject: &str) -> String {
nornir_identity_sso::rules::subject_key(issuer, subject)
}
/// The longest principal id a console carries in a session.
pub const MAX_ID_LEN: usize = 64;
/// **The ids a NEW employee for `user_name` may take**, in order:
/// `<account>.<local>`, then `-2` … `-9`, then short suffixes derived from the
/// whole user name (A34-42: the 10th person with the same local part was
/// refused) — the local part of an address (or the whole name) reduced to
/// `[a-z0-9._-]`. Never an existing principal: the caller takes the first one
/// that is free and creates it. The rule is edda's
/// ([`nornir_identity_directory::names::principal_id_candidates`]), this
/// crate adds its id length and component rule.
pub fn principal_id_candidates(account: &str, user_name: &str) -> Vec<String> {
nornir_identity_directory::names::principal_id_candidates(
account,
user_name,
MAX_ID_LEN,
|id| crate::sandbox::check_component(id, id).is_ok(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn candidates_are_the_accounts_and_valid_ids() {
let ids = principal_id_candidates("acme", "Bob.Smith@ACME.example");
assert_eq!(ids[0], "acme.bob.smith");
assert_eq!(ids[1], "acme.bob.smith-2");
assert_eq!(ids.len(), 13, "-1..-9 and four hashed suffixes: {ids:?}");
let odd = principal_id_candidates("acme", " ..--åsa ö'connor--..");
assert_eq!(odd[0], "acme.sa-connor");
let empty = principal_id_candidates("acme", "@@@");
assert_eq!(empty[0], "acme.user");
let long = principal_id_candidates("acme", &"x".repeat(200));
assert!(long.iter().all(|id| id.len() <= MAX_ID_LEN));
}
/// A provider with no CA is written as before: a gunnar from before the
/// CA reads it (`deny_unknown_fields`).
#[test]
fn a_provider_without_a_ca_is_written_as_before() {
let o = OidcSettings {
issuer: "https://idp.example".into(),
client_id: "c".into(),
..Default::default()
};
let text = serde_json::to_string(&o).expect("json");
assert!(!text.contains("idp_ca"), "{text}");
let with = OidcSettings {
idp_ca: vec!["AAAA".into()],
..o
};
assert!(serde_json::to_string(&with)
.expect("json")
.contains("idp_ca"));
}
#[test]
fn subject_keys_bind_the_issuer() {
assert_ne!(
subject_key("https://a.example", "1"),
subject_key("https://b.example", "1")
);
assert_eq!(
subject_key("https://a.example/", "1"),
subject_key("https://a.example", "1")
);
assert_eq!(subject_key("i", "s").len(), 64);
}
}