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