Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

1,218 linesCodeBlameRaw
1//! Single sign-on: a workspace's own identity provider, on every paid plan.
2//! See `g1t_contracts::sso` for the model; in short, it never signs anyone
3//! in to g1t. A person signed in to g1t proves themselves to the
4//! workspace's provider to use the workspace:
5//!
6//! 1. `sso_start` keeps a state row bound to their session and sends them
7//! to the provider: OpenID Connect or OAuth 2.0 with PKCE (oauth.rs),
8//! or a SAML request (saml.rs).
9//! 2. `sso_finish` (a code) or `sso_saml` (a posted response) spends the
10//! state, checks what the provider said, and `complete` links who they
11//! are there to their g1t account (once, and one each way), adds them
12//! as a member when the workspace lets its provider's people join, and
13//! marks their session as signed in through it for a day.
14//!
15//! **Requiring it.** `workspaces.require_sso` is read with the memberships
16//! (workspaces.rs), so it costs nothing where no workspace asks. A member
17//! of one that requires it uses it ([`Identity::sso_holds`]) only from a
18//! session marked for it (`sso_sessions`), or with a token or key once
19//! they have linked (`sso_identities`); otherwise the workspace, and their
20//! roles on its repositories, are held out with a notice that links to
21//! signing in through it. Owners and g1t's own agent are never held, so a
22//! provider that breaks never locks a workspace out of itself.
23//!
24//! **Paid plans.** Setting it up, changing it, requiring it, and signing in
25//! through it need a paid plan (paid.rs). Turning it off and deleting it
26//! never do. A workspace whose plan ends keeps its settings; its members
27//! cannot sign in through it until the plan starts again or an owner turns
28//! the requirement off.
29
30#[path = "sso/oauth.rs"]
31mod oauth;
32#[path = "sso/saml.rs"]
33mod saml;
34#[path = "sso/scim.rs"]
35mod scim;
36#[path = "sso/jwt.rs"]
37mod jwt;
38#[path = "sso/provisioning.rs"]
39mod provisioning;
40
41use std::collections::HashSet;
42
43use base64::Engine;
44use base64::engine::general_purpose::STANDARD;
45use g1t_contracts::audit::Surface;
46use g1t_contracts::events::JoinedHow;
47use g1t_contracts::members::PolicyHold;
48use g1t_contracts::sso::*;
49use g1t_contracts::time::{SQL_NOW, rfc3339, sql_after};
50use g1t_contracts::identity::SlugArgs;
51use g1t_contracts::{FailureCode, Membership, Outcome, PrincipalKind, Role, User, new_id};
52use g1t_kit::now_ms;
53use g1t_secrets::Sealer;
54use serde::Deserialize;
55use serde_json::Value;
56use worker::Result;
57use worker::wasm_bindgen::JsValue;
58
59use crate::security::is_person;
60use crate::{Identity, crypto};
61
62const STATE_TTL_SECONDS: u64 = 10 * 60;
63/// How long a session signed in through a workspace's provider counts as
64/// that, before it goes through the provider again (usually at once: the
65/// provider remembers them).
66const SSO_SESSION_SECONDS: u64 = 24 * 60 * 60;
67const OWNERS_ONLY: &str = "Only an owner can change the workspace's single sign-on.";
68const EXPIRED: &str = "This single sign-on took too long or was already used. Start again.";
69const MAX_FIELD: usize = 2048;
70
71/// What to say to a free workspace.
72fn plan_needed(slug: &str) -> String {
73 format!("Single sign-on comes with every paid plan. An owner of {slug} can start the plan at /{slug}/-/billing#plan.")
74}
75
76/// What to tell a member a workspace holds out until they sign in through
77/// its provider.
78pub fn hold_reason(slug: &str) -> String {
79 format!("{slug} requires single sign-on. Sign in through its identity provider to use it.")
80}
81
82#[derive(Deserialize)]
83struct ConnectionRow {
84 id: String,
85 workspace_id: String,
86 protocol: String,
87 config: String,
88 client_secret: Option<String>,
89 allow_join: u8,
90 tested_at: Option<String>,
91 #[serde(default)]
92 tested_by_username: Option<String>,
93 created_at: String,
94 updated_at: String,
95}
96
97impl ConnectionRow {
98 fn protocol(&self) -> SsoProtocol {
99 SsoProtocol::parse(&self.protocol).unwrap_or(SsoProtocol::Oidc)
100 }
101
102 fn config(&self) -> SsoConfig {
103 serde_json::from_str(&self.config).unwrap_or_default()
104 }
105
106 fn view(&self) -> SsoConnection {
107 let config = self.config();
108 SsoConnection {
109 id: self.id.clone(),
110 protocol: self.protocol(),
111 has_client_secret: self.client_secret.is_some(),
112 certificates: config
113 .certificates
114 .iter()
115 .filter_map(|der| STANDARD.decode(der).ok())
116 .map(|der| SsoCertificate { fingerprint: saml::fingerprint(&der) })
117 .collect(),
118 config,
119 allow_join: self.allow_join != 0,
120 tested_at: self.tested_at.clone(),
121 tested_by: self.tested_by_username.clone(),
122 created_at: self.created_at.clone(),
123 updated_at: self.updated_at.clone(),
124 }
125 }
126}
127
128#[derive(Deserialize)]
129struct StateRow {
130 connection_id: String,
131 session_id: String,
132 user_id: String,
133 verifier: Option<String>,
134 nonce: Option<String>,
135 redirect_uri: String,
136 audience: Option<String>,
137 next: String,
138}
139
140/// Who the provider says signed in.
141pub(crate) struct Said {
142 /// Its stable id for them: OpenID's `sub`, the userinfo subject claim,
143 /// or SAML's NameID. An address-shaped one is lowercased, as providers
144 /// change its case.
145 pub subject: String,
146 /// Every stable id it gave, the subject first: also Entra's `oid` and,
147 /// for an Entra tenant's own members, their user principal name.
148 pub ids: Vec<String>,
149 /// The address it gave, for showing.
150 pub email: Option<String>,
151 /// Whether that address can be trusted to be theirs: OpenID's
152 /// `email_verified`, or a SAML assertion the provider signed. Only a
153 /// trusted address matches anyone or lets anyone join by domain.
154 pub email_verified: bool,
155}
156
157impl Said {
158 fn new(subject: String, email: Option<String>, email_verified: bool) -> Said {
159 let subject = if subject.contains('@') { subject.trim().to_lowercase() } else { subject.trim().to_owned() };
160 Said { ids: vec![subject.clone()], subject, email: email.map(|e| e.trim().to_lowercase()).filter(|e| e.contains('@')), email_verified }
161 }
162
163 fn trusted_email(&self) -> Option<&str> {
164 self.email.as_deref().filter(|_| self.email_verified)
165 }
166}
167
168/// What an OpenID Connect ID token says: Entra's `oid` and, for its tenant's
169/// own members (never a guest, `#EXT#`), the user principal name are stable
170/// ids SCIM may know them by.
171fn said_by_oidc(claims: &Value, issuer: &str) -> Option<Said> {
172 let subject = oauth::claim(claims, "sub")?;
173 let mut said = Said::new(subject, oauth::claim(claims, "email"), claims["email_verified"].as_bool() == Some(true));
174 if issuer.starts_with("https://login.microsoftonline.com/") {
175 said.ids.extend(oauth::claim(claims, "oid"));
176 said.ids.extend(oauth::claim(claims, "preferred_username").filter(|upn| !upn.contains("#EXT#")).map(|upn| upn.to_lowercase()));
177 }
178 Some(said)
179}
180
181/// Why `said` may not join by its address, when the connection names the
182/// domains that may: an address the provider vouches for, at one of them.
183fn join_domain_refusal(domains: &[String], said: &Said, slug: &str) -> Option<String> {
184 if domains.is_empty() {
185 return None;
186 }
187 let at = said.trusted_email().and_then(|email| email.rsplit_once('@')).map(|(_, domain)| domain.to_owned());
188 match at {
189 Some(domain) if domains.iter().any(|d| d.eq_ignore_ascii_case(&domain)) => None,
190 _ => Some(format!(
191 "Only people with an address at {} join {slug} through its identity provider. Ask an owner to invite you.",
192 domains.join(" or ")
193 )),
194 }
195}
196
197/// The domains a connection lets join, as an owner entered them.
198fn domains_from(given: &[String]) -> std::result::Result<Vec<String>, String> {
199 let mut out: Vec<String> = Vec::new();
200 for domain in given.iter().flat_map(|d| d.split([',', ' ', '\n'])) {
201 let domain = domain.trim().trim_start_matches('@').to_lowercase();
202 if domain.is_empty() {
203 continue;
204 }
205 if !domain.contains('.') || domain.len() > 253 || !domain.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') {
206 return Err(format!("{domain} is not a domain, such as acme.com."));
207 }
208 if !out.contains(&domain) {
209 out.push(domain);
210 }
211 }
212 out.truncate(20);
213 Ok(out)
214}
215
216/// Which provider a connection is: changing it unlinks everyone.
217fn provider_of(protocol: SsoProtocol, config: &SsoConfig) -> String {
218 let at = match protocol {
219 SsoProtocol::Oidc => config.issuer.as_deref(),
220 SsoProtocol::Oauth2 => config.authorization_url.as_deref(),
221 SsoProtocol::Saml => config.idp_entity_id.as_deref(),
222 };
223 format!("{}:{}", protocol.as_str(), at.unwrap_or_default().trim().trim_end_matches('/'))
224}
225
226/// A field as given: trimmed, empty as absent, and not too long.
227fn field(value: &Option<String>, name: &str) -> std::result::Result<Option<String>, String> {
228 let value = value.as_deref().map(str::trim).filter(|v| !v.is_empty());
229 match value {
230 Some(v) if v.len() > MAX_FIELD => Err(format!("The {name} is too long.")),
231 v => Ok(v.map(str::to_owned)),
232 }
233}
234
235fn https_field(value: &Option<String>, name: &str) -> std::result::Result<String, String> {
236 let value = field(value, name)?.ok_or_else(|| format!("Enter the {name}."))?;
237 oauth::https(&value).ok_or_else(|| format!("The {name} must be an https address."))?;
238 Ok(value)
239}
240
241/// The stored config for what an owner entered, checked for the protocol;
242/// `certificates` are SAML's, as they will be kept. Only the protocol's
243/// own fields are kept.
244fn checked_config(protocol: SsoProtocol, given: &SsoConfig, certificates: Vec<String>) -> std::result::Result<SsoConfig, String> {
245 let client_id = || field(&given.client_id, "client id")?.ok_or_else(|| "Enter the client id.".to_owned());
246 let join_domains = domains_from(&given.join_domains)?;
247 let config = match protocol {
248 SsoProtocol::Oidc => SsoConfig {
249 issuer: Some(https_field(&given.issuer, "issuer")?),
250 client_id: Some(client_id()?),
251 scopes: field(&given.scopes, "scopes")?,
252 ..SsoConfig::default()
253 },
254 SsoProtocol::Oauth2 => SsoConfig {
255 authorization_url: Some(https_field(&given.authorization_url, "authorization URL")?),
256 token_url: Some(https_field(&given.token_url, "token URL")?),
257 userinfo_url: Some(https_field(&given.userinfo_url, "userinfo URL")?),
258 client_id: Some(client_id()?),
259 scopes: field(&given.scopes, "scopes")?,
260 subject_claim: field(&given.subject_claim, "subject claim")?,
261 email_claim: field(&given.email_claim, "email claim")?,
262 ..SsoConfig::default()
263 },
264 SsoProtocol::Saml => {
265 if certificates.is_empty() {
266 return Err("Paste the identity provider's signing certificate.".to_owned());
267 }
268 SsoConfig {
269 idp_entity_id: Some(field(&given.idp_entity_id, "entity id")?.ok_or("Enter the identity provider's entity id (its issuer).")?),
270 idp_sso_url: Some(https_field(&given.idp_sso_url, "single sign-on URL")?),
271 certificates,
272 ..SsoConfig::default()
273 }
274 }
275 };
276 Ok(SsoConfig { join_domains, ..config })
277}
278
279/// How someone came in, for what single sign-on asks of them.
280#[derive(Clone, Copy, Debug, PartialEq, Eq)]
281pub enum Via<'a> {
282 /// A session on the site: its hash.
283 Session(&'a str),
284 /// An access token, an SSH key, a deploy key.
285 Credential,
286 /// Their g1t password (git over HTTPS without a token).
287 Password,
288}
289
290/// Which of a person's memberships a workspace's single sign-on holds out:
291/// those that require it and they have not met, never as an owner.
292pub fn held_by_sso(required: &[(String, Role)], met: &HashSet<String>) -> Vec<String> {
293 required
294 .iter()
295 .filter(|(slug, role)| *role != Role::Owner && !met.contains(slug))
296 .map(|(slug, _)| slug.clone())
297 .collect()
298}
299
300/// Leaves out the memberships and grants of the workspaces in `held`, and
301/// says why, beside any other holds.
302pub fn apply_sso_holds(user: &mut User, held: &[String]) {
303 if held.is_empty() {
304 return;
305 }
306 user.workspaces.retain(|membership: &Membership| !held.contains(&membership.slug));
307 user.grants.retain(|grant| !held.contains(&grant.workspace));
308 for slug in held {
309 if !user.held.iter().any(|hold| &hold.slug == slug) {
310 user.held.push(PolicyHold { slug: slug.clone(), reason: hold_reason(slug), gap: "sso".to_owned() });
311 }
312 }
313}
314
315impl Identity {
316 fn sso_sealer(&self) -> Option<Sealer> {
317 Sealer::new(&self.env.secret("IDENTITY_KEY").ok()?.to_string())
318 }
319
320 async fn connection_where(&self, column: &str, value: &str) -> Result<Option<ConnectionRow>> {
321 self.db
322 .prepare(format!(
323 "SELECT c.*, u.username AS tested_by_username FROM sso_connections c
324 LEFT JOIN users u ON u.id = c.tested_by WHERE c.{column} = ?"
325 ))
326 .bind(&[value.into()])?
327 .first::<ConnectionRow>(None)
328 .await
329 }
330
331 /// The workspace's id when `actor` is a confirmed person who owns it.
332 async fn sso_owner(&self, actor: &User, slug: &str) -> Result<Outcome<String>> {
333 if !is_person(actor) || actor.role_in(slug) != Some(Role::Owner) {
334 return Ok(Outcome::fail(FailureCode::Forbidden, OWNERS_ONLY));
335 }
336 if !actor.verified {
337 return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address first."));
338 }
339 Ok(match self.workspace_id_of(slug).await? {
340 Some(id) => Outcome::Ok(id),
341 None => Outcome::fail(FailureCode::NotFound, "Workspace not found."),
342 })
343 }
344
345 async fn require_sso_of(&self, workspace_id: &str) -> Result<bool> {
346 #[derive(Deserialize)]
347 struct Row {
348 require_sso: u8,
349 }
350 Ok(self
351 .db
352 .prepare("SELECT require_sso FROM workspaces WHERE id = ?")
353 .bind(&[workspace_id.into()])?
354 .first::<Row>(None)
355 .await?
356 .is_some_and(|row| row.require_sso != 0))
357 }
358
359 async fn sso_linked(&self, connection_id: &str, user_id: &str) -> Result<bool> {
360 Ok(self
361 .db
362 .prepare("SELECT 1 AS yes FROM sso_identities WHERE connection_id = ? AND user_id = ?")
363 .bind(&[connection_id.into(), user_id.into()])?
364 .first::<Value>(None)
365 .await?
366 .is_some())
367 }
368
369 /// `sso_settings`: the workspace's single sign-on, for its owners.
370 pub async fn sso_settings(&self, a: SsoSettingsArgs) -> Result<Outcome<SsoSettings>> {
371 let slug = a.slug.trim().to_lowercase();
372 let workspace_id = match self.sso_owner(&a.actor, &slug).await? {
373 Outcome::Ok(id) => id,
374 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
375 };
376 self.settings_of(&workspace_id, &slug, &a.actor).await.map(Outcome::Ok)
377 }
378
379 async fn settings_of(&self, workspace_id: &str, slug: &str, actor: &User) -> Result<SsoSettings> {
380 let row = self.connection_where("workspace_id", workspace_id).await?;
381 let (identities, you_linked) = match &row {
382 Some(row) => {
383 #[derive(Deserialize)]
384 struct Linked {
385 user_id: String,
386 username: String,
387 display_username: Option<String>,
388 subject: String,
389 email: Option<String>,
390 created_at: String,
391 last_used_at: String,
392 }
393 let linked = self
394 .db
395 .prepare(
396 "SELECT i.user_id, u.username, u.display_username, i.subject, i.email, i.created_at, i.last_used_at
397 FROM sso_identities i JOIN users u ON u.id = i.user_id
398 WHERE i.connection_id = ? AND u.deleted_at IS NULL ORDER BY u.username LIMIT 500",
399 )
400 .bind(&[row.id.as_str().into()])?
401 .all()
402 .await?
403 .results::<Linked>()?;
404 let you = linked.iter().any(|l| l.user_id == actor.id);
405 let identities = linked
406 .into_iter()
407 .map(|l| SsoIdentity {
408 username: l.username,
409 display_username: l.display_username,
410 subject: l.subject,
411 email: l.email,
412 linked_at: l.created_at,
413 last_used_at: l.last_used_at,
414 })
415 .collect();
416 (identities, you)
417 }
418 None => (Vec::new(), false),
419 };
420 Ok(SsoSettings {
421 slug: slug.to_owned(),
422 connection: row.as_ref().map(ConnectionRow::view),
423 required: self.require_sso_of(workspace_id).await?,
424 you_linked,
425 identities,
426 scim: self.scim_status(workspace_id).await?,
427 })
428 }
429
430 /// `admin_sso`: a workspace's single sign-on, for staff in sudo.
431 pub async fn admin_sso(&self, a: SlugArgs) -> Result<AdminSso> {
432 let slug = a.slug.trim().to_lowercase();
433 let Some(workspace_id) = self.workspace_id_of(&slug).await? else {
434 return Ok(None);
435 };
436 if self.connection_where("workspace_id", &workspace_id).await?.is_none() {
437 return Ok(None);
438 }
439 self.settings_of(&workspace_id, &slug, &User::default()).await.map(Some)
440 }
441
442 /// `sso_summary`: whether a workspace has single sign-on, for anyone.
443 pub async fn sso_summary(&self, a: SlugArgs) -> Result<SsoSummary> {
444 let Some(workspace_id) = self.workspace_id_of(&a.slug.to_lowercase()).await? else {
445 return Ok(SsoSummary::default());
446 };
447 let Some(row) = self.connection_where("workspace_id", &workspace_id).await? else {
448 return Ok(SsoSummary::default());
449 };
450 Ok(SsoSummary {
451 enabled: true,
452 protocol: Some(row.protocol()),
453 required: self.require_sso_of(&workspace_id).await?,
454 allow_join: row.allow_join != 0,
455 })
456 }
457
458 /// `save_sso`: see [`SaveSsoArgs`].
459 pub async fn save_sso(&self, a: SaveSsoArgs) -> Result<Outcome<SsoSettings>> {
460 let slug = a.slug.trim().to_lowercase();
461 let workspace_id = match self.sso_owner(&a.actor, &slug).await? {
462 Outcome::Ok(id) => id,
463 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
464 };
465 if let Some(refused) = self.plan_refusal(&slug, plan_needed(&slug)).await? {
466 return Ok(refused);
467 }
468 let Some(sealer) = self.sso_sealer() else {
469 return Ok(Outcome::fail(FailureCode::Conflict, "Single sign-on is not set up on this g1t: it needs IDENTITY_KEY."));
470 };
471 let before = self.connection_where("workspace_id", &workspace_id).await?;
472 let same_protocol = before.as_ref().filter(|row| row.protocol() == a.protocol);
473 // SAML's certificates: as pasted, or the ones kept.
474 let certificates = match a.certificate.as_deref().map(str::trim).filter(|c| !c.is_empty()) {
475 Some(pasted) => match saml::certificates_from(pasted) {
476 Ok(certificates) => certificates.iter().map(|der| STANDARD.encode(der)).collect(),
477 Err(why) => return Ok(Outcome::fail(FailureCode::Invalid, why)),
478 },
479 None => same_protocol.map(|row| row.config().certificates).unwrap_or_default(),
480 };
481 let config = match checked_config(a.protocol, &a.config, if a.protocol == SsoProtocol::Saml { certificates } else { vec![] }) {
482 Ok(config) => config,
483 Err(why) => return Ok(Outcome::fail(FailureCode::Invalid, why)),
484 };
485 if a.protocol == SsoProtocol::Oidc
486 && let Err(why) = oauth::discover(config.issuer.as_deref().unwrap_or_default()).await
487 {
488 return Ok(Outcome::fail(FailureCode::Invalid, why));
489 }
490 let id = before.as_ref().map(|row| row.id.clone()).unwrap_or_else(|| new_id("sso", now_ms()));
491 // The client secret: as given, or the one kept.
492 let secret = match a.client_secret.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
493 _ if a.protocol == SsoProtocol::Saml => None,
494 Some(secret) if secret.len() > MAX_FIELD => return Ok(Outcome::fail(FailureCode::Invalid, "The client secret is too long.")),
495 Some(secret) => Some(sealer.seal(secret, &format!("sso:{id}"))),
496 None => match same_protocol.and_then(|row| row.client_secret.clone()) {
497 Some(kept) => Some(kept),
498 None => return Ok(Outcome::fail(FailureCode::Invalid, "Enter the client secret.")),
499 },
500 };
501 let changed_provider = before.as_ref().is_some_and(|row| provider_of(row.protocol(), &row.config()) != provider_of(a.protocol, &config));
502 let now = rfc3339(now_ms());
503 let stored = serde_json::to_string(&config).unwrap_or_default();
504 let secret_value = secret.as_deref().map_or(JsValue::NULL, Into::into);
505 self.db
506 .prepare(
507 "INSERT INTO sso_connections (id, workspace_id, protocol, config, client_secret, allow_join, created_at, updated_at)
508 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)
509 ON CONFLICT (workspace_id) DO UPDATE SET protocol = excluded.protocol, config = excluded.config,
510 client_secret = excluded.client_secret, allow_join = excluded.allow_join, updated_at = excluded.updated_at",
511 )
512 .bind(&[
513 id.as_str().into(),
514 workspace_id.as_str().into(),
515 a.protocol.as_str().into(),
516 stored.into(),
517 secret_value,
518 (a.allow_join as u8).into(),
519 now.as_str().into(),
520 ])?
521 .run()
522 .await?;
523 if changed_provider {
524 // Another provider: who people were at the old one means nothing
525 // now, and it must be tried before it is required again.
526 self.db
527 .batch(vec![
528 self.db.prepare("DELETE FROM sso_identities WHERE connection_id = ?").bind(&[id.as_str().into()])?,
529 self.db.prepare("DELETE FROM sso_sessions WHERE workspace_id = ?").bind(&[workspace_id.as_str().into()])?,
530 self.db
531 .prepare("UPDATE sso_connections SET tested_at = NULL, tested_by = NULL WHERE id = ?")
532 .bind(&[id.as_str().into()])?,
533 self.db.prepare("UPDATE workspaces SET require_sso = 0 WHERE id = ?").bind(&[workspace_id.as_str().into()])?,
534 ])
535 .await?;
536 }
537 let surface = a.surface.unwrap_or(Surface::Web);
538 let message = match (&before, changed_provider) {
539 (None, _) => format!("Set up single sign-on over {}", protocol_name(a.protocol)),
540 (Some(_), true) => format!("Moved single sign-on to another identity provider ({}), unlinking everyone", protocol_name(a.protocol)),
541 (Some(_), false) => "Changed single sign-on's settings".to_owned(),
542 };
543 self.audit_workspace(&a.actor, if before.is_none() { "sso.created" } else { "sso.updated" }, &slug, surface, message).await;
544 self.announce_workspace(&workspace_id, &slug, Some(&a.actor.id)).await;
545 self.settings_of(&workspace_id, &slug, &a.actor).await.map(Outcome::Ok)
546 }
547
548 /// `set_sso_required`: see [`SetSsoRequiredArgs`].
549 pub async fn set_sso_required(&self, a: SetSsoRequiredArgs) -> Result<Outcome<bool>> {
550 let slug = a.slug.trim().to_lowercase();
551 let workspace_id = match self.sso_owner(&a.actor, &slug).await? {
552 Outcome::Ok(id) => id,
553 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
554 };
555 let before = self.require_sso_of(&workspace_id).await?;
556 if before == a.required {
557 return Ok(Outcome::Ok(before));
558 }
559 if a.required {
560 let Some(row) = self.connection_where("workspace_id", &workspace_id).await? else {
561 return Ok(Outcome::fail(FailureCode::Conflict, "Set up single sign-on before requiring it."));
562 };
563 if let Some(refused) = self.plan_refusal(&slug, plan_needed(&slug)).await? {
564 return Ok(refused);
565 }
566 if !self.sso_linked(&row.id, &a.actor.id).await? {
567 return Ok(Outcome::fail(
568 FailureCode::Conflict,
569 "Sign in through the identity provider yourself before requiring it of everyone: use Test sign-in.",
570 ));
571 }
572 }
573 self.db
574 .prepare("UPDATE workspaces SET require_sso = ? WHERE id = ?")
575 .bind(&[(a.required as u8).into(), workspace_id.as_str().into()])?
576 .run()
577 .await?;
578 self.audit_workspace(
579 &a.actor,
580 if a.required { "sso.required" } else { "sso.not_required" },
581 &slug,
582 a.surface.unwrap_or(Surface::Web),
583 if a.required { "Required single sign-on of members".to_owned() } else { "Stopped requiring single sign-on".to_owned() },
584 )
585 .await;
586 self.announce_workspace(&workspace_id, &slug, Some(&a.actor.id)).await;
587 Ok(Outcome::Ok(a.required))
588 }
589
590 /// `delete_sso`: removes the connection, everyone's links to it, and the
591 /// requirement. Never needs a paid plan.
592 pub async fn delete_sso(&self, a: SsoSettingsArgs) -> Result<Outcome<bool>> {
593 let slug = a.slug.trim().to_lowercase();
594 let workspace_id = match self.sso_owner(&a.actor, &slug).await? {
595 Outcome::Ok(id) => id,
596 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
597 };
598 let Some(row) = self.connection_where("workspace_id", &workspace_id).await? else {
599 return Ok(Outcome::Ok(false));
600 };
601 self.db
602 .batch(vec![
603 self.db.prepare("DELETE FROM sso_states WHERE connection_id = ?").bind(&[row.id.as_str().into()])?,
604 self.db.prepare("DELETE FROM sso_identities WHERE connection_id = ?").bind(&[row.id.as_str().into()])?,
605 self.db.prepare("DELETE FROM sso_connections WHERE id = ?").bind(&[row.id.as_str().into()])?,
606 self.db.prepare("DELETE FROM sso_sessions WHERE workspace_id = ?").bind(&[workspace_id.as_str().into()])?,
607 self.db.prepare("UPDATE workspaces SET require_sso = 0 WHERE id = ?").bind(&[workspace_id.as_str().into()])?,
608 // SCIM is single sign-on's: it goes with it.
609 self.db.prepare("DELETE FROM scim_tokens WHERE workspace_id = ?").bind(&[workspace_id.as_str().into()])?,
610 self.db.prepare("DELETE FROM scim_users WHERE workspace_id = ?").bind(&[workspace_id.as_str().into()])?,
611 ])
612 .await?;
613 self.audit_workspace(&a.actor, "sso.deleted", &slug, a.surface.unwrap_or(Surface::Web), "Removed single sign-on".to_owned())
614 .await;
615 self.announce_workspace(&workspace_id, &slug, Some(&a.actor.id)).await;
616 Ok(Outcome::Ok(true))
617 }
618
619 /// `unlink_sso_identity`: see [`UnlinkSsoIdentityArgs`].
620 pub async fn unlink_sso_identity(&self, a: UnlinkSsoIdentityArgs) -> Result<Outcome<bool>> {
621 let slug = a.slug.trim().to_lowercase();
622 let workspace_id = match self.sso_owner(&a.actor, &slug).await? {
623 Outcome::Ok(id) => id,
624 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
625 };
626 let Some(row) = self.connection_where("workspace_id", &workspace_id).await? else {
627 return Ok(Outcome::Ok(false));
628 };
629 let username = a.username.trim().trim_start_matches('@').to_lowercase();
630 let removed = self
631 .db
632 .prepare(
633 "DELETE FROM sso_identities WHERE connection_id = ?
634 AND user_id = (SELECT id FROM users WHERE username = ?) RETURNING user_id",
635 )
636 .bind(&[row.id.as_str().into(), username.as_str().into()])?
637 .first::<Value>(None)
638 .await?;
639 let Some(removed) = removed else {
640 return Ok(Outcome::Ok(false));
641 };
642 if let Some(user_id) = removed["user_id"].as_str() {
643 // Their sessions go through the provider again, and their SCIM
644 // assignment links to whoever signs in as it next.
645 self.db
646 .batch(vec![
647 self.db
648 .prepare("DELETE FROM sso_sessions WHERE workspace_id = ? AND session_id IN (SELECT id FROM sessions WHERE user_id = ?)")
649 .bind(&[workspace_id.as_str().into(), user_id.into()])?,
650 self.db
651 .prepare("UPDATE scim_users SET user_id = NULL WHERE workspace_id = ? AND user_id = ?")
652 .bind(&[workspace_id.as_str().into(), user_id.into()])?,
653 ])
654 .await?;
655 }
656 self.audit_workspace(
657 &a.actor,
658 "sso.identity_unlinked",
659 &slug,
660 a.surface.unwrap_or(Surface::Web),
661 format!("Unlinked {username}'s identity at the single sign-on provider"),
662 )
663 .await;
664 Ok(Outcome::Ok(true))
665 }
666
667 /// `sso_start`: see [`SsoStartArgs`].
668 pub async fn sso_start(&self, a: SsoStartArgs) -> Result<Outcome<SsoStart>> {
669 if !is_person(&a.user) {
670 return Ok(Outcome::fail(FailureCode::Forbidden, "Sign in to g1t as yourself first."));
671 }
672 if !a.user.verified {
673 return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address first."));
674 }
675 let slug = a.slug.trim().to_lowercase();
676 let Some(workspace_id) = self.workspace_id_of(&slug).await? else {
677 return Ok(Outcome::fail(FailureCode::NotFound, "There is no workspace with that name."));
678 };
679 let Some(row) = self.connection_where("workspace_id", &workspace_id).await? else {
680 return Ok(Outcome::fail(FailureCode::NotFound, format!("{slug} has not set up single sign-on.")));
681 };
682 if row.allow_join == 0 && !self.is_member_of(&workspace_id, &a.user.id).await? {
683 return Ok(Outcome::fail(
684 FailureCode::Forbidden,
685 format!("Only members of {slug} sign in through its identity provider. Ask an owner to invite you."),
686 ));
687 }
688 if let Some(refused) = self.plan_refusal(&slug, plan_needed(&slug)).await? {
689 return Ok(refused);
690 }
691 let session_id = crypto::sha256_hex(&a.session_token);
692 let live = self
693 .db
694 .prepare(format!("SELECT 1 AS yes FROM sessions WHERE id = ? AND user_id = ? AND expires_at > {SQL_NOW}"))
695 .bind(&[session_id.as_str().into(), a.user.id.as_str().into()])?
696 .first::<Value>(None)
697 .await?;
698 if live.is_none() {
699 return Ok(Outcome::fail(FailureCode::Unauthenticated, "Sign in to g1t again."));
700 }
701 let callback = worker::Url::parse(&a.redirect_uri).ok();
702 if !callback.is_some_and(|url| url.scheme() == "https" || url.host_str() == Some("localhost")) {
703 return Ok(Outcome::fail(FailureCode::Invalid, "The callback must be an https address."));
704 }
705 let config = row.config();
706 let (key, url, state, verifier, nonce) = match row.protocol() {
707 SsoProtocol::Saml => {
708 let Some(entity_id) = a.entity_id.as_deref().filter(|e| !e.is_empty()) else {
709 return Ok(Outcome::fail(FailureCode::Invalid, "SAML needs g1t's entity id."));
710 };
711 let request_id = saml::request_id(&crypto::random_hex(20));
712 let sso_url = config.idp_sso_url.as_deref().unwrap_or_default();
713 let Some(url) = saml::authn_request_url(sso_url, &request_id, entity_id, &a.redirect_uri, &slug, now_ms()) else {
714 return Ok(Outcome::fail(FailureCode::Invalid, "The identity provider's single sign-on URL must be an https address."));
715 };
716 (request_id, url, None, None, None)
717 }
718 protocol => {
719 let endpoints = match self.endpoints_of(protocol, &config).await {
720 Ok(endpoints) => endpoints,
721 Err(why) => return Ok(Outcome::fail(FailureCode::Conflict, why)),
722 };
723 let state = crypto::random_hex(32);
724 let verifier = crate::github::new_verifier();
725 let nonce = (protocol == SsoProtocol::Oidc).then(|| crypto::random_hex(16));
726 let scopes = oauth::scopes(config.scopes.as_deref(), protocol == SsoProtocol::Oidc);
727 let authorize = oauth::Authorize {
728 client_id: config.client_id.as_deref().unwrap_or_default(),
729 redirect_uri: &a.redirect_uri,
730 scopes: &scopes,
731 state: &state,
732 nonce: nonce.as_deref(),
733 challenge: &crate::github::pkce_challenge(&verifier),
734 };
735 let Some(url) = oauth::authorize_url(&endpoints.authorize, &authorize) else {
736 return Ok(Outcome::fail(FailureCode::Conflict, "The provider's authorization endpoint must be an https address."));
737 };
738 (state.clone(), url, Some(state), Some(verifier), nonce)
739 }
740 };
741 let optional = |value: Option<&str>| value.map_or(JsValue::NULL, Into::into);
742 self.db
743 .prepare(format!(
744 "INSERT INTO sso_states (id, connection_id, session_id, user_id, verifier, nonce, redirect_uri, audience, next, expires_at)
745 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, {})",
746 sql_after(STATE_TTL_SECONDS)
747 ))
748 .bind(&[
749 crypto::sha256_hex(&key).into(),
750 row.id.as_str().into(),
751 session_id.as_str().into(),
752 a.user.id.as_str().into(),
753 optional(verifier.as_deref()),
754 optional(nonce.as_deref()),
755 a.redirect_uri.as_str().into(),
756 optional(a.entity_id.as_deref()),
757 a.next.as_str().into(),
758 ])?
759 .run()
760 .await?;
761 // Old trips never finished, and sessions past their day, go now and then.
762 self.db
763 .batch(vec![
764 self.db.prepare(format!("DELETE FROM sso_states WHERE expires_at < {SQL_NOW}")),
765 self.db.prepare(format!("DELETE FROM sso_sessions WHERE expires_at < {SQL_NOW}")),
766 ])
767 .await?;
768 Ok(Outcome::Ok(SsoStart { url, state }))
769 }
770
771 async fn endpoints_of(&self, protocol: SsoProtocol, config: &SsoConfig) -> std::result::Result<oauth::Endpoints, String> {
772 match protocol {
773 SsoProtocol::Oidc => oauth::discover(config.issuer.as_deref().unwrap_or_default()).await,
774 _ => Ok(oauth::Endpoints {
775 authorize: config.authorization_url.clone().unwrap_or_default(),
776 token: config.token_url.clone().unwrap_or_default(),
777 userinfo: config.userinfo_url.clone(),
778 issuer: None,
779 jwks: None,
780 basic: false,
781 }),
782 }
783 }
784
785 /// Spends a state row, once, and finds its connection, which must be
786 /// `slug`'s.
787 async fn spend_state(&self, key: &str, slug: &str) -> Result<Option<(StateRow, ConnectionRow)>> {
788 let state = self
789 .db
790 .prepare(format!("DELETE FROM sso_states WHERE id = ? AND expires_at > {SQL_NOW} RETURNING *"))
791 .bind(&[crypto::sha256_hex(key).into()])?
792 .first::<StateRow>(None)
793 .await?;
794 let Some(state) = state else {
795 return Ok(None);
796 };
797 let Some(row) = self.connection_where("id", &state.connection_id).await? else {
798 return Ok(None);
799 };
800 let workspace = self.workspace_id_of(&slug.trim().to_lowercase()).await?;
801 Ok((workspace.as_deref() == Some(row.workspace_id.as_str())).then_some((state, row)))
802 }
803
804 /// `sso_finish`: see [`SsoFinishArgs`].
805 pub async fn sso_finish(&self, a: SsoFinishArgs) -> Result<Outcome<SsoFinished>> {
806 let Some((state, row)) = self.spend_state(&a.state, &a.slug).await? else {
807 return Ok(Outcome::fail(FailureCode::Invalid, EXPIRED));
808 };
809 if crypto::sha256_hex(&a.session_token) != state.session_id {
810 return Ok(Outcome::fail(FailureCode::Invalid, "This single sign-on started in another session. Start again."));
811 }
812 let protocol = row.protocol();
813 let config = row.config();
814 let secret = row
815 .client_secret
816 .as_deref()
817 .and_then(|sealed| self.sso_sealer()?.open(sealed, &format!("sso:{}", row.id)));
818 let Some(secret) = secret else {
819 return Ok(Outcome::fail(FailureCode::Conflict, "The client secret could not be read. An owner can enter it again in the single sign-on settings."));
820 };
821 let said = async {
822 let endpoints = self.endpoints_of(protocol, &config).await?;
823 let client_id = config.client_id.as_deref().unwrap_or_default();
824 let tokens = oauth::exchange(&oauth::Exchange {
825 token_url: &endpoints.token,
826 basic: endpoints.basic,
827 client_id,
828 client_secret: &secret,
829 code: &a.code,
830 redirect_uri: &state.redirect_uri,
831 verifier: state.verifier.as_deref().unwrap_or_default(),
832 })
833 .await?;
834 if protocol == SsoProtocol::Oidc {
835 let id_token = tokens["id_token"].as_str().ok_or("The identity provider sent no ID token.")?;
836 let keys = oauth::keys(endpoints.jwks.as_deref().unwrap_or_default()).await?;
837 let claims = oauth::id_token_claims(
838 id_token,
839 &keys,
840 endpoints.issuer.as_deref().unwrap_or_default(),
841 client_id,
842 state.nonce.as_deref().unwrap_or_default(),
843 now_ms() / 1000,
844 )?;
845 let said = said_by_oidc(&claims, endpoints.issuer.as_deref().unwrap_or_default()).ok_or("The ID token names no one.")?;
846 Ok::<_, String>(said)
847 } else {
848 let access = tokens["access_token"].as_str().ok_or("The identity provider sent no access token.")?;
849 let info = oauth::userinfo(endpoints.userinfo.as_deref().unwrap_or_default(), access).await?;
850 let subject = match config.subject_claim.as_deref() {
851 Some(path) => oauth::claim(&info, path),
852 None => oauth::claim(&info, "sub").or_else(|| oauth::claim(&info, "id")),
853 }
854 .ok_or("The userinfo answer has no id for the person: set the subject claim.")?;
855 // Plain OAuth 2.0 says nothing of whether an address is theirs.
856 let email = oauth::claim(&info, config.email_claim.as_deref().unwrap_or("email"));
857 Ok(Said::new(subject, email, false))
858 }
859 };
860 match said.await {
861 Ok(said) => self.complete(&row, &state, &a.slug, &said).await,
862 Err(why) => Ok(Outcome::fail(FailureCode::Invalid, why)),
863 }
864 }
865
866 /// `sso_saml`: see [`SsoSamlArgs`].
867 pub async fn sso_saml(&self, a: SsoSamlArgs) -> Result<Outcome<SsoFinished>> {
868 let Some(request_id) = saml::in_response_to(&a.saml_response) else {
869 return Ok(Outcome::fail(
870 FailureCode::Invalid,
871 "Start single sign-on from g1t: sign-ins started at the identity provider are not accepted.",
872 ));
873 };
874 let Some((state, row)) = self.spend_state(&request_id, &a.slug).await? else {
875 return Ok(Outcome::fail(FailureCode::Invalid, EXPIRED));
876 };
877 let config = row.config();
878 let certificates: Vec<Vec<u8>> = config.certificates.iter().filter_map(|der| STANDARD.decode(der).ok()).collect();
879 let expect = saml::Expect {
880 idp_entity_id: config.idp_entity_id.as_deref().unwrap_or_default(),
881 certificates: &certificates,
882 acs_url: &state.redirect_uri,
883 audience: state.audience.as_deref().unwrap_or_default(),
884 request_id: &request_id,
885 now_ms: now_ms(),
886 };
887 match saml::verify_response(&a.saml_response, &expect) {
888 // The provider signed the assertion, address and all.
889 Ok(asserted) => self.complete(&row, &state, &a.slug, &Said::new(asserted.subject, asserted.email, true)).await,
890 Err(why) => Ok(Outcome::fail(FailureCode::Invalid, why)),
891 }
892 }
893
894 /// The provider has said who signed in: link them, let them in, and
895 /// mark their session. Everything that can refuse is decided before
896 /// anything is written, so a refused sign-in leaves no link behind.
897 async fn complete(&self, row: &ConnectionRow, state: &StateRow, slug: &str, said: &Said) -> Result<Outcome<SsoFinished>> {
898 let slug = slug.trim().to_lowercase();
899 let user_id = state.user_id.as_str();
900 let subject = said.subject.as_str();
901 let Some(person) = self.sso_person(user_id).await? else {
902 return Ok(Outcome::fail(FailureCode::Unauthenticated, "Sign in to g1t again."));
903 };
904 #[derive(Deserialize)]
905 struct Linked {
906 user_id: String,
907 subject: String,
908 }
909 let linked = self
910 .db
911 .prepare("SELECT user_id, subject FROM sso_identities WHERE connection_id = ?1 AND (subject = ?2 OR user_id = ?3)")
912 .bind(&[row.id.as_str().into(), subject.into(), user_id.into()])?
913 .all()
914 .await?
915 .results::<Linked>()?;
916 if linked.iter().any(|l| l.subject == subject && l.user_id != user_id) {
917 return Ok(Outcome::fail(
918 FailureCode::Conflict,
919 format!("That account at {slug}'s identity provider is linked to another g1t account. An owner of {slug} can unlink it."),
920 ));
921 }
922 if linked.iter().any(|l| l.user_id == user_id && l.subject != subject) {
923 return Ok(Outcome::fail(
924 FailureCode::Conflict,
925 format!("Your g1t account is linked to another account at {slug}'s identity provider. Sign in with that one, or ask an owner of {slug} to unlink it."),
926 ));
927 }
928 // With SCIM on, only someone the provider has assigned, matched
929 // without doubt, and not as another g1t account (provisioning.rs).
930 let provisioned = match self.scim_on(&row.workspace_id).await? {
931 false => None,
932 true => match self.provisioned_as(&row.workspace_id, &said.ids, said.trusted_email()).await? {
933 provisioning::Match::One(_, Some(other)) if other != user_id => {
934 return Ok(Outcome::fail(
935 FailureCode::Conflict,
936 format!("That account at {slug}'s identity provider is linked to another g1t account. An owner of {slug} can unlink it."),
937 ));
938 }
939 provisioning::Match::One(id, _) => Some(id),
940 provisioning::Match::Many => {
941 return Ok(Outcome::fail(
942 FailureCode::Conflict,
943 format!("More than one person assigned to g1t in {slug}'s identity provider matches your account there. Ask whoever runs it to fix the assignments."),
944 ));
945 }
946 provisioning::Match::None => {
947 return Ok(Outcome::fail(
948 FailureCode::Forbidden,
949 format!("Your account at {slug}'s identity provider is not assigned to g1t. Ask whoever runs it for {slug} to assign you."),
950 ));
951 }
952 },
953 };
954 // Not a member yet: the provider vouches for them, if the workspace
955 // lets it and they meet its rules (security.rs), and they have room.
956 let member = self.is_member_of(&row.workspace_id, user_id).await?;
957 if !member {
958 if row.allow_join == 0 {
959 return Ok(Outcome::fail(FailureCode::Forbidden, format!("Only members of {slug} sign in through its identity provider. Ask an owner to invite you.")));
960 }
961 if let Some(why) = join_domain_refusal(&row.config().join_domains, said, &slug) {
962 return Ok(Outcome::fail(FailureCode::Forbidden, why));
963 }
964 if let Some(why) = self.policy_refusal(user_id, &slug).await? {
965 return Ok(Outcome::fail(FailureCode::Forbidden, why));
966 }
967 if let Some(refused) = self.plan_refusal(&slug, plan_needed(&slug)).await? {
968 return Ok(refused);
969 }
970 if self.memberships(user_id).await?.len() >= crate::workspaces::MAX_WORKSPACES_PER_USER {
971 return Ok(Outcome::fail(FailureCode::Conflict, "You belong to the maximum number of workspaces."));
972 }
973 }
974 // Refusals done: write.
975 let now = rfc3339(now_ms());
976 let email_value = said.email.as_deref().map_or(JsValue::NULL, Into::into);
977 if linked.is_empty() {
978 self.db
979 .prepare(
980 "INSERT INTO sso_identities (connection_id, user_id, subject, email, created_at, last_used_at)
981 VALUES (?, ?, ?, ?, ?, ?)",
982 )
983 .bind(&[row.id.as_str().into(), user_id.into(), subject.into(), email_value, now.as_str().into(), now.as_str().into()])?
984 .run()
985 .await?;
986 self.audit_workspace(&person, "sso.identity_linked", &slug, Surface::Web, format!("Linked to {subject} at the single sign-on provider"))
987 .await;
988 } else {
989 self.db
990 .prepare("UPDATE sso_identities SET last_used_at = ?, email = COALESCE(?, email) WHERE connection_id = ? AND user_id = ?")
991 .bind(&[now.as_str().into(), email_value, row.id.as_str().into(), user_id.into()])?
992 .run()
993 .await?;
994 }
995 if let Some(id) = &provisioned {
996 self.db
997 .prepare("UPDATE scim_users SET user_id = ? WHERE id = ? AND workspace_id = ?")
998 .bind(&[user_id.into(), id.as_str().into(), row.workspace_id.as_str().into()])?
999 .run()
1000 .await?;
1001 }
1002 let mut joined = false;
1003 if !member {
1004 joined = self
1005 .db
1006 .prepare(
1007 "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role, created_at)
1008 VALUES (?, ?, 'member', ?) RETURNING user_id",
1009 )
1010 .bind(&[row.workspace_id.as_str().into(), user_id.into(), now.as_str().into()])?
1011 .first::<Value>(None)
1012 .await?
1013 .is_some();
1014 if joined {
1015 self.audit_workspace(&person, "member.joined_by_sso", &slug, Surface::Web, "Joined through single sign-on".to_owned()).await;
1016 self.announce_member_joined(&row.workspace_id, &slug, &person, Role::Member, JoinedHow::Joined).await;
1017 }
1018 }
1019 // The session counts as signed in through the provider, while it lasts.
1020 self.db
1021 .prepare(format!(
1022 "INSERT INTO sso_sessions (session_id, workspace_id, expires_at)
1023 SELECT ?1, ?2, {} WHERE EXISTS (SELECT 1 FROM sessions WHERE id = ?1 AND user_id = ?3 AND expires_at > {SQL_NOW})
1024 ON CONFLICT (session_id, workspace_id) DO UPDATE SET expires_at = excluded.expires_at",
1025 sql_after(SSO_SESSION_SECONDS)
1026 ))
1027 .bind(&[state.session_id.as_str().into(), row.workspace_id.as_str().into(), user_id.into()])?
1028 .run()
1029 .await?;
1030 let first = self
1031 .db
1032 .prepare("UPDATE sso_connections SET tested_at = ?, tested_by = ? WHERE id = ? AND tested_at IS NULL RETURNING id")
1033 .bind(&[now.as_str().into(), user_id.into(), row.id.as_str().into()])?
1034 .first::<Value>(None)
1035 .await?
1036 .is_some();
1037 Ok(Outcome::Ok(SsoFinished { slug, next: state.next.clone(), joined, first }))
1038 }
1039
1040 async fn sso_person(&self, user_id: &str) -> Result<Option<User>> {
1041 #[derive(Deserialize)]
1042 struct Person {
1043 id: String,
1044 username: String,
1045 display_username: Option<String>,
1046 verified: u8,
1047 }
1048 Ok(self
1049 .db
1050 .prepare("SELECT id, username, display_username, email_verified_at IS NOT NULL AS verified FROM users WHERE id = ? AND deleted_at IS NULL")
1051 .bind(&[user_id.into()])?
1052 .first::<Person>(None)
1053 .await?
1054 .map(|p| User { id: p.id, username: p.username, display_username: p.display_username, verified: p.verified != 0, ..User::default() }))
1055 }
1056
1057 /// The workspaces of `required` (slugs, with the person's role) whose
1058 /// single sign-on holds the person out, for how they came in (`via`):
1059 /// a session counts once it went through the provider in the last day,
1060 /// a token or key once they have linked, and a password never (it is
1061 /// what single sign-on stands in for). Where SCIM is on, they must also
1062 /// still be assigned.
1063 pub async fn sso_holds(&self, user: &User, required: Vec<(String, Role)>, via: Via<'_>) -> Result<Vec<String>> {
1064 // g1t's own agent is never held: it is no one's to sign in.
1065 if required.iter().all(|(_, role)| *role == Role::Owner) || crate::paid::is_g1t(&user.username) {
1066 return Ok(Vec::new());
1067 }
1068 #[derive(Deserialize)]
1069 struct Slug {
1070 slug: String,
1071 }
1072 // Where SCIM is on, only while the provider still has them assigned.
1073 const ASSIGNED: &str = "(NOT EXISTS (SELECT 1 FROM scim_tokens t WHERE t.workspace_id = w.id)
1074 OR EXISTS (SELECT 1 FROM scim_users u WHERE u.workspace_id = w.id AND u.user_id = ?2 AND u.active = 1))";
1075 let met = match via {
1076 Via::Password => return Ok(held_by_sso(&required, &HashSet::new())),
1077 Via::Session(session) => {
1078 self.db
1079 .prepare(format!(
1080 "SELECT w.slug FROM sso_sessions s JOIN workspaces w ON w.id = s.workspace_id
1081 WHERE s.session_id = ?1 AND s.expires_at > {SQL_NOW} AND {ASSIGNED}"
1082 ))
1083 .bind(&[session.into(), user.id.as_str().into()])?
1084 .all()
1085 .await?
1086 }
1087 Via::Credential => {
1088 self.db
1089 .prepare(format!(
1090 "SELECT w.slug FROM sso_identities i JOIN sso_connections c ON c.id = i.connection_id
1091 JOIN workspaces w ON w.id = c.workspace_id WHERE i.user_id = ?1 AND {ASSIGNED}"
1092 ))
1093 .bind(&[user.id.as_str().into(), user.id.as_str().into()])?
1094 .all()
1095 .await?
1096 }
1097 };
1098 let met: HashSet<String> = met.results::<Slug>()?.into_iter().map(|row| row.slug).collect();
1099 Ok(held_by_sso(&required, &met))
1100 }
1101
1102 /// `sso_metadata`: g1t's SAML metadata for a workspace's provider.
1103 pub fn sso_metadata(&self, a: SsoMetadataArgs) -> String {
1104 saml::metadata(&a.entity_id, &a.acs_url)
1105 }
1106
1107 /// Forgets which workspaces' providers a session went through, when it ends.
1108 pub async fn forget_sso_session(&self, session_hash: &str) -> Result<()> {
1109 self.db.prepare("DELETE FROM sso_sessions WHERE session_id = ?").bind(&[session_hash.into()])?.run().await?;
1110 Ok(())
1111 }
1112}
1113
1114fn protocol_name(protocol: SsoProtocol) -> &'static str {
1115 match protocol {
1116 SsoProtocol::Oidc => "OpenID Connect",
1117 SsoProtocol::Oauth2 => "OAuth 2.0",
1118 SsoProtocol::Saml => "SAML",
1119 }
1120}
1121
1122#[cfg(test)]
1123mod tests {
1124 use super::*;
1125 use g1t_contracts::access::{RepoGrant, RepoRole};
1126
1127 #[test]
1128 fn owners_are_never_held_and_a_met_workspace_is_let_in() {
1129 let required = vec![("acme".to_owned(), Role::Member), ("globex".to_owned(), Role::Owner), ("initech".to_owned(), Role::Member)];
1130 let met: HashSet<String> = ["initech".to_owned()].into();
1131 assert_eq!(held_by_sso(&required, &met), vec!["acme".to_owned()]);
1132 assert!(held_by_sso(&required, &["acme".to_owned(), "initech".to_owned()].into()).is_empty());
1133 }
1134
1135 #[test]
1136 fn a_held_workspace_takes_its_repositories_with_it() {
1137 let mut user = User {
1138 id: "usr_1".into(),
1139 username: "ada".into(),
1140 workspaces: vec![Membership::member("acme"), Membership::member("globex")],
1141 grants: vec![
1142 RepoGrant { repo_id: "rep_1".into(), workspace: "acme".into(), role: RepoRole::Admin, team: None },
1143 RepoGrant { repo_id: "rep_2".into(), workspace: "globex".into(), role: RepoRole::Write, team: None },
1144 ],
1145 ..User::default()
1146 };
1147 apply_sso_holds(&mut user, &["acme".to_owned()]);
1148 assert_eq!(user.workspaces.iter().map(|m| m.slug.as_str()).collect::<Vec<_>>(), vec!["globex"]);
1149 assert_eq!(user.grants.iter().map(|g| g.repo_id.as_str()).collect::<Vec<_>>(), vec!["rep_2"]);
1150 assert_eq!(user.held.len(), 1);
1151 assert_eq!(user.held[0].gap, "sso");
1152 assert!(user.held[0].reason.contains("acme requires single sign-on"));
1153 }
1154
1155 #[test]
1156 fn each_protocol_keeps_only_its_own_fields() {
1157 let given = SsoConfig {
1158 issuer: Some(" https://acme.okta.com ".into()),
1159 client_id: Some("client".into()),
1160 authorization_url: Some("https://elsewhere.test/authorize".into()),
1161 ..SsoConfig::default()
1162 };
1163 let oidc = checked_config(SsoProtocol::Oidc, &given, vec![]).unwrap();
1164 assert_eq!(oidc.issuer.as_deref(), Some("https://acme.okta.com"));
1165 assert_eq!(oidc.authorization_url, None);
1166 assert!(checked_config(SsoProtocol::Oauth2, &given, vec![]).unwrap_err().contains("token URL"));
1167 let plain = SsoConfig { issuer: Some("http://acme.test".into()), client_id: Some("c".into()), ..SsoConfig::default() };
1168 assert!(checked_config(SsoProtocol::Oidc, &plain, vec![]).unwrap_err().contains("https"));
1169 let saml = SsoConfig { idp_entity_id: Some("http://idp/entity".into()), idp_sso_url: Some("https://idp/sso".into()), ..SsoConfig::default() };
1170 assert!(checked_config(SsoProtocol::Saml, &saml, vec![]).unwrap_err().contains("certificate"));
1171 assert_eq!(checked_config(SsoProtocol::Saml, &saml, vec!["AAAA".into()]).unwrap().certificates, vec!["AAAA".to_owned()]);
1172 }
1173
1174 #[test]
1175 fn only_a_trusted_address_at_a_named_domain_joins() {
1176 let domains = vec!["acme.com".to_owned()];
1177 let trusted = Said::new("u1".into(), Some("Ada@Acme.com".into()), true);
1178 assert_eq!(join_domain_refusal(&domains, &trusted, "acme"), None);
1179 let untrusted = Said::new("u1".into(), Some("ada@acme.com".into()), false);
1180 assert!(join_domain_refusal(&domains, &untrusted, "acme").is_some());
1181 let elsewhere = Said::new("u1".into(), Some("ada@gmail.com".into()), true);
1182 assert!(join_domain_refusal(&domains, &elsewhere, "acme").unwrap().contains("acme.com"));
1183 assert_eq!(join_domain_refusal(&[], &elsewhere, "acme"), None);
1184 assert_eq!(domains_from(&["@Acme.com, globex.io".to_owned()]).unwrap(), vec!["acme.com".to_owned(), "globex.io".to_owned()]);
1185 assert!(domains_from(&["not a domain!".to_owned()]).is_err());
1186 }
1187
1188 #[test]
1189 fn an_address_shaped_subject_is_lowercased() {
1190 assert_eq!(Said::new(" Ada@Acme.COM ".into(), None, true).subject, "ada@acme.com");
1191 assert_eq!(Said::new("00uAbC".into(), None, true).subject, "00uAbC");
1192 }
1193
1194 #[test]
1195 fn entra_gives_its_object_id_and_a_members_principal_name() {
1196 let claims = serde_json::json!({ "sub": "pairwise", "oid": "11111111-2222", "preferred_username": "Ada@Contoso.com", "email": "ada@contoso.com" });
1197 let said = said_by_oidc(&claims, "https://login.microsoftonline.com/tid/v2.0").unwrap();
1198 assert_eq!(said.ids, vec!["pairwise".to_owned(), "11111111-2222".to_owned(), "ada@contoso.com".to_owned()]);
1199 // Entra sends no email_verified: its address is never trusted.
1200 assert_eq!(said.trusted_email(), None);
1201 let guest = serde_json::json!({ "sub": "p", "preferred_username": "eve_gmail.com#EXT#@contoso.onmicrosoft.com" });
1202 assert_eq!(said_by_oidc(&guest, "https://login.microsoftonline.com/tid/v2.0").unwrap().ids, vec!["p".to_owned()]);
1203 let okta = serde_json::json!({ "sub": "00u1", "oid": "x", "email": "ada@acme.com", "email_verified": true });
1204 let said = said_by_oidc(&okta, "https://acme.okta.com").unwrap();
1205 assert_eq!(said.ids, vec!["00u1".to_owned()]);
1206 assert_eq!(said.trusted_email(), Some("ada@acme.com"));
1207 }
1208
1209 #[test]
1210 fn another_provider_is_told_apart_from_a_changed_setting() {
1211 let a = SsoConfig { issuer: Some("https://acme.okta.com/".into()), client_id: Some("one".into()), ..SsoConfig::default() };
1212 let b = SsoConfig { issuer: Some("https://acme.okta.com".into()), client_id: Some("two".into()), ..SsoConfig::default() };
1213 assert_eq!(provider_of(SsoProtocol::Oidc, &a), provider_of(SsoProtocol::Oidc, &b));
1214 let c = SsoConfig { issuer: Some("https://globex.okta.com".into()), ..SsoConfig::default() };
1215 assert_ne!(provider_of(SsoProtocol::Oidc, &a), provider_of(SsoProtocol::Oidc, &c));
1216 assert_ne!(provider_of(SsoProtocol::Oidc, &a), provider_of(SsoProtocol::Saml, &a));
1217 }
1218}