Skip to main content

gunnar_core/
sso.rs

1//! **Company single sign-on: the rows** (owner, 2026-10-05: "SSO for
2//! companies in gunnar, OIDC and SAML, so a company's people log in with their
3//! own identity system" — and SCIM provisioning beside it).
4//!
5//! The account model is the owner's and does not move:
6//!
7//! * one account type; its creator is its admin;
8//! * an **enterprise** account is an account whose admin enrolled employees,
9//!   and an employee is a NEW user, created by and bound to the account
10//!   ([`crate::AuthTable::enroll_employee`]) — an existing principal never
11//!   becomes one;
12//! * private by default; only the account admin makes anything public.
13//!
14//! SSO changes none of that. An account admin points the account at the
15//! company's identity provider; a person the IdP vouches for is mapped to an
16//! EMPLOYEE of THAT account — created just in time on the first login (a new
17//! employee, never an attached principal), or provisioned beforehand by the
18//! company's directory (SCIM, a CSV import) and claimed by its directory user
19//! name on its first login. Every row here lives in the identity table
20//! (`refs/gunnar/auth`), in the same compare-and-swap as the principal it
21//! names, and reaches the twin with it.
22//!
23//! **No secret is in any row.** The OIDC client secret is sealed in the
24//! secret store under [`client_secret_key`] (the box's KEK, replicated sealed);
25//! a SCIM bearer is kept only as its SHA-256.
26
27use crate::model::UnixMillis;
28
29/// The protocol an account's identity provider speaks.
30#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
31#[serde(rename_all = "lowercase")]
32pub enum SsoProtocol {
33    /// OpenID Connect.
34    Oidc,
35    /// SAML 2.0.
36    Saml,
37}
38
39/// An OIDC identity provider (authorization code + PKCE).
40#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct OidcSettings {
43    /// The issuer, exactly as its discovery document names it.
44    pub issuer: String,
45    /// The client id the IdP issued for this account's console.
46    pub client_id: String,
47    /// Beside `openid`, which is always asked.
48    #[serde(default)]
49    pub scopes: Vec<String>,
50    /// The claim that names the person in the company's directory
51    /// (`preferred_username` when empty; `email` when that is absent).
52    #[serde(default)]
53    pub username_claim: String,
54    /// The claim shown as the person's name (`name` when empty).
55    #[serde(default)]
56    pub display_name_claim: String,
57    /// **The IdP's own CA** (DER, base64, standard alphabet): the account
58    /// admin's trust anchor for a company IdP behind a private CA — used for
59    /// THIS provider's https calls alone (discovery, keys, token), beside the
60    /// public roots (`gunnar_grpc::sso::idp_ca::parse_idp_ca` decides what is one). Written only when
61    /// set, so a gunnar from before it reads every provider that has none.
62    #[serde(default, skip_serializing_if = "Vec::is_empty")]
63    pub idp_ca: Vec<String>,
64}
65
66/// An IdP CA as the identity table keeps it (base64 DER, [`OidcSettings::idp_ca`]).
67pub fn idp_ca_stored(ders: &[Vec<u8>]) -> Vec<String> {
68    use base64ct::{Base64, Encoding as _};
69    ders.iter().map(|d| Base64::encode_string(d)).collect()
70}
71
72/// [`idp_ca_stored`] read back; an entry that does not decode is dropped.
73pub fn idp_ca_from_stored(stored: &[String]) -> Vec<Vec<u8>> {
74    use base64ct::{Base64, Encoding as _};
75    stored.iter().filter_map(|s| Base64::decode_vec(s.trim()).ok()).collect()
76}
77
78/// A SAML 2.0 identity provider (HTTP-Redirect out, HTTP-POST back).
79#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
80#[serde(deny_unknown_fields)]
81pub struct SamlSettings {
82    /// The IdP's entity id (the assertion's issuer).
83    pub idp_entity_id: String,
84    /// Where the AuthnRequest goes (HTTP-Redirect binding).
85    pub idp_sso_url: String,
86    /// The IdP's signing certificates, DER, base64 (standard alphabet). The
87    /// ONLY keys a response is verified with — a key in the message is never
88    /// trusted.
89    pub idp_certificates: Vec<String>,
90    /// Where the metadata was read from, when it was (an https URL).
91    #[serde(default)]
92    pub metadata_url: String,
93    /// The attribute that names the person (the NameID when empty).
94    #[serde(default)]
95    pub username_attribute: String,
96    #[serde(default)]
97    /// The attribute shown as the person's name.
98    pub display_name_attribute: String,
99}
100
101/// **An account's identity provider**, as its admin configured it. Keyed by
102/// the account.
103#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
104#[serde(deny_unknown_fields)]
105pub struct SsoProvider {
106    /// The account (its principal id and namespace).
107    pub account: String,
108    /// OIDC or SAML.
109    pub protocol: SsoProtocol,
110    /// "Sign in with <display_name>".
111    pub display_name: String,
112    /// Email domains that lead the login page to this account (lowercase,
113    /// unique on the server: one domain, one company).
114    #[serde(default)]
115    pub domains: Vec<String>,
116    #[serde(default)]
117    /// Set when `protocol` is OIDC.
118    pub oidc: Option<OidcSettings>,
119    #[serde(default)]
120    /// Set when `protocol` is SAML.
121    pub saml: Option<SamlSettings>,
122    /// The account's EMPLOYEES log in through the IdP only: a passkey alone
123    /// is refused for them. The account admin is not an employee and keeps
124    /// the passkey (the break-glass).
125    #[serde(default)]
126    pub require_sso: bool,
127    /// After the IdP, a passkey of the same person (registered on the first
128    /// login when there is none yet).
129    #[serde(default)]
130    pub require_passkey: bool,
131    /// The console the redirect URI and the SAML ACS hang off
132    /// (`https://gunnar.example`), as the admin's console named it.
133    pub console_origin: String,
134    #[serde(default)]
135    /// When it was last configured.
136    pub updated: UnixMillis,
137    #[serde(default)]
138    /// Who configured it last.
139    pub updated_by: String,
140}
141
142impl SsoProvider {
143    /// The OIDC redirect URI the admin registers at the IdP.
144    pub fn redirect_uri(&self) -> String {
145        redirect_uri(&self.console_origin)
146    }
147
148    /// The SAML SP entity id (the audience).
149    pub fn sp_entity_id(&self) -> String {
150        sp_entity_id(&self.console_origin, &self.account)
151    }
152
153    /// The SAML Assertion Consumer Service URL.
154    pub fn acs_url(&self) -> String {
155        acs_url(&self.console_origin)
156    }
157
158    /// The issuer a subject is bound under: the OIDC issuer, or the SAML
159    /// IdP's entity id.
160    pub fn issuer(&self) -> &str {
161        match self.protocol {
162            SsoProtocol::Oidc => self.oidc.as_ref().map_or("", |o| o.issuer.as_str()),
163            SsoProtocol::Saml => self.saml.as_ref().map_or("", |s| s.idp_entity_id.as_str()),
164        }
165    }
166}
167
168/// `<origin>/auth/sso/oidc/callback`.
169pub fn redirect_uri(origin: &str) -> String {
170    format!("{}/auth/sso/oidc/callback", origin.trim_end_matches('/'))
171}
172
173/// `<origin>/auth/sso/saml/<account>` — the SP entity id, one per account so
174/// two companies' IdPs never share an audience.
175pub fn sp_entity_id(origin: &str, account: &str) -> String {
176    format!("{}/auth/sso/saml/{account}", origin.trim_end_matches('/'))
177}
178
179/// `<origin>/auth/sso/saml/acs`.
180pub fn acs_url(origin: &str) -> String {
181    format!("{}/auth/sso/saml/acs", origin.trim_end_matches('/'))
182}
183
184/// Where a directory user came from.
185#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
186#[serde(rename_all = "lowercase")]
187pub enum SsoSource {
188    /// Created just in time by its first SSO login.
189    Jit,
190    /// Provisioned by the company's directory over SCIM.
191    Scim,
192    /// Provisioned by the account admin's CSV import.
193    Csv,
194}
195
196impl SsoSource {
197    /// The word a listing shows.
198    pub fn as_str(self) -> &'static str {
199        match self {
200            SsoSource::Jit => "jit",
201            SsoSource::Scim => "scim",
202            SsoSource::Csv => "csv",
203        }
204    }
205}
206
207/// **One person of a company's directory**: an EMPLOYEE of `account`, keyed
208/// by its principal id. Written in the same compare-and-swap as the employee
209/// it describes, removed with it.
210#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
211#[serde(deny_unknown_fields)]
212pub struct SsoUser {
213    /// The employee.
214    pub principal_id: String,
215    /// The account it is an employee of.
216    pub account: String,
217    /// The directory's user name, lowercase (SCIM `userName`, the mapped
218    /// claim or attribute). Unique within the account.
219    pub user_name: String,
220    #[serde(default)]
221    /// The person's name, as the directory says it.
222    pub display_name: String,
223    #[serde(default)]
224    /// The person's address, as the directory says it (never mailed by gunnar).
225    pub email: String,
226    /// The directory's own id for the person (SCIM `externalId`).
227    #[serde(default)]
228    pub external_id: String,
229    /// [`subject_key`] of the IdP subject this person logs in as, once bound
230    /// (by the first login). `None`: provisioned, never logged in yet.
231    #[serde(default)]
232    pub subject: Option<String>,
233    /// False: deactivated — gated, sessions revoked, data kept.
234    pub active: bool,
235    /// Where the row came from.
236    pub source: SsoSource,
237    /// When it was created.
238    pub created: UnixMillis,
239    #[serde(default)]
240    /// When its last SSO login was.
241    pub last_login: Option<UnixMillis>,
242    /// The web session keys SSO logins enrolled (fingerprints), so a
243    /// deactivation ends them. Pruned at each login.
244    #[serde(default)]
245    pub sessions: Vec<String>,
246    /// Removed from the directory (SCIM DELETE): still gated, data kept, no
247    /// longer listed or claimable; its user name is free for a NEW person.
248    #[serde(default)]
249    pub deleted: bool,
250}
251
252/// A SCIM bearer, by the SHA-256 of its secret.
253#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
254#[serde(deny_unknown_fields)]
255pub struct ScimToken {
256    /// The public id a listing and a revocation name.
257    pub id: String,
258    /// The account the bearer provisions.
259    pub account: String,
260    /// Lowercase hex SHA-256 of the secret. Never the secret.
261    pub secret_hash: String,
262    #[serde(default)]
263    /// What the account admin called it.
264    pub label: String,
265    /// When it was created.
266    pub created: UnixMillis,
267    /// Who created it.
268    pub created_by: String,
269    #[serde(default)]
270    /// When it was last presented (to a minute).
271    pub last_used: Option<UnixMillis>,
272}
273
274/// A SCIM group of an account's directory. Kept as the directory sent it;
275/// what a group may reach is granted by the account admin.
276#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
277#[serde(deny_unknown_fields)]
278pub struct SsoGroup {
279    /// The group's id (SCIM `id`).
280    pub id: String,
281    /// The account whose directory it is in.
282    pub account: String,
283    /// The group's name (SCIM `displayName`), unique within the account.
284    pub display_name: String,
285    #[serde(default)]
286    /// The directory's own id for the group (SCIM `externalId`).
287    pub external_id: String,
288    /// Principal ids (employees of the same account only).
289    #[serde(default)]
290    pub members: Vec<String>,
291    /// When it was created.
292    pub created: UnixMillis,
293}
294
295/// The secret-store key of an account's OIDC client secret.
296pub fn client_secret_key(account: &str) -> String {
297    format!("sso/{account}/oidc-client-secret")
298}
299
300/// The gate holder a directory deactivation writes, so a reactivation lifts
301/// only its own gate and never an operator's suspension.
302pub fn gate_holder(account: &str) -> String {
303    format!("sso:{account}")
304}
305
306/// **The key a subject is bound under**: SHA-256 of `issuer ␟ subject`, hex.
307/// Hashed so a listing of the identity table carries no IdP identifiers.
308pub fn subject_key(issuer: &str, subject: &str) -> String {
309    let mut text = issuer.trim().trim_end_matches('/').to_owned();
310    text.push('\u{1f}');
311    text.push_str(subject);
312    crate::auth::token_hash(&text)
313}
314
315/// The longest principal id a console carries in a session.
316pub const MAX_ID_LEN: usize = 64;
317
318/// **The ids a NEW employee for `user_name` may take**, in order:
319/// `<account>.<local>`, then `-2` … `-9` — the local part of an address (or
320/// the whole name) reduced to `[a-z0-9._-]`. Never an existing principal: the
321/// caller takes the first one that is free and creates it.
322pub fn principal_id_candidates(account: &str, user_name: &str) -> Vec<String> {
323    let local = user_name.split('@').next().unwrap_or(user_name);
324    let mut cleaned = String::new();
325    for c in local.chars() {
326        let c = c.to_ascii_lowercase();
327        let keep =
328            if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-' || c == '.' {
329                c
330            } else {
331                '-'
332            };
333        if (keep == '-' || keep == '.') && cleaned.ends_with(['-', '.']) {
334            continue;
335        }
336        cleaned.push(keep);
337    }
338    let cleaned = cleaned.trim_matches(['-', '.']).to_owned();
339    let cleaned = if cleaned.is_empty() {
340        "user".to_owned()
341    } else {
342        cleaned
343    };
344    let account = account.to_ascii_lowercase();
345    let mut out = Vec::new();
346    for n in 1..=9 {
347        let suffix = if n == 1 {
348            String::new()
349        } else {
350            format!("-{n}")
351        };
352        let room = MAX_ID_LEN.saturating_sub(account.len() + 1 + suffix.len());
353        if room == 0 {
354            break;
355        }
356        let body: String = cleaned.chars().take(room).collect();
357        let body = body.trim_end_matches(['-', '.']);
358        let id = format!("{account}.{body}{suffix}");
359        if crate::sandbox::check_component(&id, &id).is_ok() && id.len() <= MAX_ID_LEN {
360            out.push(id);
361        }
362    }
363    out
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn candidates_are_the_accounts_and_valid_ids() {
372        let ids = principal_id_candidates("acme", "Bob.Smith@ACME.example");
373        assert_eq!(ids[0], "acme.bob.smith");
374        assert_eq!(ids[1], "acme.bob.smith-2");
375        assert_eq!(ids.len(), 9);
376        let odd = principal_id_candidates("acme", "  ..--åsa ö'connor--..");
377        assert_eq!(odd[0], "acme.sa-connor");
378        let empty = principal_id_candidates("acme", "@@@");
379        assert_eq!(empty[0], "acme.user");
380        let long = principal_id_candidates("acme", &"x".repeat(200));
381        assert!(long.iter().all(|id| id.len() <= MAX_ID_LEN));
382    }
383
384    /// A provider with no CA is written as before: a gunnar from before the
385    /// CA reads it (`deny_unknown_fields`).
386    #[test]
387    fn a_provider_without_a_ca_is_written_as_before() {
388        let o = OidcSettings { issuer: "https://idp.example".into(), client_id: "c".into(), ..Default::default() };
389        let text = serde_json::to_string(&o).expect("json");
390        assert!(!text.contains("idp_ca"), "{text}");
391        let with = OidcSettings { idp_ca: vec!["AAAA".into()], ..o };
392        assert!(serde_json::to_string(&with).expect("json").contains("idp_ca"));
393    }
394
395    #[test]
396    fn subject_keys_bind_the_issuer() {
397        assert_ne!(
398            subject_key("https://a.example", "1"),
399            subject_key("https://b.example", "1")
400        );
401        assert_eq!(
402            subject_key("https://a.example/", "1"),
403            subject_key("https://a.example", "1")
404        );
405        assert_eq!(subject_key("i", "s").len(), 64);
406    }
407}