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
76 .iter()
77 .filter_map(|s| Base64::decode_vec(s.trim()).ok())
78 .collect()
79}
80
81/// A SAML 2.0 identity provider (HTTP-Redirect out, HTTP-POST back).
82#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct SamlSettings {
85 /// The IdP's entity id (the assertion's issuer).
86 pub idp_entity_id: String,
87 /// Where the AuthnRequest goes (HTTP-Redirect binding).
88 pub idp_sso_url: String,
89 /// The IdP's signing certificates, DER, base64 (standard alphabet). The
90 /// ONLY keys a response is verified with — a key in the message is never
91 /// trusted.
92 pub idp_certificates: Vec<String>,
93 /// Where the metadata was read from, when it was (an https URL).
94 #[serde(default)]
95 pub metadata_url: String,
96 /// The attribute that names the person (the NameID when empty).
97 #[serde(default)]
98 pub username_attribute: String,
99 #[serde(default)]
100 /// The attribute shown as the person's name.
101 pub display_name_attribute: String,
102}
103
104/// **An account's identity provider**, as its admin configured it. Keyed by
105/// the account.
106#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
107#[serde(deny_unknown_fields)]
108pub struct SsoProvider {
109 /// The account (its principal id and namespace).
110 pub account: String,
111 /// OIDC or SAML.
112 pub protocol: SsoProtocol,
113 /// "Sign in with <display_name>".
114 pub display_name: String,
115 /// Email domains that lead the login page to this account (lowercase,
116 /// unique on the server: one domain, one company).
117 #[serde(default)]
118 pub domains: Vec<String>,
119 #[serde(default)]
120 /// Set when `protocol` is OIDC.
121 pub oidc: Option<OidcSettings>,
122 #[serde(default)]
123 /// Set when `protocol` is SAML.
124 pub saml: Option<SamlSettings>,
125 /// The account's EMPLOYEES log in through the IdP only: a passkey alone
126 /// is refused for them. The account admin is not an employee and keeps
127 /// the passkey (the break-glass).
128 #[serde(default)]
129 pub require_sso: bool,
130 /// After the IdP, a passkey of the same person (registered on the first
131 /// login when there is none yet).
132 #[serde(default)]
133 pub require_passkey: bool,
134 /// The console the redirect URI and the SAML ACS hang off
135 /// (`https://gunnar.example`), as the admin's console named it.
136 pub console_origin: String,
137 #[serde(default)]
138 /// When it was last configured.
139 pub updated: UnixMillis,
140 #[serde(default)]
141 /// Who configured it last.
142 pub updated_by: String,
143 /// The `domains` whose DNS proof was read (A34-5): only these lead a sign-in
144 /// here, count for a claim or a JIT employee, and are held against other
145 /// accounts. Written only when set, as `idp_ca`.
146 #[serde(default, skip_serializing_if = "Vec::is_empty")]
147 pub verified_domains: Vec<String>,
148 /// Who may become a new employee just in time (A34-25): `open` (empty),
149 /// `domains` or `off` (`nornir_identity_sso::rules::JitPolicy`).
150 #[serde(default, skip_serializing_if = "String::is_empty")]
151 pub jit: String,
152}
153
154impl SsoProvider {
155 /// The OIDC redirect URI the admin registers at the IdP.
156 pub fn redirect_uri(&self) -> String {
157 redirect_uri(&self.console_origin)
158 }
159
160 /// The SAML SP entity id (the audience).
161 pub fn sp_entity_id(&self) -> String {
162 sp_entity_id(&self.console_origin, &self.account)
163 }
164
165 /// The SAML Assertion Consumer Service URL.
166 pub fn acs_url(&self) -> String {
167 acs_url(&self.console_origin)
168 }
169
170 /// The issuer a subject is bound under: the OIDC issuer, or the SAML
171 /// IdP's entity id.
172 pub fn issuer(&self) -> &str {
173 match self.protocol {
174 SsoProtocol::Oidc => self.oidc.as_ref().map_or("", |o| o.issuer.as_str()),
175 SsoProtocol::Saml => self.saml.as_ref().map_or("", |s| s.idp_entity_id.as_str()),
176 }
177 }
178}
179
180/// `<origin>/auth/sso/oidc/callback`.
181pub fn redirect_uri(origin: &str) -> String {
182 format!("{}/auth/sso/oidc/callback", origin.trim_end_matches('/'))
183}
184
185/// `<origin>/auth/sso/saml/<account>` — the SP entity id, one per account so
186/// two companies' IdPs never share an audience.
187pub fn sp_entity_id(origin: &str, account: &str) -> String {
188 format!("{}/auth/sso/saml/{account}", origin.trim_end_matches('/'))
189}
190
191/// `<origin>/auth/sso/saml/acs`.
192pub fn acs_url(origin: &str) -> String {
193 format!("{}/auth/sso/saml/acs", origin.trim_end_matches('/'))
194}
195
196/// Where a directory user came from.
197#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
198#[serde(rename_all = "lowercase")]
199pub enum SsoSource {
200 /// Created just in time by its first SSO login.
201 Jit,
202 /// Provisioned by the company's directory over SCIM.
203 Scim,
204 /// Provisioned by the account admin's CSV import.
205 Csv,
206}
207
208impl SsoSource {
209 /// The word a listing shows.
210 pub fn as_str(self) -> &'static str {
211 match self {
212 SsoSource::Jit => "jit",
213 SsoSource::Scim => "scim",
214 SsoSource::Csv => "csv",
215 }
216 }
217}
218
219/// **One person of a company's directory**: an EMPLOYEE of `account`, keyed
220/// by its principal id. Written in the same compare-and-swap as the employee
221/// it describes, removed with it.
222#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
223#[serde(deny_unknown_fields)]
224pub struct SsoUser {
225 /// The employee.
226 pub principal_id: String,
227 /// The account it is an employee of.
228 pub account: String,
229 /// The directory's user name, lowercase (SCIM `userName`, the mapped
230 /// claim or attribute). Unique within the account.
231 pub user_name: String,
232 #[serde(default)]
233 /// The person's name, as the directory says it.
234 pub display_name: String,
235 #[serde(default)]
236 /// The person's address, as the directory says it (never mailed by gunnar).
237 pub email: String,
238 /// The directory's own id for the person (SCIM `externalId`).
239 #[serde(default)]
240 pub external_id: String,
241 /// [`subject_key`] of the IdP subject this person logs in as, once bound
242 /// (by the first login). `None`: provisioned, never logged in yet.
243 #[serde(default)]
244 pub subject: Option<String>,
245 /// False: deactivated — gated, sessions revoked, data kept.
246 pub active: bool,
247 /// Where the row came from.
248 pub source: SsoSource,
249 /// When it was created.
250 pub created: UnixMillis,
251 #[serde(default)]
252 /// When its last SSO login was.
253 pub last_login: Option<UnixMillis>,
254 /// The web session keys SSO logins enrolled (fingerprints), so a
255 /// deactivation ends them. Pruned at each login.
256 #[serde(default)]
257 pub sessions: Vec<String>,
258 /// Removed from the directory (SCIM DELETE): still gated, data kept, no
259 /// longer listed or claimable; its user name is free for a NEW person.
260 #[serde(default)]
261 pub deleted: bool,
262 /// The account admin opened a rebind (A34-11, an IdP migration): unbound
263 /// from its old subject, its next sign-in binds it afresh on the claim rules.
264 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
265 pub rebind: bool,
266}
267
268/// A SCIM bearer, by the SHA-256 of its secret.
269#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
270#[serde(deny_unknown_fields)]
271pub struct ScimToken {
272 /// The public id a listing and a revocation name.
273 pub id: String,
274 /// The account the bearer provisions.
275 pub account: String,
276 /// Lowercase hex SHA-256 of the secret. Never the secret.
277 pub secret_hash: String,
278 #[serde(default)]
279 /// What the account admin called it.
280 pub label: String,
281 /// When it was created.
282 pub created: UnixMillis,
283 /// Who created it.
284 pub created_by: String,
285 #[serde(default)]
286 /// When it was last presented (to a minute).
287 pub last_used: Option<UnixMillis>,
288 /// When it stops working (A34-27); `None` never. Not written when `None`,
289 /// so a row without one reads as before.
290 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub expires: Option<UnixMillis>,
292 /// It may only read the directory (A34-27). Not written when false.
293 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
294 pub read_only: bool,
295}
296
297/// A SCIM group of an account's directory. Kept as the directory sent it;
298/// what a group may reach is granted by the account admin.
299#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
300#[serde(deny_unknown_fields)]
301pub struct SsoGroup {
302 /// The group's id (SCIM `id`).
303 pub id: String,
304 /// The account whose directory it is in.
305 pub account: String,
306 /// The group's name (SCIM `displayName`), unique within the account.
307 pub display_name: String,
308 #[serde(default)]
309 /// The directory's own id for the group (SCIM `externalId`).
310 pub external_id: String,
311 /// Principal ids (employees of the same account only).
312 #[serde(default)]
313 pub members: Vec<String>,
314 /// When it was created.
315 pub created: UnixMillis,
316}
317
318/// The secret-store key of an account's OIDC client secret.
319pub fn client_secret_key(account: &str) -> String {
320 format!("sso/{account}/oidc-client-secret")
321}
322
323/// The gate holder a directory deactivation writes, so a reactivation lifts
324/// only its own gate and never an operator's suspension.
325pub fn gate_holder(account: &str) -> String {
326 format!("sso:{account}")
327}
328
329/// **The key a subject is bound under**: SHA-256 of `issuer ␟ subject`, hex.
330/// Hashed so a listing of the identity table carries no IdP identifiers.
331pub fn subject_key(issuer: &str, subject: &str) -> String {
332 nornir_identity_sso::rules::subject_key(issuer, subject)
333}
334
335/// The longest principal id a console carries in a session.
336pub const MAX_ID_LEN: usize = 64;
337
338/// **The ids a NEW employee for `user_name` may take**, in order:
339/// `<account>.<local>`, then `-2` … `-9`, then short suffixes derived from the
340/// whole user name (A34-42: the 10th person with the same local part was
341/// refused) — the local part of an address (or the whole name) reduced to
342/// `[a-z0-9._-]`. Never an existing principal: the caller takes the first one
343/// that is free and creates it. The rule is edda's
344/// ([`nornir_identity_directory::names::principal_id_candidates`]), this
345/// crate adds its id length and component rule.
346pub fn principal_id_candidates(account: &str, user_name: &str) -> Vec<String> {
347 nornir_identity_directory::names::principal_id_candidates(
348 account,
349 user_name,
350 MAX_ID_LEN,
351 |id| crate::sandbox::check_component(id, id).is_ok(),
352 )
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 #[test]
360 fn candidates_are_the_accounts_and_valid_ids() {
361 let ids = principal_id_candidates("acme", "Bob.Smith@ACME.example");
362 assert_eq!(ids[0], "acme.bob.smith");
363 assert_eq!(ids[1], "acme.bob.smith-2");
364 assert_eq!(ids.len(), 13, "-1..-9 and four hashed suffixes: {ids:?}");
365 let odd = principal_id_candidates("acme", " ..--åsa ö'connor--..");
366 assert_eq!(odd[0], "acme.sa-connor");
367 let empty = principal_id_candidates("acme", "@@@");
368 assert_eq!(empty[0], "acme.user");
369 let long = principal_id_candidates("acme", &"x".repeat(200));
370 assert!(long.iter().all(|id| id.len() <= MAX_ID_LEN));
371 }
372
373 /// A provider with no CA is written as before: a gunnar from before the
374 /// CA reads it (`deny_unknown_fields`).
375 #[test]
376 fn a_provider_without_a_ca_is_written_as_before() {
377 let o = OidcSettings {
378 issuer: "https://idp.example".into(),
379 client_id: "c".into(),
380 ..Default::default()
381 };
382 let text = serde_json::to_string(&o).expect("json");
383 assert!(!text.contains("idp_ca"), "{text}");
384 let with = OidcSettings {
385 idp_ca: vec!["AAAA".into()],
386 ..o
387 };
388 assert!(serde_json::to_string(&with)
389 .expect("json")
390 .contains("idp_ca"));
391 }
392
393 #[test]
394 fn subject_keys_bind_the_issuer() {
395 assert_ne!(
396 subject_key("https://a.example", "1"),
397 subject_key("https://b.example", "1")
398 );
399 assert_eq!(
400 subject_key("https://a.example/", "1"),
401 subject_key("https://a.example", "1")
402 );
403 assert_eq!(subject_key("i", "s").len(), 64);
404 }
405}