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,022 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
35use std::collections::HashSet;
36
37use base64::Engine;
38use base64::engine::general_purpose::STANDARD;
39use g1t_contracts::audit::Surface;
40use g1t_contracts::events::JoinedHow;
41use g1t_contracts::members::PolicyHold;
42use g1t_contracts::sso::*;
43use g1t_contracts::time::{SQL_NOW, rfc3339, sql_after};
44use g1t_contracts::identity::SlugArgs;
45use g1t_contracts::{FailureCode, Membership, Outcome, Role, User, new_id};
46use g1t_kit::now_ms;
47use g1t_secrets::Sealer;
48use serde::Deserialize;
49use serde_json::Value;
50use worker::Result;
51use worker::wasm_bindgen::JsValue;
52
53use crate::security::is_person;
54use crate::{Identity, crypto};
55
56const STATE_TTL_SECONDS: u64 = 10 * 60;
57/// How long a session signed in through a workspace's provider counts as
58/// that, before it goes through the provider again (usually at once: the
59/// provider remembers them).
60const SSO_SESSION_SECONDS: u64 = 24 * 60 * 60;
61const OWNERS_ONLY: &str = "Only an owner can change the workspace's single sign-on.";
62const EXPIRED: &str = "This single sign-on took too long or was already used. Start again.";
63const MAX_FIELD: usize = 2048;
64
65/// What to say to a free workspace.
66fn plan_needed(slug: &str) -> String {
67 format!("Single sign-on comes with every paid plan. An owner of {slug} can start the plan at /{slug}/-/billing#plan.")
68}
69
70/// What to tell a member a workspace holds out until they sign in through
71/// its provider.
72pub fn hold_reason(slug: &str) -> String {
73 format!("{slug} requires single sign-on. Sign in through its identity provider to use it.")
74}
75
76#[derive(Deserialize)]
77struct ConnectionRow {
78 id: String,
79 workspace_id: String,
80 protocol: String,
81 config: String,
82 client_secret: Option<String>,
83 allow_join: u8,
84 tested_at: Option<String>,
85 #[serde(default)]
86 tested_by_username: Option<String>,
87 created_at: String,
88 updated_at: String,
89}
90
91impl ConnectionRow {
92 fn protocol(&self) -> SsoProtocol {
93 SsoProtocol::parse(&self.protocol).unwrap_or(SsoProtocol::Oidc)
94 }
95
96 fn config(&self) -> SsoConfig {
97 serde_json::from_str(&self.config).unwrap_or_default()
98 }
99
100 fn view(&self) -> SsoConnection {
101 let config = self.config();
102 SsoConnection {
103 id: self.id.clone(),
104 protocol: self.protocol(),
105 has_client_secret: self.client_secret.is_some(),
106 certificates: config
107 .certificates
108 .iter()
109 .filter_map(|der| STANDARD.decode(der).ok())
110 .map(|der| SsoCertificate { fingerprint: saml::fingerprint(&der) })
111 .collect(),
112 config,
113 allow_join: self.allow_join != 0,
114 tested_at: self.tested_at.clone(),
115 tested_by: self.tested_by_username.clone(),
116 created_at: self.created_at.clone(),
117 updated_at: self.updated_at.clone(),
118 }
119 }
120}
121
122#[derive(Deserialize)]
123struct StateRow {
124 connection_id: String,
125 session_id: String,
126 user_id: String,
127 verifier: Option<String>,
128 nonce: Option<String>,
129 redirect_uri: String,
130 audience: Option<String>,
131 next: String,
132}
133
134/// Which provider a connection is: changing it unlinks everyone.
135fn provider_of(protocol: SsoProtocol, config: &SsoConfig) -> String {
136 let at = match protocol {
137 SsoProtocol::Oidc => config.issuer.as_deref(),
138 SsoProtocol::Oauth2 => config.authorization_url.as_deref(),
139 SsoProtocol::Saml => config.idp_entity_id.as_deref(),
140 };
141 format!("{}:{}", protocol.as_str(), at.unwrap_or_default().trim().trim_end_matches('/'))
142}
143
144/// A field as given: trimmed, empty as absent, and not too long.
145fn field(value: &Option<String>, name: &str) -> std::result::Result<Option<String>, String> {
146 let value = value.as_deref().map(str::trim).filter(|v| !v.is_empty());
147 match value {
148 Some(v) if v.len() > MAX_FIELD => Err(format!("The {name} is too long.")),
149 v => Ok(v.map(str::to_owned)),
150 }
151}
152
153fn https_field(value: &Option<String>, name: &str) -> std::result::Result<String, String> {
154 let value = field(value, name)?.ok_or_else(|| format!("Enter the {name}."))?;
155 oauth::https(&value).ok_or_else(|| format!("The {name} must be an https address."))?;
156 Ok(value)
157}
158
159/// The stored config for what an owner entered, checked for the protocol;
160/// `certificates` are SAML's, as they will be kept. Only the protocol's
161/// own fields are kept.
162fn checked_config(protocol: SsoProtocol, given: &SsoConfig, certificates: Vec<String>) -> std::result::Result<SsoConfig, String> {
163 let client_id = || field(&given.client_id, "client id")?.ok_or_else(|| "Enter the client id.".to_owned());
164 Ok(match protocol {
165 SsoProtocol::Oidc => SsoConfig {
166 issuer: Some(https_field(&given.issuer, "issuer")?),
167 client_id: Some(client_id()?),
168 scopes: field(&given.scopes, "scopes")?,
169 ..SsoConfig::default()
170 },
171 SsoProtocol::Oauth2 => SsoConfig {
172 authorization_url: Some(https_field(&given.authorization_url, "authorization URL")?),
173 token_url: Some(https_field(&given.token_url, "token URL")?),
174 userinfo_url: Some(https_field(&given.userinfo_url, "userinfo URL")?),
175 client_id: Some(client_id()?),
176 scopes: field(&given.scopes, "scopes")?,
177 subject_claim: field(&given.subject_claim, "subject claim")?,
178 email_claim: field(&given.email_claim, "email claim")?,
179 ..SsoConfig::default()
180 },
181 SsoProtocol::Saml => {
182 if certificates.is_empty() {
183 return Err("Paste the identity provider's signing certificate.".to_owned());
184 }
185 SsoConfig {
186 idp_entity_id: Some(field(&given.idp_entity_id, "entity id")?.ok_or("Enter the identity provider's entity id (its issuer).")?),
187 idp_sso_url: Some(https_field(&given.idp_sso_url, "single sign-on URL")?),
188 certificates,
189 ..SsoConfig::default()
190 }
191 }
192 })
193}
194
195/// Which of a person's memberships a workspace's single sign-on holds out:
196/// those that require it and they have not met, never as an owner.
197pub fn held_by_sso(required: &[(String, Role)], met: &HashSet<String>) -> Vec<String> {
198 required
199 .iter()
200 .filter(|(slug, role)| *role != Role::Owner && !met.contains(slug))
201 .map(|(slug, _)| slug.clone())
202 .collect()
203}
204
205/// Leaves out the memberships and grants of the workspaces in `held`, and
206/// says why, beside any other holds.
207pub fn apply_sso_holds(user: &mut User, held: &[String]) {
208 if held.is_empty() {
209 return;
210 }
211 user.workspaces.retain(|membership: &Membership| !held.contains(&membership.slug));
212 user.grants.retain(|grant| !held.contains(&grant.workspace));
213 for slug in held {
214 if !user.held.iter().any(|hold| &hold.slug == slug) {
215 user.held.push(PolicyHold { slug: slug.clone(), reason: hold_reason(slug), gap: "sso".to_owned() });
216 }
217 }
218}
219
220impl Identity {
221 fn sso_sealer(&self) -> Option<Sealer> {
222 Sealer::new(&self.env.secret("IDENTITY_KEY").ok()?.to_string())
223 }
224
225 async fn connection_where(&self, column: &str, value: &str) -> Result<Option<ConnectionRow>> {
226 self.db
227 .prepare(format!(
228 "SELECT c.*, u.username AS tested_by_username FROM sso_connections c
229 LEFT JOIN users u ON u.id = c.tested_by WHERE c.{column} = ?"
230 ))
231 .bind(&[value.into()])?
232 .first::<ConnectionRow>(None)
233 .await
234 }
235
236 /// The workspace's id when `actor` is a confirmed person who owns it.
237 async fn sso_owner(&self, actor: &User, slug: &str) -> Result<Outcome<String>> {
238 if !is_person(actor) || actor.role_in(slug) != Some(Role::Owner) {
239 return Ok(Outcome::fail(FailureCode::Forbidden, OWNERS_ONLY));
240 }
241 if !actor.verified {
242 return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address first."));
243 }
244 Ok(match self.workspace_id_of(slug).await? {
245 Some(id) => Outcome::Ok(id),
246 None => Outcome::fail(FailureCode::NotFound, "Workspace not found."),
247 })
248 }
249
250 async fn require_sso_of(&self, workspace_id: &str) -> Result<bool> {
251 #[derive(Deserialize)]
252 struct Row {
253 require_sso: u8,
254 }
255 Ok(self
256 .db
257 .prepare("SELECT require_sso FROM workspaces WHERE id = ?")
258 .bind(&[workspace_id.into()])?
259 .first::<Row>(None)
260 .await?
261 .is_some_and(|row| row.require_sso != 0))
262 }
263
264 async fn sso_linked(&self, connection_id: &str, user_id: &str) -> Result<bool> {
265 Ok(self
266 .db
267 .prepare("SELECT 1 AS yes FROM sso_identities WHERE connection_id = ? AND user_id = ?")
268 .bind(&[connection_id.into(), user_id.into()])?
269 .first::<Value>(None)
270 .await?
271 .is_some())
272 }
273
274 /// `sso_settings`: the workspace's single sign-on, for its owners.
275 pub async fn sso_settings(&self, a: SsoSettingsArgs) -> Result<Outcome<SsoSettings>> {
276 let slug = a.slug.trim().to_lowercase();
277 let workspace_id = match self.sso_owner(&a.actor, &slug).await? {
278 Outcome::Ok(id) => id,
279 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
280 };
281 self.settings_of(&workspace_id, &slug, &a.actor).await.map(Outcome::Ok)
282 }
283
284 async fn settings_of(&self, workspace_id: &str, slug: &str, actor: &User) -> Result<SsoSettings> {
285 let row = self.connection_where("workspace_id", workspace_id).await?;
286 let (identities, you_linked) = match &row {
287 Some(row) => {
288 #[derive(Deserialize)]
289 struct Linked {
290 user_id: String,
291 username: String,
292 display_username: Option<String>,
293 subject: String,
294 email: Option<String>,
295 created_at: String,
296 last_used_at: String,
297 }
298 let linked = self
299 .db
300 .prepare(
301 "SELECT i.user_id, u.username, u.display_username, i.subject, i.email, i.created_at, i.last_used_at
302 FROM sso_identities i JOIN users u ON u.id = i.user_id
303 WHERE i.connection_id = ? AND u.deleted_at IS NULL ORDER BY u.username LIMIT 500",
304 )
305 .bind(&[row.id.as_str().into()])?
306 .all()
307 .await?
308 .results::<Linked>()?;
309 let you = linked.iter().any(|l| l.user_id == actor.id);
310 let identities = linked
311 .into_iter()
312 .map(|l| SsoIdentity {
313 username: l.username,
314 display_username: l.display_username,
315 subject: l.subject,
316 email: l.email,
317 linked_at: l.created_at,
318 last_used_at: l.last_used_at,
319 })
320 .collect();
321 (identities, you)
322 }
323 None => (Vec::new(), false),
324 };
325 Ok(SsoSettings {
326 slug: slug.to_owned(),
327 connection: row.as_ref().map(ConnectionRow::view),
328 required: self.require_sso_of(workspace_id).await?,
329 you_linked,
330 identities,
331 })
332 }
333
334 /// `admin_sso`: a workspace's single sign-on, for staff in sudo.
335 pub async fn admin_sso(&self, a: SlugArgs) -> Result<AdminSso> {
336 let slug = a.slug.trim().to_lowercase();
337 let Some(workspace_id) = self.workspace_id_of(&slug).await? else {
338 return Ok(None);
339 };
340 if self.connection_where("workspace_id", &workspace_id).await?.is_none() {
341 return Ok(None);
342 }
343 self.settings_of(&workspace_id, &slug, &User::default()).await.map(Some)
344 }
345
346 /// `sso_summary`: whether a workspace has single sign-on, for anyone.
347 pub async fn sso_summary(&self, a: SlugArgs) -> Result<SsoSummary> {
348 let Some(workspace_id) = self.workspace_id_of(&a.slug.to_lowercase()).await? else {
349 return Ok(SsoSummary::default());
350 };
351 let Some(row) = self.connection_where("workspace_id", &workspace_id).await? else {
352 return Ok(SsoSummary::default());
353 };
354 Ok(SsoSummary {
355 enabled: true,
356 protocol: Some(row.protocol()),
357 required: self.require_sso_of(&workspace_id).await?,
358 allow_join: row.allow_join != 0,
359 })
360 }
361
362 /// `save_sso`: see [`SaveSsoArgs`].
363 pub async fn save_sso(&self, a: SaveSsoArgs) -> Result<Outcome<SsoSettings>> {
364 let slug = a.slug.trim().to_lowercase();
365 let workspace_id = match self.sso_owner(&a.actor, &slug).await? {
366 Outcome::Ok(id) => id,
367 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
368 };
369 if let Some(refused) = self.plan_refusal(&slug, plan_needed(&slug)).await? {
370 return Ok(refused);
371 }
372 let Some(sealer) = self.sso_sealer() else {
373 return Ok(Outcome::fail(FailureCode::Conflict, "Single sign-on is not set up on this g1t: it needs IDENTITY_KEY."));
374 };
375 let before = self.connection_where("workspace_id", &workspace_id).await?;
376 let same_protocol = before.as_ref().filter(|row| row.protocol() == a.protocol);
377 // SAML's certificates: as pasted, or the ones kept.
378 let certificates = match a.certificate.as_deref().map(str::trim).filter(|c| !c.is_empty()) {
379 Some(pasted) => match saml::certificates_from(pasted) {
380 Ok(certificates) => certificates.iter().map(|der| STANDARD.encode(der)).collect(),
381 Err(why) => return Ok(Outcome::fail(FailureCode::Invalid, why)),
382 },
383 None => same_protocol.map(|row| row.config().certificates).unwrap_or_default(),
384 };
385 let config = match checked_config(a.protocol, &a.config, if a.protocol == SsoProtocol::Saml { certificates } else { vec![] }) {
386 Ok(config) => config,
387 Err(why) => return Ok(Outcome::fail(FailureCode::Invalid, why)),
388 };
389 if a.protocol == SsoProtocol::Oidc
390 && let Err(why) = oauth::discover(config.issuer.as_deref().unwrap_or_default()).await
391 {
392 return Ok(Outcome::fail(FailureCode::Invalid, why));
393 }
394 let id = before.as_ref().map(|row| row.id.clone()).unwrap_or_else(|| new_id("sso", now_ms()));
395 // The client secret: as given, or the one kept.
396 let secret = match a.client_secret.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
397 _ if a.protocol == SsoProtocol::Saml => None,
398 Some(secret) if secret.len() > MAX_FIELD => return Ok(Outcome::fail(FailureCode::Invalid, "The client secret is too long.")),
399 Some(secret) => Some(sealer.seal(secret, &format!("sso:{id}"))),
400 None => match same_protocol.and_then(|row| row.client_secret.clone()) {
401 Some(kept) => Some(kept),
402 None => return Ok(Outcome::fail(FailureCode::Invalid, "Enter the client secret.")),
403 },
404 };
405 let changed_provider = before.as_ref().is_some_and(|row| provider_of(row.protocol(), &row.config()) != provider_of(a.protocol, &config));
406 let now = rfc3339(now_ms());
407 let stored = serde_json::to_string(&config).unwrap_or_default();
408 let secret_value = secret.as_deref().map_or(JsValue::NULL, Into::into);
409 self.db
410 .prepare(
411 "INSERT INTO sso_connections (id, workspace_id, protocol, config, client_secret, allow_join, created_at, updated_at)
412 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)
413 ON CONFLICT (workspace_id) DO UPDATE SET protocol = excluded.protocol, config = excluded.config,
414 client_secret = excluded.client_secret, allow_join = excluded.allow_join, updated_at = excluded.updated_at",
415 )
416 .bind(&[
417 id.as_str().into(),
418 workspace_id.as_str().into(),
419 a.protocol.as_str().into(),
420 stored.into(),
421 secret_value,
422 (a.allow_join as u8).into(),
423 now.as_str().into(),
424 ])?
425 .run()
426 .await?;
427 if changed_provider {
428 // Another provider: who people were at the old one means nothing
429 // now, and it must be tried before it is required again.
430 self.db
431 .batch(vec![
432 self.db.prepare("DELETE FROM sso_identities WHERE connection_id = ?").bind(&[id.as_str().into()])?,
433 self.db.prepare("DELETE FROM sso_sessions WHERE workspace_id = ?").bind(&[workspace_id.as_str().into()])?,
434 self.db
435 .prepare("UPDATE sso_connections SET tested_at = NULL, tested_by = NULL WHERE id = ?")
436 .bind(&[id.as_str().into()])?,
437 self.db.prepare("UPDATE workspaces SET require_sso = 0 WHERE id = ?").bind(&[workspace_id.as_str().into()])?,
438 ])
439 .await?;
440 }
441 let surface = a.surface.unwrap_or(Surface::Web);
442 let message = match (&before, changed_provider) {
443 (None, _) => format!("Set up single sign-on over {}", protocol_name(a.protocol)),
444 (Some(_), true) => format!("Moved single sign-on to another identity provider ({}), unlinking everyone", protocol_name(a.protocol)),
445 (Some(_), false) => "Changed single sign-on's settings".to_owned(),
446 };
447 self.audit_workspace(&a.actor, if before.is_none() { "sso.created" } else { "sso.updated" }, &slug, surface, message).await;
448 self.announce_workspace(&workspace_id, &slug, Some(&a.actor.id)).await;
449 self.settings_of(&workspace_id, &slug, &a.actor).await.map(Outcome::Ok)
450 }
451
452 /// `set_sso_required`: see [`SetSsoRequiredArgs`].
453 pub async fn set_sso_required(&self, a: SetSsoRequiredArgs) -> Result<Outcome<bool>> {
454 let slug = a.slug.trim().to_lowercase();
455 let workspace_id = match self.sso_owner(&a.actor, &slug).await? {
456 Outcome::Ok(id) => id,
457 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
458 };
459 let before = self.require_sso_of(&workspace_id).await?;
460 if before == a.required {
461 return Ok(Outcome::Ok(before));
462 }
463 if a.required {
464 let Some(row) = self.connection_where("workspace_id", &workspace_id).await? else {
465 return Ok(Outcome::fail(FailureCode::Conflict, "Set up single sign-on before requiring it."));
466 };
467 if let Some(refused) = self.plan_refusal(&slug, plan_needed(&slug)).await? {
468 return Ok(refused);
469 }
470 if !self.sso_linked(&row.id, &a.actor.id).await? {
471 return Ok(Outcome::fail(
472 FailureCode::Conflict,
473 "Sign in through the identity provider yourself before requiring it of everyone: use Test sign-in.",
474 ));
475 }
476 }
477 self.db
478 .prepare("UPDATE workspaces SET require_sso = ? WHERE id = ?")
479 .bind(&[(a.required as u8).into(), workspace_id.as_str().into()])?
480 .run()
481 .await?;
482 self.audit_workspace(
483 &a.actor,
484 if a.required { "sso.required" } else { "sso.not_required" },
485 &slug,
486 a.surface.unwrap_or(Surface::Web),
487 if a.required { "Required single sign-on of members".to_owned() } else { "Stopped requiring single sign-on".to_owned() },
488 )
489 .await;
490 self.announce_workspace(&workspace_id, &slug, Some(&a.actor.id)).await;
491 Ok(Outcome::Ok(a.required))
492 }
493
494 /// `delete_sso`: removes the connection, everyone's links to it, and the
495 /// requirement. Never needs a paid plan.
496 pub async fn delete_sso(&self, a: SsoSettingsArgs) -> Result<Outcome<bool>> {
497 let slug = a.slug.trim().to_lowercase();
498 let workspace_id = match self.sso_owner(&a.actor, &slug).await? {
499 Outcome::Ok(id) => id,
500 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
501 };
502 let Some(row) = self.connection_where("workspace_id", &workspace_id).await? else {
503 return Ok(Outcome::Ok(false));
504 };
505 self.db
506 .batch(vec![
507 self.db.prepare("DELETE FROM sso_states WHERE connection_id = ?").bind(&[row.id.as_str().into()])?,
508 self.db.prepare("DELETE FROM sso_identities WHERE connection_id = ?").bind(&[row.id.as_str().into()])?,
509 self.db.prepare("DELETE FROM sso_connections WHERE id = ?").bind(&[row.id.as_str().into()])?,
510 self.db.prepare("DELETE FROM sso_sessions WHERE workspace_id = ?").bind(&[workspace_id.as_str().into()])?,
511 self.db.prepare("UPDATE workspaces SET require_sso = 0 WHERE id = ?").bind(&[workspace_id.as_str().into()])?,
512 ])
513 .await?;
514 self.audit_workspace(&a.actor, "sso.deleted", &slug, a.surface.unwrap_or(Surface::Web), "Removed single sign-on".to_owned())
515 .await;
516 self.announce_workspace(&workspace_id, &slug, Some(&a.actor.id)).await;
517 Ok(Outcome::Ok(true))
518 }
519
520 /// `unlink_sso_identity`: see [`UnlinkSsoIdentityArgs`].
521 pub async fn unlink_sso_identity(&self, a: UnlinkSsoIdentityArgs) -> Result<Outcome<bool>> {
522 let slug = a.slug.trim().to_lowercase();
523 let workspace_id = match self.sso_owner(&a.actor, &slug).await? {
524 Outcome::Ok(id) => id,
525 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
526 };
527 let Some(row) = self.connection_where("workspace_id", &workspace_id).await? else {
528 return Ok(Outcome::Ok(false));
529 };
530 let username = a.username.trim().trim_start_matches('@').to_lowercase();
531 let removed = self
532 .db
533 .prepare(
534 "DELETE FROM sso_identities WHERE connection_id = ?
535 AND user_id = (SELECT id FROM users WHERE username = ?) RETURNING user_id",
536 )
537 .bind(&[row.id.as_str().into(), username.as_str().into()])?
538 .first::<Value>(None)
539 .await?;
540 let Some(removed) = removed else {
541 return Ok(Outcome::Ok(false));
542 };
543 if let Some(user_id) = removed["user_id"].as_str() {
544 // Their sessions go through the provider again.
545 self.db
546 .prepare("DELETE FROM sso_sessions WHERE workspace_id = ? AND session_id IN (SELECT id FROM sessions WHERE user_id = ?)")
547 .bind(&[workspace_id.as_str().into(), user_id.into()])?
548 .run()
549 .await?;
550 }
551 self.audit_workspace(
552 &a.actor,
553 "sso.identity_unlinked",
554 &slug,
555 a.surface.unwrap_or(Surface::Web),
556 format!("Unlinked {username}'s identity at the single sign-on provider"),
557 )
558 .await;
559 Ok(Outcome::Ok(true))
560 }
561
562 /// `sso_start`: see [`SsoStartArgs`].
563 pub async fn sso_start(&self, a: SsoStartArgs) -> Result<Outcome<SsoStart>> {
564 if !is_person(&a.user) {
565 return Ok(Outcome::fail(FailureCode::Forbidden, "Sign in to g1t as yourself first."));
566 }
567 if !a.user.verified {
568 return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address first."));
569 }
570 let slug = a.slug.trim().to_lowercase();
571 let Some(workspace_id) = self.workspace_id_of(&slug).await? else {
572 return Ok(Outcome::fail(FailureCode::NotFound, "There is no workspace with that name."));
573 };
574 let Some(row) = self.connection_where("workspace_id", &workspace_id).await? else {
575 return Ok(Outcome::fail(FailureCode::NotFound, format!("{slug} has not set up single sign-on.")));
576 };
577 if row.allow_join == 0 && !self.is_member_of(&workspace_id, &a.user.id).await? {
578 return Ok(Outcome::fail(
579 FailureCode::Forbidden,
580 format!("Only members of {slug} sign in through its identity provider. Ask an owner to invite you."),
581 ));
582 }
583 if let Some(refused) = self.plan_refusal(&slug, plan_needed(&slug)).await? {
584 return Ok(refused);
585 }
586 let session_id = crypto::sha256_hex(&a.session_token);
587 let live = self
588 .db
589 .prepare(format!("SELECT 1 AS yes FROM sessions WHERE id = ? AND user_id = ? AND expires_at > {SQL_NOW}"))
590 .bind(&[session_id.as_str().into(), a.user.id.as_str().into()])?
591 .first::<Value>(None)
592 .await?;
593 if live.is_none() {
594 return Ok(Outcome::fail(FailureCode::Unauthenticated, "Sign in to g1t again."));
595 }
596 let callback = worker::Url::parse(&a.redirect_uri).ok();
597 if !callback.is_some_and(|url| url.scheme() == "https" || url.host_str() == Some("localhost")) {
598 return Ok(Outcome::fail(FailureCode::Invalid, "The callback must be an https address."));
599 }
600 let config = row.config();
601 let (key, url, state, verifier, nonce) = match row.protocol() {
602 SsoProtocol::Saml => {
603 let Some(entity_id) = a.entity_id.as_deref().filter(|e| !e.is_empty()) else {
604 return Ok(Outcome::fail(FailureCode::Invalid, "SAML needs g1t's entity id."));
605 };
606 let request_id = saml::request_id(&crypto::random_hex(20));
607 let sso_url = config.idp_sso_url.as_deref().unwrap_or_default();
608 let Some(url) = saml::authn_request_url(sso_url, &request_id, entity_id, &a.redirect_uri, &slug, now_ms()) else {
609 return Ok(Outcome::fail(FailureCode::Invalid, "The identity provider's single sign-on URL must be an https address."));
610 };
611 (request_id, url, None, None, None)
612 }
613 protocol => {
614 let endpoints = match self.endpoints_of(protocol, &config).await {
615 Ok(endpoints) => endpoints,
616 Err(why) => return Ok(Outcome::fail(FailureCode::Conflict, why)),
617 };
618 let state = crypto::random_hex(32);
619 let verifier = crate::github::new_verifier();
620 let nonce = (protocol == SsoProtocol::Oidc).then(|| crypto::random_hex(16));
621 let scopes = oauth::scopes(config.scopes.as_deref(), protocol == SsoProtocol::Oidc);
622 let authorize = oauth::Authorize {
623 client_id: config.client_id.as_deref().unwrap_or_default(),
624 redirect_uri: &a.redirect_uri,
625 scopes: &scopes,
626 state: &state,
627 nonce: nonce.as_deref(),
628 challenge: &crate::github::pkce_challenge(&verifier),
629 };
630 let Some(url) = oauth::authorize_url(&endpoints.authorize, &authorize) else {
631 return Ok(Outcome::fail(FailureCode::Conflict, "The provider's authorization endpoint must be an https address."));
632 };
633 (state.clone(), url, Some(state), Some(verifier), nonce)
634 }
635 };
636 let optional = |value: Option<&str>| value.map_or(JsValue::NULL, Into::into);
637 self.db
638 .prepare(format!(
639 "INSERT INTO sso_states (id, connection_id, session_id, user_id, verifier, nonce, redirect_uri, audience, next, expires_at)
640 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, {})",
641 sql_after(STATE_TTL_SECONDS)
642 ))
643 .bind(&[
644 crypto::sha256_hex(&key).into(),
645 row.id.as_str().into(),
646 session_id.as_str().into(),
647 a.user.id.as_str().into(),
648 optional(verifier.as_deref()),
649 optional(nonce.as_deref()),
650 a.redirect_uri.as_str().into(),
651 optional(a.entity_id.as_deref()),
652 a.next.as_str().into(),
653 ])?
654 .run()
655 .await?;
656 // Old trips never finished, and sessions past their day, go now and then.
657 self.db
658 .batch(vec![
659 self.db.prepare(format!("DELETE FROM sso_states WHERE expires_at < {SQL_NOW}")),
660 self.db.prepare(format!("DELETE FROM sso_sessions WHERE expires_at < {SQL_NOW}")),
661 ])
662 .await?;
663 Ok(Outcome::Ok(SsoStart { url, state }))
664 }
665
666 async fn endpoints_of(&self, protocol: SsoProtocol, config: &SsoConfig) -> std::result::Result<oauth::Endpoints, String> {
667 match protocol {
668 SsoProtocol::Oidc => oauth::discover(config.issuer.as_deref().unwrap_or_default()).await,
669 _ => Ok(oauth::Endpoints {
670 authorize: config.authorization_url.clone().unwrap_or_default(),
671 token: config.token_url.clone().unwrap_or_default(),
672 userinfo: config.userinfo_url.clone(),
673 issuer: None,
674 basic: false,
675 }),
676 }
677 }
678
679 /// Spends a state row, once, and finds its connection, which must be
680 /// `slug`'s.
681 async fn spend_state(&self, key: &str, slug: &str) -> Result<Option<(StateRow, ConnectionRow)>> {
682 let state = self
683 .db
684 .prepare(format!("DELETE FROM sso_states WHERE id = ? AND expires_at > {SQL_NOW} RETURNING *"))
685 .bind(&[crypto::sha256_hex(key).into()])?
686 .first::<StateRow>(None)
687 .await?;
688 let Some(state) = state else {
689 return Ok(None);
690 };
691 let Some(row) = self.connection_where("id", &state.connection_id).await? else {
692 return Ok(None);
693 };
694 let workspace = self.workspace_id_of(&slug.trim().to_lowercase()).await?;
695 Ok((workspace.as_deref() == Some(row.workspace_id.as_str())).then_some((state, row)))
696 }
697
698 /// `sso_finish`: see [`SsoFinishArgs`].
699 pub async fn sso_finish(&self, a: SsoFinishArgs) -> Result<Outcome<SsoFinished>> {
700 let Some((state, row)) = self.spend_state(&a.state, &a.slug).await? else {
701 return Ok(Outcome::fail(FailureCode::Invalid, EXPIRED));
702 };
703 if crypto::sha256_hex(&a.session_token) != state.session_id {
704 return Ok(Outcome::fail(FailureCode::Invalid, "This single sign-on started in another session. Start again."));
705 }
706 let protocol = row.protocol();
707 let config = row.config();
708 let secret = row
709 .client_secret
710 .as_deref()
711 .and_then(|sealed| self.sso_sealer()?.open(sealed, &format!("sso:{}", row.id)));
712 let Some(secret) = secret else {
713 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."));
714 };
715 let said = async {
716 let endpoints = self.endpoints_of(protocol, &config).await?;
717 let client_id = config.client_id.as_deref().unwrap_or_default();
718 let tokens = oauth::exchange(&oauth::Exchange {
719 token_url: &endpoints.token,
720 basic: endpoints.basic,
721 client_id,
722 client_secret: &secret,
723 code: &a.code,
724 redirect_uri: &state.redirect_uri,
725 verifier: state.verifier.as_deref().unwrap_or_default(),
726 })
727 .await?;
728 if protocol == SsoProtocol::Oidc {
729 let id_token = tokens["id_token"].as_str().ok_or("The identity provider sent no ID token.")?;
730 let claims = oauth::id_token_claims(
731 id_token,
732 endpoints.issuer.as_deref().unwrap_or_default(),
733 client_id,
734 state.nonce.as_deref().unwrap_or_default(),
735 now_ms() / 1000,
736 )?;
737 let subject = oauth::claim(&claims, "sub").ok_or("The ID token names no one.")?;
738 let email = oauth::claim(&claims, "email").filter(|_| claims["email_verified"].as_bool() != Some(false));
739 Ok::<_, String>((subject, email))
740 } else {
741 let access = tokens["access_token"].as_str().ok_or("The identity provider sent no access token.")?;
742 let info = oauth::userinfo(endpoints.userinfo.as_deref().unwrap_or_default(), access).await?;
743 let subject = match config.subject_claim.as_deref() {
744 Some(path) => oauth::claim(&info, path),
745 None => oauth::claim(&info, "sub").or_else(|| oauth::claim(&info, "id")),
746 }
747 .ok_or("The userinfo answer has no id for the person: set the subject claim.")?;
748 let email = oauth::claim(&info, config.email_claim.as_deref().unwrap_or("email"));
749 Ok((subject, email))
750 }
751 };
752 match said.await {
753 Ok((subject, email)) => self.complete(&row, &state, &a.slug, &subject, email.as_deref()).await,
754 Err(why) => Ok(Outcome::fail(FailureCode::Invalid, why)),
755 }
756 }
757
758 /// `sso_saml`: see [`SsoSamlArgs`].
759 pub async fn sso_saml(&self, a: SsoSamlArgs) -> Result<Outcome<SsoFinished>> {
760 let Some(request_id) = saml::in_response_to(&a.saml_response) else {
761 return Ok(Outcome::fail(
762 FailureCode::Invalid,
763 "Start single sign-on from g1t: sign-ins started at the identity provider are not accepted.",
764 ));
765 };
766 let Some((state, row)) = self.spend_state(&request_id, &a.slug).await? else {
767 return Ok(Outcome::fail(FailureCode::Invalid, EXPIRED));
768 };
769 let config = row.config();
770 let certificates: Vec<Vec<u8>> = config.certificates.iter().filter_map(|der| STANDARD.decode(der).ok()).collect();
771 let expect = saml::Expect {
772 idp_entity_id: config.idp_entity_id.as_deref().unwrap_or_default(),
773 certificates: &certificates,
774 acs_url: &state.redirect_uri,
775 audience: state.audience.as_deref().unwrap_or_default(),
776 request_id: &request_id,
777 now_ms: now_ms(),
778 };
779 match saml::verify_response(&a.saml_response, &expect) {
780 Ok(asserted) => self.complete(&row, &state, &a.slug, &asserted.subject, asserted.email.as_deref()).await,
781 Err(why) => Ok(Outcome::fail(FailureCode::Invalid, why)),
782 }
783 }
784
785 /// The provider has said who signed in: link them, let them in, and
786 /// mark their session.
787 async fn complete(&self, row: &ConnectionRow, state: &StateRow, slug: &str, subject: &str, email: Option<&str>) -> Result<Outcome<SsoFinished>> {
788 let slug = slug.trim().to_lowercase();
789 let user_id = state.user_id.as_str();
790 let Some(person) = self.sso_person(user_id).await? else {
791 return Ok(Outcome::fail(FailureCode::Unauthenticated, "Sign in to g1t again."));
792 };
793 #[derive(Deserialize)]
794 struct Linked {
795 user_id: String,
796 subject: String,
797 }
798 let linked = self
799 .db
800 .prepare("SELECT user_id, subject FROM sso_identities WHERE connection_id = ?1 AND (subject = ?2 OR user_id = ?3)")
801 .bind(&[row.id.as_str().into(), subject.into(), user_id.into()])?
802 .all()
803 .await?
804 .results::<Linked>()?;
805 let now = rfc3339(now_ms());
806 let email_value = email.map_or(JsValue::NULL, Into::into);
807 if linked.iter().any(|l| l.subject == subject && l.user_id != user_id) {
808 return Ok(Outcome::fail(
809 FailureCode::Conflict,
810 format!("That account at {slug}'s identity provider is linked to another g1t account. An owner of {slug} can unlink it."),
811 ));
812 }
813 if linked.iter().any(|l| l.user_id == user_id && l.subject != subject) {
814 return Ok(Outcome::fail(
815 FailureCode::Conflict,
816 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."),
817 ));
818 }
819 if linked.is_empty() {
820 self.db
821 .prepare(
822 "INSERT INTO sso_identities (connection_id, user_id, subject, email, created_at, last_used_at)
823 VALUES (?, ?, ?, ?, ?, ?)",
824 )
825 .bind(&[row.id.as_str().into(), user_id.into(), subject.into(), email_value, now.as_str().into(), now.as_str().into()])?
826 .run()
827 .await?;
828 self.audit_workspace(&person, "sso.identity_linked", &slug, Surface::Web, format!("Linked to {subject} at the single sign-on provider"))
829 .await;
830 } else {
831 self.db
832 .prepare("UPDATE sso_identities SET last_used_at = ?, email = COALESCE(?, email) WHERE connection_id = ? AND user_id = ?")
833 .bind(&[now.as_str().into(), email_value, row.id.as_str().into(), user_id.into()])?
834 .run()
835 .await?;
836 }
837 // Not a member yet: the provider vouches for them, if the workspace
838 // lets it, within its policy (security.rs).
839 let mut joined = false;
840 if !self.is_member_of(&row.workspace_id, user_id).await? {
841 if row.allow_join == 0 {
842 return Ok(Outcome::fail(FailureCode::Forbidden, format!("Only members of {slug} sign in through its identity provider. Ask an owner to invite you.")));
843 }
844 if let Some(why) = self.policy_refusal(user_id, &slug).await? {
845 return Ok(Outcome::fail(FailureCode::Forbidden, why));
846 }
847 if let Some(refused) = self.plan_refusal(&slug, plan_needed(&slug)).await? {
848 return Ok(refused);
849 }
850 joined = self
851 .db
852 .prepare(
853 "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role, created_at)
854 VALUES (?, ?, 'member', ?) RETURNING user_id",
855 )
856 .bind(&[row.workspace_id.as_str().into(), user_id.into(), now.as_str().into()])?
857 .first::<Value>(None)
858 .await?
859 .is_some();
860 if joined {
861 self.audit_workspace(&person, "member.joined_by_sso", &slug, Surface::Web, "Joined through single sign-on".to_owned()).await;
862 self.announce_member_joined(&row.workspace_id, &slug, &person, Role::Member, JoinedHow::Joined).await;
863 }
864 }
865 // The session counts as signed in through the provider, while it lasts.
866 self.db
867 .prepare(format!(
868 "INSERT INTO sso_sessions (session_id, workspace_id, expires_at)
869 SELECT ?1, ?2, {} WHERE EXISTS (SELECT 1 FROM sessions WHERE id = ?1 AND user_id = ?3 AND expires_at > {SQL_NOW})
870 ON CONFLICT (session_id, workspace_id) DO UPDATE SET expires_at = excluded.expires_at",
871 sql_after(SSO_SESSION_SECONDS)
872 ))
873 .bind(&[state.session_id.as_str().into(), row.workspace_id.as_str().into(), user_id.into()])?
874 .run()
875 .await?;
876 let first = self
877 .db
878 .prepare("UPDATE sso_connections SET tested_at = ?, tested_by = ? WHERE id = ? AND tested_at IS NULL RETURNING id")
879 .bind(&[now.as_str().into(), user_id.into(), row.id.as_str().into()])?
880 .first::<Value>(None)
881 .await?
882 .is_some();
883 Ok(Outcome::Ok(SsoFinished { slug, next: state.next.clone(), joined, first }))
884 }
885
886 async fn sso_person(&self, user_id: &str) -> Result<Option<User>> {
887 #[derive(Deserialize)]
888 struct Person {
889 id: String,
890 username: String,
891 display_username: Option<String>,
892 verified: u8,
893 }
894 Ok(self
895 .db
896 .prepare("SELECT id, username, display_username, email_verified_at IS NOT NULL AS verified FROM users WHERE id = ? AND deleted_at IS NULL")
897 .bind(&[user_id.into()])?
898 .first::<Person>(None)
899 .await?
900 .map(|p| User { id: p.id, username: p.username, display_username: p.display_username, verified: p.verified != 0, ..User::default() }))
901 }
902
903 /// The workspaces of `required` (slugs, with the person's role) whose
904 /// single sign-on holds the person out: met by a session marked for it
905 /// (`session`, its hash) or, for a token or key, by having linked.
906 pub async fn sso_holds(&self, user: &User, required: Vec<(String, Role)>, session: Option<&str>) -> Result<Vec<String>> {
907 // g1t's own agent is never held: it is no one's to sign in.
908 if required.iter().all(|(_, role)| *role == Role::Owner) || crate::paid::is_g1t(&user.username) {
909 return Ok(Vec::new());
910 }
911 #[derive(Deserialize)]
912 struct Slug {
913 slug: String,
914 }
915 let met = match session {
916 Some(session) => {
917 self.db
918 .prepare(format!(
919 "SELECT w.slug FROM sso_sessions s JOIN workspaces w ON w.id = s.workspace_id
920 WHERE s.session_id = ? AND s.expires_at > {SQL_NOW}"
921 ))
922 .bind(&[session.into()])?
923 .all()
924 .await?
925 }
926 None => {
927 self.db
928 .prepare(
929 "SELECT w.slug FROM sso_identities i JOIN sso_connections c ON c.id = i.connection_id
930 JOIN workspaces w ON w.id = c.workspace_id WHERE i.user_id = ?",
931 )
932 .bind(&[user.id.as_str().into()])?
933 .all()
934 .await?
935 }
936 };
937 let met: HashSet<String> = met.results::<Slug>()?.into_iter().map(|row| row.slug).collect();
938 Ok(held_by_sso(&required, &met))
939 }
940
941 /// `sso_metadata`: g1t's SAML metadata for a workspace's provider.
942 pub fn sso_metadata(&self, a: SsoMetadataArgs) -> String {
943 saml::metadata(&a.entity_id, &a.acs_url)
944 }
945
946 /// Forgets which workspaces' providers a session went through, when it ends.
947 pub async fn forget_sso_session(&self, session_hash: &str) -> Result<()> {
948 self.db.prepare("DELETE FROM sso_sessions WHERE session_id = ?").bind(&[session_hash.into()])?.run().await?;
949 Ok(())
950 }
951}
952
953fn protocol_name(protocol: SsoProtocol) -> &'static str {
954 match protocol {
955 SsoProtocol::Oidc => "OpenID Connect",
956 SsoProtocol::Oauth2 => "OAuth 2.0",
957 SsoProtocol::Saml => "SAML",
958 }
959}
960
961#[cfg(test)]
962mod tests {
963 use super::*;
964 use g1t_contracts::access::{RepoGrant, RepoRole};
965
966 #[test]
967 fn owners_are_never_held_and_a_met_workspace_is_let_in() {
968 let required = vec![("acme".to_owned(), Role::Member), ("globex".to_owned(), Role::Owner), ("initech".to_owned(), Role::Member)];
969 let met: HashSet<String> = ["initech".to_owned()].into();
970 assert_eq!(held_by_sso(&required, &met), vec!["acme".to_owned()]);
971 assert!(held_by_sso(&required, &["acme".to_owned(), "initech".to_owned()].into()).is_empty());
972 }
973
974 #[test]
975 fn a_held_workspace_takes_its_repositories_with_it() {
976 let mut user = User {
977 id: "usr_1".into(),
978 username: "ada".into(),
979 workspaces: vec![Membership::member("acme"), Membership::member("globex")],
980 grants: vec![
981 RepoGrant { repo_id: "rep_1".into(), workspace: "acme".into(), role: RepoRole::Admin, team: None },
982 RepoGrant { repo_id: "rep_2".into(), workspace: "globex".into(), role: RepoRole::Write, team: None },
983 ],
984 ..User::default()
985 };
986 apply_sso_holds(&mut user, &["acme".to_owned()]);
987 assert_eq!(user.workspaces.iter().map(|m| m.slug.as_str()).collect::<Vec<_>>(), vec!["globex"]);
988 assert_eq!(user.grants.iter().map(|g| g.repo_id.as_str()).collect::<Vec<_>>(), vec!["rep_2"]);
989 assert_eq!(user.held.len(), 1);
990 assert_eq!(user.held[0].gap, "sso");
991 assert!(user.held[0].reason.contains("acme requires single sign-on"));
992 }
993
994 #[test]
995 fn each_protocol_keeps_only_its_own_fields() {
996 let given = SsoConfig {
997 issuer: Some(" https://acme.okta.com ".into()),
998 client_id: Some("client".into()),
999 authorization_url: Some("https://elsewhere.test/authorize".into()),
1000 ..SsoConfig::default()
1001 };
1002 let oidc = checked_config(SsoProtocol::Oidc, &given, vec![]).unwrap();
1003 assert_eq!(oidc.issuer.as_deref(), Some("https://acme.okta.com"));
1004 assert_eq!(oidc.authorization_url, None);
1005 assert!(checked_config(SsoProtocol::Oauth2, &given, vec![]).unwrap_err().contains("token URL"));
1006 let plain = SsoConfig { issuer: Some("http://acme.test".into()), client_id: Some("c".into()), ..SsoConfig::default() };
1007 assert!(checked_config(SsoProtocol::Oidc, &plain, vec![]).unwrap_err().contains("https"));
1008 let saml = SsoConfig { idp_entity_id: Some("http://idp/entity".into()), idp_sso_url: Some("https://idp/sso".into()), ..SsoConfig::default() };
1009 assert!(checked_config(SsoProtocol::Saml, &saml, vec![]).unwrap_err().contains("certificate"));
1010 assert_eq!(checked_config(SsoProtocol::Saml, &saml, vec!["AAAA".into()]).unwrap().certificates, vec!["AAAA".to_owned()]);
1011 }
1012
1013 #[test]
1014 fn another_provider_is_told_apart_from_a_changed_setting() {
1015 let a = SsoConfig { issuer: Some("https://acme.okta.com/".into()), client_id: Some("one".into()), ..SsoConfig::default() };
1016 let b = SsoConfig { issuer: Some("https://acme.okta.com".into()), client_id: Some("two".into()), ..SsoConfig::default() };
1017 assert_eq!(provider_of(SsoProtocol::Oidc, &a), provider_of(SsoProtocol::Oidc, &b));
1018 let c = SsoConfig { issuer: Some("https://globex.okta.com".into()), ..SsoConfig::default() };
1019 assert_ne!(provider_of(SsoProtocol::Oidc, &a), provider_of(SsoProtocol::Oidc, &c));
1020 assert_ne!(provider_of(SsoProtocol::Oidc, &a), provider_of(SsoProtocol::Saml, &a));
1021 }
1022}