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,010 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 /// `sso_summary`: whether a workspace has single sign-on, for anyone.
335 pub async fn sso_summary(&self, a: SlugArgs) -> Result<SsoSummary> {
336 let Some(workspace_id) = self.workspace_id_of(&a.slug.to_lowercase()).await? else {
337 return Ok(SsoSummary::default());
338 };
339 let Some(row) = self.connection_where("workspace_id", &workspace_id).await? else {
340 return Ok(SsoSummary::default());
341 };
342 Ok(SsoSummary {
343 enabled: true,
344 protocol: Some(row.protocol()),
345 required: self.require_sso_of(&workspace_id).await?,
346 allow_join: row.allow_join != 0,
347 })
348 }
349
350 /// `save_sso`: see [`SaveSsoArgs`].
351 pub async fn save_sso(&self, a: SaveSsoArgs) -> Result<Outcome<SsoSettings>> {
352 let slug = a.slug.trim().to_lowercase();
353 let workspace_id = match self.sso_owner(&a.actor, &slug).await? {
354 Outcome::Ok(id) => id,
355 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
356 };
357 if let Some(refused) = self.plan_refusal(&slug, plan_needed(&slug)).await? {
358 return Ok(refused);
359 }
360 let Some(sealer) = self.sso_sealer() else {
361 return Ok(Outcome::fail(FailureCode::Conflict, "Single sign-on is not set up on this g1t: it needs IDENTITY_KEY."));
362 };
363 let before = self.connection_where("workspace_id", &workspace_id).await?;
364 let same_protocol = before.as_ref().filter(|row| row.protocol() == a.protocol);
365 // SAML's certificates: as pasted, or the ones kept.
366 let certificates = match a.certificate.as_deref().map(str::trim).filter(|c| !c.is_empty()) {
367 Some(pasted) => match saml::certificates_from(pasted) {
368 Ok(certificates) => certificates.iter().map(|der| STANDARD.encode(der)).collect(),
369 Err(why) => return Ok(Outcome::fail(FailureCode::Invalid, why)),
370 },
371 None => same_protocol.map(|row| row.config().certificates).unwrap_or_default(),
372 };
373 let config = match checked_config(a.protocol, &a.config, if a.protocol == SsoProtocol::Saml { certificates } else { vec![] }) {
374 Ok(config) => config,
375 Err(why) => return Ok(Outcome::fail(FailureCode::Invalid, why)),
376 };
377 if a.protocol == SsoProtocol::Oidc
378 && let Err(why) = oauth::discover(config.issuer.as_deref().unwrap_or_default()).await
379 {
380 return Ok(Outcome::fail(FailureCode::Invalid, why));
381 }
382 let id = before.as_ref().map(|row| row.id.clone()).unwrap_or_else(|| new_id("sso", now_ms()));
383 // The client secret: as given, or the one kept.
384 let secret = match a.client_secret.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
385 _ if a.protocol == SsoProtocol::Saml => None,
386 Some(secret) if secret.len() > MAX_FIELD => return Ok(Outcome::fail(FailureCode::Invalid, "The client secret is too long.")),
387 Some(secret) => Some(sealer.seal(secret, &format!("sso:{id}"))),
388 None => match same_protocol.and_then(|row| row.client_secret.clone()) {
389 Some(kept) => Some(kept),
390 None => return Ok(Outcome::fail(FailureCode::Invalid, "Enter the client secret.")),
391 },
392 };
393 let changed_provider = before.as_ref().is_some_and(|row| provider_of(row.protocol(), &row.config()) != provider_of(a.protocol, &config));
394 let now = rfc3339(now_ms());
395 let stored = serde_json::to_string(&config).unwrap_or_default();
396 let secret_value = secret.as_deref().map_or(JsValue::NULL, Into::into);
397 self.db
398 .prepare(
399 "INSERT INTO sso_connections (id, workspace_id, protocol, config, client_secret, allow_join, created_at, updated_at)
400 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)
401 ON CONFLICT (workspace_id) DO UPDATE SET protocol = excluded.protocol, config = excluded.config,
402 client_secret = excluded.client_secret, allow_join = excluded.allow_join, updated_at = excluded.updated_at",
403 )
404 .bind(&[
405 id.as_str().into(),
406 workspace_id.as_str().into(),
407 a.protocol.as_str().into(),
408 stored.into(),
409 secret_value,
410 (a.allow_join as u8).into(),
411 now.as_str().into(),
412 ])?
413 .run()
414 .await?;
415 if changed_provider {
416 // Another provider: who people were at the old one means nothing
417 // now, and it must be tried before it is required again.
418 self.db
419 .batch(vec![
420 self.db.prepare("DELETE FROM sso_identities WHERE connection_id = ?").bind(&[id.as_str().into()])?,
421 self.db.prepare("DELETE FROM sso_sessions WHERE workspace_id = ?").bind(&[workspace_id.as_str().into()])?,
422 self.db
423 .prepare("UPDATE sso_connections SET tested_at = NULL, tested_by = NULL WHERE id = ?")
424 .bind(&[id.as_str().into()])?,
425 self.db.prepare("UPDATE workspaces SET require_sso = 0 WHERE id = ?").bind(&[workspace_id.as_str().into()])?,
426 ])
427 .await?;
428 }
429 let surface = a.surface.unwrap_or(Surface::Web);
430 let message = match (&before, changed_provider) {
431 (None, _) => format!("Set up single sign-on over {}", protocol_name(a.protocol)),
432 (Some(_), true) => format!("Moved single sign-on to another identity provider ({}), unlinking everyone", protocol_name(a.protocol)),
433 (Some(_), false) => "Changed single sign-on's settings".to_owned(),
434 };
435 self.audit_workspace(&a.actor, if before.is_none() { "sso.created" } else { "sso.updated" }, &slug, surface, message).await;
436 self.announce_workspace(&workspace_id, &slug, Some(&a.actor.id)).await;
437 self.settings_of(&workspace_id, &slug, &a.actor).await.map(Outcome::Ok)
438 }
439
440 /// `set_sso_required`: see [`SetSsoRequiredArgs`].
441 pub async fn set_sso_required(&self, a: SetSsoRequiredArgs) -> Result<Outcome<bool>> {
442 let slug = a.slug.trim().to_lowercase();
443 let workspace_id = match self.sso_owner(&a.actor, &slug).await? {
444 Outcome::Ok(id) => id,
445 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
446 };
447 let before = self.require_sso_of(&workspace_id).await?;
448 if before == a.required {
449 return Ok(Outcome::Ok(before));
450 }
451 if a.required {
452 let Some(row) = self.connection_where("workspace_id", &workspace_id).await? else {
453 return Ok(Outcome::fail(FailureCode::Conflict, "Set up single sign-on before requiring it."));
454 };
455 if let Some(refused) = self.plan_refusal(&slug, plan_needed(&slug)).await? {
456 return Ok(refused);
457 }
458 if !self.sso_linked(&row.id, &a.actor.id).await? {
459 return Ok(Outcome::fail(
460 FailureCode::Conflict,
461 "Sign in through the identity provider yourself before requiring it of everyone: use Test sign-in.",
462 ));
463 }
464 }
465 self.db
466 .prepare("UPDATE workspaces SET require_sso = ? WHERE id = ?")
467 .bind(&[(a.required as u8).into(), workspace_id.as_str().into()])?
468 .run()
469 .await?;
470 self.audit_workspace(
471 &a.actor,
472 if a.required { "sso.required" } else { "sso.not_required" },
473 &slug,
474 a.surface.unwrap_or(Surface::Web),
475 if a.required { "Required single sign-on of members".to_owned() } else { "Stopped requiring single sign-on".to_owned() },
476 )
477 .await;
478 self.announce_workspace(&workspace_id, &slug, Some(&a.actor.id)).await;
479 Ok(Outcome::Ok(a.required))
480 }
481
482 /// `delete_sso`: removes the connection, everyone's links to it, and the
483 /// requirement. Never needs a paid plan.
484 pub async fn delete_sso(&self, a: SsoSettingsArgs) -> Result<Outcome<bool>> {
485 let slug = a.slug.trim().to_lowercase();
486 let workspace_id = match self.sso_owner(&a.actor, &slug).await? {
487 Outcome::Ok(id) => id,
488 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
489 };
490 let Some(row) = self.connection_where("workspace_id", &workspace_id).await? else {
491 return Ok(Outcome::Ok(false));
492 };
493 self.db
494 .batch(vec![
495 self.db.prepare("DELETE FROM sso_states WHERE connection_id = ?").bind(&[row.id.as_str().into()])?,
496 self.db.prepare("DELETE FROM sso_identities WHERE connection_id = ?").bind(&[row.id.as_str().into()])?,
497 self.db.prepare("DELETE FROM sso_connections WHERE id = ?").bind(&[row.id.as_str().into()])?,
498 self.db.prepare("DELETE FROM sso_sessions WHERE workspace_id = ?").bind(&[workspace_id.as_str().into()])?,
499 self.db.prepare("UPDATE workspaces SET require_sso = 0 WHERE id = ?").bind(&[workspace_id.as_str().into()])?,
500 ])
501 .await?;
502 self.audit_workspace(&a.actor, "sso.deleted", &slug, a.surface.unwrap_or(Surface::Web), "Removed single sign-on".to_owned())
503 .await;
504 self.announce_workspace(&workspace_id, &slug, Some(&a.actor.id)).await;
505 Ok(Outcome::Ok(true))
506 }
507
508 /// `unlink_sso_identity`: see [`UnlinkSsoIdentityArgs`].
509 pub async fn unlink_sso_identity(&self, a: UnlinkSsoIdentityArgs) -> Result<Outcome<bool>> {
510 let slug = a.slug.trim().to_lowercase();
511 let workspace_id = match self.sso_owner(&a.actor, &slug).await? {
512 Outcome::Ok(id) => id,
513 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
514 };
515 let Some(row) = self.connection_where("workspace_id", &workspace_id).await? else {
516 return Ok(Outcome::Ok(false));
517 };
518 let username = a.username.trim().trim_start_matches('@').to_lowercase();
519 let removed = self
520 .db
521 .prepare(
522 "DELETE FROM sso_identities WHERE connection_id = ?
523 AND user_id = (SELECT id FROM users WHERE username = ?) RETURNING user_id",
524 )
525 .bind(&[row.id.as_str().into(), username.as_str().into()])?
526 .first::<Value>(None)
527 .await?;
528 let Some(removed) = removed else {
529 return Ok(Outcome::Ok(false));
530 };
531 if let Some(user_id) = removed["user_id"].as_str() {
532 // Their sessions go through the provider again.
533 self.db
534 .prepare("DELETE FROM sso_sessions WHERE workspace_id = ? AND session_id IN (SELECT id FROM sessions WHERE user_id = ?)")
535 .bind(&[workspace_id.as_str().into(), user_id.into()])?
536 .run()
537 .await?;
538 }
539 self.audit_workspace(
540 &a.actor,
541 "sso.identity_unlinked",
542 &slug,
543 a.surface.unwrap_or(Surface::Web),
544 format!("Unlinked {username}'s identity at the single sign-on provider"),
545 )
546 .await;
547 Ok(Outcome::Ok(true))
548 }
549
550 /// `sso_start`: see [`SsoStartArgs`].
551 pub async fn sso_start(&self, a: SsoStartArgs) -> Result<Outcome<SsoStart>> {
552 if !is_person(&a.user) {
553 return Ok(Outcome::fail(FailureCode::Forbidden, "Sign in to g1t as yourself first."));
554 }
555 if !a.user.verified {
556 return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address first."));
557 }
558 let slug = a.slug.trim().to_lowercase();
559 let Some(workspace_id) = self.workspace_id_of(&slug).await? else {
560 return Ok(Outcome::fail(FailureCode::NotFound, "There is no workspace with that name."));
561 };
562 let Some(row) = self.connection_where("workspace_id", &workspace_id).await? else {
563 return Ok(Outcome::fail(FailureCode::NotFound, format!("{slug} has not set up single sign-on.")));
564 };
565 if row.allow_join == 0 && !self.is_member_of(&workspace_id, &a.user.id).await? {
566 return Ok(Outcome::fail(
567 FailureCode::Forbidden,
568 format!("Only members of {slug} sign in through its identity provider. Ask an owner to invite you."),
569 ));
570 }
571 if let Some(refused) = self.plan_refusal(&slug, plan_needed(&slug)).await? {
572 return Ok(refused);
573 }
574 let session_id = crypto::sha256_hex(&a.session_token);
575 let live = self
576 .db
577 .prepare(format!("SELECT 1 AS yes FROM sessions WHERE id = ? AND user_id = ? AND expires_at > {SQL_NOW}"))
578 .bind(&[session_id.as_str().into(), a.user.id.as_str().into()])?
579 .first::<Value>(None)
580 .await?;
581 if live.is_none() {
582 return Ok(Outcome::fail(FailureCode::Unauthenticated, "Sign in to g1t again."));
583 }
584 let callback = worker::Url::parse(&a.redirect_uri).ok();
585 if !callback.is_some_and(|url| url.scheme() == "https" || url.host_str() == Some("localhost")) {
586 return Ok(Outcome::fail(FailureCode::Invalid, "The callback must be an https address."));
587 }
588 let config = row.config();
589 let (key, url, state, verifier, nonce) = match row.protocol() {
590 SsoProtocol::Saml => {
591 let Some(entity_id) = a.entity_id.as_deref().filter(|e| !e.is_empty()) else {
592 return Ok(Outcome::fail(FailureCode::Invalid, "SAML needs g1t's entity id."));
593 };
594 let request_id = saml::request_id(&crypto::random_hex(20));
595 let sso_url = config.idp_sso_url.as_deref().unwrap_or_default();
596 let Some(url) = saml::authn_request_url(sso_url, &request_id, entity_id, &a.redirect_uri, &slug, now_ms()) else {
597 return Ok(Outcome::fail(FailureCode::Invalid, "The identity provider's single sign-on URL must be an https address."));
598 };
599 (request_id, url, None, None, None)
600 }
601 protocol => {
602 let endpoints = match self.endpoints_of(protocol, &config).await {
603 Ok(endpoints) => endpoints,
604 Err(why) => return Ok(Outcome::fail(FailureCode::Conflict, why)),
605 };
606 let state = crypto::random_hex(32);
607 let verifier = crate::github::new_verifier();
608 let nonce = (protocol == SsoProtocol::Oidc).then(|| crypto::random_hex(16));
609 let scopes = oauth::scopes(config.scopes.as_deref(), protocol == SsoProtocol::Oidc);
610 let authorize = oauth::Authorize {
611 client_id: config.client_id.as_deref().unwrap_or_default(),
612 redirect_uri: &a.redirect_uri,
613 scopes: &scopes,
614 state: &state,
615 nonce: nonce.as_deref(),
616 challenge: &crate::github::pkce_challenge(&verifier),
617 };
618 let Some(url) = oauth::authorize_url(&endpoints.authorize, &authorize) else {
619 return Ok(Outcome::fail(FailureCode::Conflict, "The provider's authorization endpoint must be an https address."));
620 };
621 (state.clone(), url, Some(state), Some(verifier), nonce)
622 }
623 };
624 let optional = |value: Option<&str>| value.map_or(JsValue::NULL, Into::into);
625 self.db
626 .prepare(format!(
627 "INSERT INTO sso_states (id, connection_id, session_id, user_id, verifier, nonce, redirect_uri, audience, next, expires_at)
628 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, {})",
629 sql_after(STATE_TTL_SECONDS)
630 ))
631 .bind(&[
632 crypto::sha256_hex(&key).into(),
633 row.id.as_str().into(),
634 session_id.as_str().into(),
635 a.user.id.as_str().into(),
636 optional(verifier.as_deref()),
637 optional(nonce.as_deref()),
638 a.redirect_uri.as_str().into(),
639 optional(a.entity_id.as_deref()),
640 a.next.as_str().into(),
641 ])?
642 .run()
643 .await?;
644 // Old trips never finished, and sessions past their day, go now and then.
645 self.db
646 .batch(vec![
647 self.db.prepare(format!("DELETE FROM sso_states WHERE expires_at < {SQL_NOW}")),
648 self.db.prepare(format!("DELETE FROM sso_sessions WHERE expires_at < {SQL_NOW}")),
649 ])
650 .await?;
651 Ok(Outcome::Ok(SsoStart { url, state }))
652 }
653
654 async fn endpoints_of(&self, protocol: SsoProtocol, config: &SsoConfig) -> std::result::Result<oauth::Endpoints, String> {
655 match protocol {
656 SsoProtocol::Oidc => oauth::discover(config.issuer.as_deref().unwrap_or_default()).await,
657 _ => Ok(oauth::Endpoints {
658 authorize: config.authorization_url.clone().unwrap_or_default(),
659 token: config.token_url.clone().unwrap_or_default(),
660 userinfo: config.userinfo_url.clone(),
661 issuer: None,
662 basic: false,
663 }),
664 }
665 }
666
667 /// Spends a state row, once, and finds its connection, which must be
668 /// `slug`'s.
669 async fn spend_state(&self, key: &str, slug: &str) -> Result<Option<(StateRow, ConnectionRow)>> {
670 let state = self
671 .db
672 .prepare(format!("DELETE FROM sso_states WHERE id = ? AND expires_at > {SQL_NOW} RETURNING *"))
673 .bind(&[crypto::sha256_hex(key).into()])?
674 .first::<StateRow>(None)
675 .await?;
676 let Some(state) = state else {
677 return Ok(None);
678 };
679 let Some(row) = self.connection_where("id", &state.connection_id).await? else {
680 return Ok(None);
681 };
682 let workspace = self.workspace_id_of(&slug.trim().to_lowercase()).await?;
683 Ok((workspace.as_deref() == Some(row.workspace_id.as_str())).then_some((state, row)))
684 }
685
686 /// `sso_finish`: see [`SsoFinishArgs`].
687 pub async fn sso_finish(&self, a: SsoFinishArgs) -> Result<Outcome<SsoFinished>> {
688 let Some((state, row)) = self.spend_state(&a.state, &a.slug).await? else {
689 return Ok(Outcome::fail(FailureCode::Invalid, EXPIRED));
690 };
691 if crypto::sha256_hex(&a.session_token) != state.session_id {
692 return Ok(Outcome::fail(FailureCode::Invalid, "This single sign-on started in another session. Start again."));
693 }
694 let protocol = row.protocol();
695 let config = row.config();
696 let secret = row
697 .client_secret
698 .as_deref()
699 .and_then(|sealed| self.sso_sealer()?.open(sealed, &format!("sso:{}", row.id)));
700 let Some(secret) = secret else {
701 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."));
702 };
703 let said = async {
704 let endpoints = self.endpoints_of(protocol, &config).await?;
705 let client_id = config.client_id.as_deref().unwrap_or_default();
706 let tokens = oauth::exchange(&oauth::Exchange {
707 token_url: &endpoints.token,
708 basic: endpoints.basic,
709 client_id,
710 client_secret: &secret,
711 code: &a.code,
712 redirect_uri: &state.redirect_uri,
713 verifier: state.verifier.as_deref().unwrap_or_default(),
714 })
715 .await?;
716 if protocol == SsoProtocol::Oidc {
717 let id_token = tokens["id_token"].as_str().ok_or("The identity provider sent no ID token.")?;
718 let claims = oauth::id_token_claims(
719 id_token,
720 endpoints.issuer.as_deref().unwrap_or_default(),
721 client_id,
722 state.nonce.as_deref().unwrap_or_default(),
723 now_ms() / 1000,
724 )?;
725 let subject = oauth::claim(&claims, "sub").ok_or("The ID token names no one.")?;
726 let email = oauth::claim(&claims, "email").filter(|_| claims["email_verified"].as_bool() != Some(false));
727 Ok::<_, String>((subject, email))
728 } else {
729 let access = tokens["access_token"].as_str().ok_or("The identity provider sent no access token.")?;
730 let info = oauth::userinfo(endpoints.userinfo.as_deref().unwrap_or_default(), access).await?;
731 let subject = match config.subject_claim.as_deref() {
732 Some(path) => oauth::claim(&info, path),
733 None => oauth::claim(&info, "sub").or_else(|| oauth::claim(&info, "id")),
734 }
735 .ok_or("The userinfo answer has no id for the person: set the subject claim.")?;
736 let email = oauth::claim(&info, config.email_claim.as_deref().unwrap_or("email"));
737 Ok((subject, email))
738 }
739 };
740 match said.await {
741 Ok((subject, email)) => self.complete(&row, &state, &a.slug, &subject, email.as_deref()).await,
742 Err(why) => Ok(Outcome::fail(FailureCode::Invalid, why)),
743 }
744 }
745
746 /// `sso_saml`: see [`SsoSamlArgs`].
747 pub async fn sso_saml(&self, a: SsoSamlArgs) -> Result<Outcome<SsoFinished>> {
748 let Some(request_id) = saml::in_response_to(&a.saml_response) else {
749 return Ok(Outcome::fail(
750 FailureCode::Invalid,
751 "Start single sign-on from g1t: sign-ins started at the identity provider are not accepted.",
752 ));
753 };
754 let Some((state, row)) = self.spend_state(&request_id, &a.slug).await? else {
755 return Ok(Outcome::fail(FailureCode::Invalid, EXPIRED));
756 };
757 let config = row.config();
758 let certificates: Vec<Vec<u8>> = config.certificates.iter().filter_map(|der| STANDARD.decode(der).ok()).collect();
759 let expect = saml::Expect {
760 idp_entity_id: config.idp_entity_id.as_deref().unwrap_or_default(),
761 certificates: &certificates,
762 acs_url: &state.redirect_uri,
763 audience: state.audience.as_deref().unwrap_or_default(),
764 request_id: &request_id,
765 now_ms: now_ms(),
766 };
767 match saml::verify_response(&a.saml_response, &expect) {
768 Ok(asserted) => self.complete(&row, &state, &a.slug, &asserted.subject, asserted.email.as_deref()).await,
769 Err(why) => Ok(Outcome::fail(FailureCode::Invalid, why)),
770 }
771 }
772
773 /// The provider has said who signed in: link them, let them in, and
774 /// mark their session.
775 async fn complete(&self, row: &ConnectionRow, state: &StateRow, slug: &str, subject: &str, email: Option<&str>) -> Result<Outcome<SsoFinished>> {
776 let slug = slug.trim().to_lowercase();
777 let user_id = state.user_id.as_str();
778 let Some(person) = self.sso_person(user_id).await? else {
779 return Ok(Outcome::fail(FailureCode::Unauthenticated, "Sign in to g1t again."));
780 };
781 #[derive(Deserialize)]
782 struct Linked {
783 user_id: String,
784 subject: String,
785 }
786 let linked = self
787 .db
788 .prepare("SELECT user_id, subject FROM sso_identities WHERE connection_id = ?1 AND (subject = ?2 OR user_id = ?3)")
789 .bind(&[row.id.as_str().into(), subject.into(), user_id.into()])?
790 .all()
791 .await?
792 .results::<Linked>()?;
793 let now = rfc3339(now_ms());
794 let email_value = email.map_or(JsValue::NULL, Into::into);
795 if linked.iter().any(|l| l.subject == subject && l.user_id != user_id) {
796 return Ok(Outcome::fail(
797 FailureCode::Conflict,
798 format!("That account at {slug}'s identity provider is linked to another g1t account. An owner of {slug} can unlink it."),
799 ));
800 }
801 if linked.iter().any(|l| l.user_id == user_id && l.subject != subject) {
802 return Ok(Outcome::fail(
803 FailureCode::Conflict,
804 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."),
805 ));
806 }
807 if linked.is_empty() {
808 self.db
809 .prepare(
810 "INSERT INTO sso_identities (connection_id, user_id, subject, email, created_at, last_used_at)
811 VALUES (?, ?, ?, ?, ?, ?)",
812 )
813 .bind(&[row.id.as_str().into(), user_id.into(), subject.into(), email_value, now.as_str().into(), now.as_str().into()])?
814 .run()
815 .await?;
816 self.audit_workspace(&person, "sso.identity_linked", &slug, Surface::Web, format!("Linked to {subject} at the single sign-on provider"))
817 .await;
818 } else {
819 self.db
820 .prepare("UPDATE sso_identities SET last_used_at = ?, email = COALESCE(?, email) WHERE connection_id = ? AND user_id = ?")
821 .bind(&[now.as_str().into(), email_value, row.id.as_str().into(), user_id.into()])?
822 .run()
823 .await?;
824 }
825 // Not a member yet: the provider vouches for them, if the workspace
826 // lets it, within its policy (security.rs).
827 let mut joined = false;
828 if !self.is_member_of(&row.workspace_id, user_id).await? {
829 if row.allow_join == 0 {
830 return Ok(Outcome::fail(FailureCode::Forbidden, format!("Only members of {slug} sign in through its identity provider. Ask an owner to invite you.")));
831 }
832 if let Some(why) = self.policy_refusal(user_id, &slug).await? {
833 return Ok(Outcome::fail(FailureCode::Forbidden, why));
834 }
835 if let Some(refused) = self.plan_refusal(&slug, plan_needed(&slug)).await? {
836 return Ok(refused);
837 }
838 joined = self
839 .db
840 .prepare(
841 "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role, created_at)
842 VALUES (?, ?, 'member', ?) RETURNING user_id",
843 )
844 .bind(&[row.workspace_id.as_str().into(), user_id.into(), now.as_str().into()])?
845 .first::<Value>(None)
846 .await?
847 .is_some();
848 if joined {
849 self.audit_workspace(&person, "member.joined_by_sso", &slug, Surface::Web, "Joined through single sign-on".to_owned()).await;
850 self.announce_member_joined(&row.workspace_id, &slug, &person, Role::Member, JoinedHow::Joined).await;
851 }
852 }
853 // The session counts as signed in through the provider, while it lasts.
854 self.db
855 .prepare(format!(
856 "INSERT INTO sso_sessions (session_id, workspace_id, expires_at)
857 SELECT ?1, ?2, {} WHERE EXISTS (SELECT 1 FROM sessions WHERE id = ?1 AND user_id = ?3 AND expires_at > {SQL_NOW})
858 ON CONFLICT (session_id, workspace_id) DO UPDATE SET expires_at = excluded.expires_at",
859 sql_after(SSO_SESSION_SECONDS)
860 ))
861 .bind(&[state.session_id.as_str().into(), row.workspace_id.as_str().into(), user_id.into()])?
862 .run()
863 .await?;
864 let first = self
865 .db
866 .prepare("UPDATE sso_connections SET tested_at = ?, tested_by = ? WHERE id = ? AND tested_at IS NULL RETURNING id")
867 .bind(&[now.as_str().into(), user_id.into(), row.id.as_str().into()])?
868 .first::<Value>(None)
869 .await?
870 .is_some();
871 Ok(Outcome::Ok(SsoFinished { slug, next: state.next.clone(), joined, first }))
872 }
873
874 async fn sso_person(&self, user_id: &str) -> Result<Option<User>> {
875 #[derive(Deserialize)]
876 struct Person {
877 id: String,
878 username: String,
879 display_username: Option<String>,
880 verified: u8,
881 }
882 Ok(self
883 .db
884 .prepare("SELECT id, username, display_username, email_verified_at IS NOT NULL AS verified FROM users WHERE id = ? AND deleted_at IS NULL")
885 .bind(&[user_id.into()])?
886 .first::<Person>(None)
887 .await?
888 .map(|p| User { id: p.id, username: p.username, display_username: p.display_username, verified: p.verified != 0, ..User::default() }))
889 }
890
891 /// The workspaces of `required` (slugs, with the person's role) whose
892 /// single sign-on holds the person out: met by a session marked for it
893 /// (`session`, its hash) or, for a token or key, by having linked.
894 pub async fn sso_holds(&self, user: &User, required: Vec<(String, Role)>, session: Option<&str>) -> Result<Vec<String>> {
895 // g1t's own agent is never held: it is no one's to sign in.
896 if required.iter().all(|(_, role)| *role == Role::Owner) || crate::paid::is_g1t(&user.username) {
897 return Ok(Vec::new());
898 }
899 #[derive(Deserialize)]
900 struct Slug {
901 slug: String,
902 }
903 let met = match session {
904 Some(session) => {
905 self.db
906 .prepare(format!(
907 "SELECT w.slug FROM sso_sessions s JOIN workspaces w ON w.id = s.workspace_id
908 WHERE s.session_id = ? AND s.expires_at > {SQL_NOW}"
909 ))
910 .bind(&[session.into()])?
911 .all()
912 .await?
913 }
914 None => {
915 self.db
916 .prepare(
917 "SELECT w.slug FROM sso_identities i JOIN sso_connections c ON c.id = i.connection_id
918 JOIN workspaces w ON w.id = c.workspace_id WHERE i.user_id = ?",
919 )
920 .bind(&[user.id.as_str().into()])?
921 .all()
922 .await?
923 }
924 };
925 let met: HashSet<String> = met.results::<Slug>()?.into_iter().map(|row| row.slug).collect();
926 Ok(held_by_sso(&required, &met))
927 }
928
929 /// `sso_metadata`: g1t's SAML metadata for a workspace's provider.
930 pub fn sso_metadata(&self, a: SsoMetadataArgs) -> String {
931 saml::metadata(&a.entity_id, &a.acs_url)
932 }
933
934 /// Forgets which workspaces' providers a session went through, when it ends.
935 pub async fn forget_sso_session(&self, session_hash: &str) -> Result<()> {
936 self.db.prepare("DELETE FROM sso_sessions WHERE session_id = ?").bind(&[session_hash.into()])?.run().await?;
937 Ok(())
938 }
939}
940
941fn protocol_name(protocol: SsoProtocol) -> &'static str {
942 match protocol {
943 SsoProtocol::Oidc => "OpenID Connect",
944 SsoProtocol::Oauth2 => "OAuth 2.0",
945 SsoProtocol::Saml => "SAML",
946 }
947}
948
949#[cfg(test)]
950mod tests {
951 use super::*;
952 use g1t_contracts::access::{RepoGrant, RepoRole};
953
954 #[test]
955 fn owners_are_never_held_and_a_met_workspace_is_let_in() {
956 let required = vec![("acme".to_owned(), Role::Member), ("globex".to_owned(), Role::Owner), ("initech".to_owned(), Role::Member)];
957 let met: HashSet<String> = ["initech".to_owned()].into();
958 assert_eq!(held_by_sso(&required, &met), vec!["acme".to_owned()]);
959 assert!(held_by_sso(&required, &["acme".to_owned(), "initech".to_owned()].into()).is_empty());
960 }
961
962 #[test]
963 fn a_held_workspace_takes_its_repositories_with_it() {
964 let mut user = User {
965 id: "usr_1".into(),
966 username: "ada".into(),
967 workspaces: vec![Membership::member("acme"), Membership::member("globex")],
968 grants: vec![
969 RepoGrant { repo_id: "rep_1".into(), workspace: "acme".into(), role: RepoRole::Admin, team: None },
970 RepoGrant { repo_id: "rep_2".into(), workspace: "globex".into(), role: RepoRole::Write, team: None },
971 ],
972 ..User::default()
973 };
974 apply_sso_holds(&mut user, &["acme".to_owned()]);
975 assert_eq!(user.workspaces.iter().map(|m| m.slug.as_str()).collect::<Vec<_>>(), vec!["globex"]);
976 assert_eq!(user.grants.iter().map(|g| g.repo_id.as_str()).collect::<Vec<_>>(), vec!["rep_2"]);
977 assert_eq!(user.held.len(), 1);
978 assert_eq!(user.held[0].gap, "sso");
979 assert!(user.held[0].reason.contains("acme requires single sign-on"));
980 }
981
982 #[test]
983 fn each_protocol_keeps_only_its_own_fields() {
984 let given = SsoConfig {
985 issuer: Some(" https://acme.okta.com ".into()),
986 client_id: Some("client".into()),
987 authorization_url: Some("https://elsewhere.test/authorize".into()),
988 ..SsoConfig::default()
989 };
990 let oidc = checked_config(SsoProtocol::Oidc, &given, vec![]).unwrap();
991 assert_eq!(oidc.issuer.as_deref(), Some("https://acme.okta.com"));
992 assert_eq!(oidc.authorization_url, None);
993 assert!(checked_config(SsoProtocol::Oauth2, &given, vec![]).unwrap_err().contains("token URL"));
994 let plain = SsoConfig { issuer: Some("http://acme.test".into()), client_id: Some("c".into()), ..SsoConfig::default() };
995 assert!(checked_config(SsoProtocol::Oidc, &plain, vec![]).unwrap_err().contains("https"));
996 let saml = SsoConfig { idp_entity_id: Some("http://idp/entity".into()), idp_sso_url: Some("https://idp/sso".into()), ..SsoConfig::default() };
997 assert!(checked_config(SsoProtocol::Saml, &saml, vec![]).unwrap_err().contains("certificate"));
998 assert_eq!(checked_config(SsoProtocol::Saml, &saml, vec!["AAAA".into()]).unwrap().certificates, vec!["AAAA".to_owned()]);
999 }
1000
1001 #[test]
1002 fn another_provider_is_told_apart_from_a_changed_setting() {
1003 let a = SsoConfig { issuer: Some("https://acme.okta.com/".into()), client_id: Some("one".into()), ..SsoConfig::default() };
1004 let b = SsoConfig { issuer: Some("https://acme.okta.com".into()), client_id: Some("two".into()), ..SsoConfig::default() };
1005 assert_eq!(provider_of(SsoProtocol::Oidc, &a), provider_of(SsoProtocol::Oidc, &b));
1006 let c = SsoConfig { issuer: Some("https://globex.okta.com".into()), ..SsoConfig::default() };
1007 assert_ne!(provider_of(SsoProtocol::Oidc, &a), provider_of(SsoProtocol::Oidc, &c));
1008 assert_ne!(provider_of(SsoProtocol::Oidc, &a), provider_of(SsoProtocol::Saml, &a));
1009 }
1010}