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