| 1 | //! A workspace's single sign-on, over REST and MCP: its connection to the |
| 2 | //! workspace's identity provider (OpenID Connect, OAuth 2.0 or SAML 2.0), |
| 3 | //! requiring it of members, the people linked to it, and unlinking one. |
| 4 | //! Identity decides and keeps all of it (services/identity/src/sso.rs); |
| 5 | //! owners only, as people, and setting it up needs a paid plan. Signing in |
| 6 | //! through it is a browser's trip to the provider, so it is not here. |
| 7 | |
| 8 | use g1t_contracts::sso::{ScimToken, SsoConfig, SsoConnection, SsoProtocol, SsoSettings, sso_addresses}; |
| 9 | use g1t_contracts::{FailureCode, Outcome, Viewer}; |
| 10 | use serde_json::{Value, json}; |
| 11 | use worker::Result; |
| 12 | |
| 13 | use crate::operations::Services; |
| 14 | |
| 15 | /// One operation. |
| 16 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 17 | pub enum SsoOp { |
| 18 | GetSso, |
| 19 | ConfigureSso, |
| 20 | SetSsoRequired, |
| 21 | DeleteSso, |
| 22 | ListSsoIdentities, |
| 23 | UnlinkSsoIdentity, |
| 24 | EnableScim, |
| 25 | DisableScim, |
| 26 | } |
| 27 | |
| 28 | /// The config fields an owner sends, as `SsoConfig` names them. |
| 29 | const CONFIG_FIELDS: [&str; 10] = [ |
| 30 | "issuer", |
| 31 | "client_id", |
| 32 | "scopes", |
| 33 | "authorization_url", |
| 34 | "token_url", |
| 35 | "userinfo_url", |
| 36 | "subject_claim", |
| 37 | "email_claim", |
| 38 | "idp_entity_id", |
| 39 | "idp_sso_url", |
| 40 | ]; |
| 41 | |
| 42 | impl SsoOp { |
| 43 | /// Every one: `Op::ALL` lists each as `Op::Sso(…)`, which a test checks |
| 44 | /// against this. |
| 45 | #[cfg(test)] |
| 46 | pub const ALL: [SsoOp; 8] = [ |
| 47 | SsoOp::GetSso, |
| 48 | SsoOp::ConfigureSso, |
| 49 | SsoOp::SetSsoRequired, |
| 50 | SsoOp::DeleteSso, |
| 51 | SsoOp::ListSsoIdentities, |
| 52 | SsoOp::UnlinkSsoIdentity, |
| 53 | SsoOp::EnableScim, |
| 54 | SsoOp::DisableScim, |
| 55 | ]; |
| 56 | |
| 57 | pub fn name(self) -> &'static str { |
| 58 | match self { |
| 59 | SsoOp::GetSso => "get_sso", |
| 60 | SsoOp::ConfigureSso => "configure_sso", |
| 61 | SsoOp::SetSsoRequired => "set_sso_required", |
| 62 | SsoOp::DeleteSso => "delete_sso", |
| 63 | SsoOp::ListSsoIdentities => "list_sso_identities", |
| 64 | SsoOp::UnlinkSsoIdentity => "unlink_sso_identity", |
| 65 | SsoOp::EnableScim => "enable_scim", |
| 66 | SsoOp::DisableScim => "disable_scim", |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | pub fn title(self) -> &'static str { |
| 71 | match self { |
| 72 | SsoOp::GetSso => "Get a workspace's single sign-on", |
| 73 | SsoOp::ConfigureSso => "Set up or change a workspace's single sign-on", |
| 74 | SsoOp::SetSsoRequired => "Require single sign-on, or stop", |
| 75 | SsoOp::DeleteSso => "Remove a workspace's single sign-on", |
| 76 | SsoOp::ListSsoIdentities => "List the people linked through single sign-on", |
| 77 | SsoOp::UnlinkSsoIdentity => "Unlink someone from single sign-on", |
| 78 | SsoOp::EnableScim => "Turn on SCIM, or make a new SCIM token", |
| 79 | SsoOp::DisableScim => "Turn off SCIM", |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | pub fn description(self) -> &'static str { |
| 84 | match self { |
| 85 | SsoOp::GetSso => "A workspace's single sign-on through its own identity provider: connection (null when it has none: its id, protocol oidc, oauth2 or saml, config with the protocol's fields, has_client_secret, certificates by SHA-256 fingerprint for SAML, allow_join, tested_at and tested_by, when the first person signed in through it), required (whether members must sign in through it), you_linked (whether you have), identities (the people linked: username, subject and email at the provider, linked_at, last_used_at), and service_provider: the addresses to give the provider (redirect_uri for OpenID Connect and OAuth 2.0; entity_id, acs_url and metadata_url for SAML; sign_in_url, where people start). The client secret is never returned. Owners only, as people.", |
| 86 | SsoOp::ConfigureSso => "Set up the workspace's single sign-on, or change it; a field left out keeps what is saved for the same protocol. oidc: issuer (read for its discovery document first), client_id, client_secret, scopes (openid email profile by default). oauth2: authorization_url, token_url, userinfo_url, client_id, client_secret, scopes, subject_claim (sub, then id, by default) and email_claim (email). saml: idp_entity_id, idp_sso_url and certificate (the provider's signing certificates, PEM; several while rotating). client_secret and certificate left out keep what is saved. allow_join (false by default) lets anyone the provider signs in join as a member; join_domains limits that to addresses the provider vouches for at those domains. Moving to another provider (protocol, issuer or entity id) unlinks everyone and stops requiring it. Needs a paid plan. Returns what get_sso does. Recorded as sso.created or sso.updated. Owners only, as people.", |
| 87 | SsoOp::SetSsoRequired => "Whether members must sign in through the provider: then a member uses the workspace only from a session that did in the last day, and with tokens and keys once linked; held out otherwise, keeping their place. Owners are never held, and outside collaborators are not asked. Requiring it needs a paid plan and you to have signed in through it yourself (you_linked); stopping never does. Recorded as sso.required or sso.not_required. Owners only, as people.", |
| 88 | SsoOp::DeleteSso => "Remove the workspace's single sign-on: forgets the provider and who everyone is there, and stops requiring it. Members keep their place. Never needs a plan. Recorded as sso.deleted. Owners only, as people.", |
| 89 | SsoOp::ListSsoIdentities => "The people linked to the workspace's provider: username, subject (their id at the provider), email (as the provider gave it, for showing), linked_at and last_used_at. Owners only, as people.", |
| 90 | SsoOp::EnableScim => "Turn on SCIM for the workspace's single sign-on, or replace its token: returns token (shown this once; the old one stops working), hint (its last four characters) and scim_url, the SCIM 2.0 base URL to give the identity provider with the token as a bearer token. With SCIM on, only people the provider has assigned (active users) can sign in through single sign-on, and one it deactivates or deletes leaves the workspace at once: membership, roles, link and sessions. Owners are never removed that way. Users only; groups are not kept. Needs single sign-on set up and a paid plan. Recorded as sso.scim_enabled or sso.scim_token_rotated. Owners only, as people.", |
| 91 | SsoOp::DisableScim => "Turn off SCIM: the token stops working and the provisioned users are forgotten. Nobody is removed from the workspace, and single sign-on lets people in as before. Recorded as sso.scim_disabled. Owners only, as people.", |
| 92 | SsoOp::UnlinkSsoIdentity => "Forget who someone is at the workspace's provider, so they (or another account there) link again on their next sign-in through it; their sessions go through it again. Recorded as sso.identity_unlinked. Owners only, as people.", |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | /// Whether it changes anything: checked against the scope table in tests. |
| 97 | #[cfg(test)] |
| 98 | pub fn writes(self) -> bool { |
| 99 | !matches!(self, SsoOp::GetSso | SsoOp::ListSsoIdentities) |
| 100 | } |
| 101 | |
| 102 | pub fn input(self) -> Value { |
| 103 | let workspace = json!({ "type": "string", "description": "The workspace's name, e.g. \"acme\"." }); |
| 104 | let (properties, required): (Value, &[&str]) = match self { |
| 105 | SsoOp::GetSso | SsoOp::DeleteSso | SsoOp::ListSsoIdentities | SsoOp::EnableScim | SsoOp::DisableScim => (json!({ "workspace": workspace }), &["workspace"]), |
| 106 | SsoOp::ConfigureSso => ( |
| 107 | json!({ |
| 108 | "workspace": workspace, |
| 109 | "protocol": { "type": "string", "enum": ["oidc", "oauth2", "saml"], "description": "How g1t speaks to the provider." }, |
| 110 | "issuer": { "type": "string", "description": "oidc: the issuer, where its /.well-known/openid-configuration lives." }, |
| 111 | "client_id": { "type": "string", "description": "oidc, oauth2: the client id." }, |
| 112 | "client_secret": { "type": "string", "description": "oidc, oauth2: the client secret; kept encrypted, never returned. Left out keeps the one saved." }, |
| 113 | "scopes": { "type": "string", "description": "oidc, oauth2: space-separated scopes." }, |
| 114 | "authorization_url": { "type": "string", "description": "oauth2: the authorization endpoint (https)." }, |
| 115 | "token_url": { "type": "string", "description": "oauth2: the token endpoint (https)." }, |
| 116 | "userinfo_url": { "type": "string", "description": "oauth2: the userinfo endpoint (https)." }, |
| 117 | "subject_claim": { "type": "string", "description": "oauth2: the userinfo claim that is the person's stable id; a dotted path works." }, |
| 118 | "email_claim": { "type": "string", "description": "oauth2: the userinfo claim that is their address." }, |
| 119 | "idp_entity_id": { "type": "string", "description": "saml: the provider's entity id (its issuer)." }, |
| 120 | "idp_sso_url": { "type": "string", "description": "saml: where the provider takes requests (HTTP-Redirect, https)." }, |
| 121 | "certificate": { "type": "string", "description": "saml: the provider's signing certificates, PEM. Left out keeps the ones saved." }, |
| 122 | "allow_join": { "type": "boolean", "description": "Anyone the provider signs in joins as a member when they first sign in through it. False by default; leave it off for a provider anyone can sign in to." }, |
| 123 | "join_domains": { "type": "array", "items": { "type": "string" }, "description": "When joining is on, only people whose address the provider vouches for (OpenID's email_verified, or a signed SAML assertion) is at one of these domains, such as [\"acme.com\"]. Empty: anyone it signs in." }, |
| 124 | }), |
| 125 | &["workspace", "protocol"], |
| 126 | ), |
| 127 | SsoOp::SetSsoRequired => ( |
| 128 | json!({ |
| 129 | "workspace": workspace, |
| 130 | "required": { "type": "boolean", "description": "True to require it of members; false to stop." }, |
| 131 | }), |
| 132 | &["workspace", "required"], |
| 133 | ), |
| 134 | SsoOp::UnlinkSsoIdentity => ( |
| 135 | json!({ |
| 136 | "workspace": workspace, |
| 137 | "username": { "type": "string", "description": "Whose link to forget." }, |
| 138 | }), |
| 139 | &["workspace", "username"], |
| 140 | ), |
| 141 | }; |
| 142 | json!({ "type": "object", "properties": properties, "required": required }) |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | fn text(input: &Value, key: &str) -> Option<String> { |
| 147 | match &input[key] { |
| 148 | Value::String(text) if !text.trim().is_empty() => Some(text.trim().to_owned()), |
| 149 | _ => None, |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | fn flag(input: &Value, key: &str) -> Option<bool> { |
| 154 | match &input[key] { |
| 155 | Value::Bool(value) => Some(*value), |
| 156 | Value::String(text) => match text.trim() { |
| 157 | "true" | "1" => Some(true), |
| 158 | "false" | "0" => Some(false), |
| 159 | _ => None, |
| 160 | }, |
| 161 | _ => None, |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | /// The protocol's fields as sent: the config, its secret and certificate. |
| 166 | fn config_of(input: &Value) -> SsoConfig { |
| 167 | let mut config = json!({}); |
| 168 | for field in CONFIG_FIELDS { |
| 169 | if let Some(value) = text(input, field) { |
| 170 | config[field] = json!(value); |
| 171 | } |
| 172 | } |
| 173 | // A list, or one comma-separated string. |
| 174 | let domains: Vec<String> = match &input["join_domains"] { |
| 175 | Value::Array(items) => items.iter().filter_map(Value::as_str).map(str::to_owned).collect(), |
| 176 | Value::String(text) => text.split(',').map(str::to_owned).collect(), |
| 177 | _ => Vec::new(), |
| 178 | }; |
| 179 | config["join_domains"] = json!(domains); |
| 180 | serde_json::from_value(config).unwrap_or_default() |
| 181 | } |
| 182 | |
| 183 | /// The settings, with where to point the provider. |
| 184 | fn with_addresses(settings: SsoSettings, site: &str, api: &str) -> Value { |
| 185 | let addresses = sso_addresses(site, &settings.workspace_id, &settings.slug); |
| 186 | let mut out = serde_json::to_value(&settings).unwrap_or_default(); |
| 187 | out["service_provider"] = serde_json::to_value(addresses).unwrap_or_default(); |
| 188 | out["service_provider"]["scim_url"] = json!(scim_url(api, &settings.workspace_id)); |
| 189 | out |
| 190 | } |
| 191 | |
| 192 | /// The SCIM 2.0 base URL for a workspace, by its id, which a rename never |
| 193 | /// changes (scim.rs). |
| 194 | fn scim_url(api: &str, workspace_id: &str) -> String { |
| 195 | format!("{}/scim/v2/{}", api.trim_end_matches('/'), workspace_id) |
| 196 | } |
| 197 | |
| 198 | /// What `configure_sso` was sent, over what is saved for the same protocol: |
| 199 | /// a field left out keeps its value, so changing one thing (a rotated |
| 200 | /// secret) changes nothing else. |
| 201 | fn merged(input: &Value, saved: Option<&SsoConnection>, protocol: SsoProtocol) -> (SsoConfig, bool) { |
| 202 | let mut config = config_of(input); |
| 203 | let same = saved.filter(|saved| saved.protocol == protocol); |
| 204 | if let Some(saved) = same { |
| 205 | let kept = &saved.config; |
| 206 | // Left out, or sent as null (some clients send every parameter). |
| 207 | let absent = |field: &str| input.get(field).is_none_or(Value::is_null); |
| 208 | let keep = |sent: &mut Option<String>, kept: &Option<String>, field: &str| { |
| 209 | if absent(field) { |
| 210 | *sent = kept.clone(); |
| 211 | } |
| 212 | }; |
| 213 | keep(&mut config.issuer, &kept.issuer, "issuer"); |
| 214 | keep(&mut config.client_id, &kept.client_id, "client_id"); |
| 215 | keep(&mut config.scopes, &kept.scopes, "scopes"); |
| 216 | keep(&mut config.authorization_url, &kept.authorization_url, "authorization_url"); |
| 217 | keep(&mut config.token_url, &kept.token_url, "token_url"); |
| 218 | keep(&mut config.userinfo_url, &kept.userinfo_url, "userinfo_url"); |
| 219 | keep(&mut config.subject_claim, &kept.subject_claim, "subject_claim"); |
| 220 | keep(&mut config.email_claim, &kept.email_claim, "email_claim"); |
| 221 | keep(&mut config.idp_entity_id, &kept.idp_entity_id, "idp_entity_id"); |
| 222 | keep(&mut config.idp_sso_url, &kept.idp_sso_url, "idp_sso_url"); |
| 223 | if absent("join_domains") { |
| 224 | config.join_domains = kept.join_domains.clone(); |
| 225 | } |
| 226 | } |
| 227 | let allow_join = flag(input, "allow_join").unwrap_or_else(|| same.is_some_and(|saved| saved.allow_join)); |
| 228 | (config, allow_join) |
| 229 | } |
| 230 | |
| 231 | pub async fn run(op: SsoOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { |
| 232 | let Some(actor) = viewer.clone() else { |
| 233 | return Ok(Outcome::fail(FailureCode::Unauthenticated, "This needs a g1t access token.")); |
| 234 | }; |
| 235 | let Some(workspace) = text(input, "workspace").map(|w| w.to_lowercase()) else { |
| 236 | return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace.")); |
| 237 | }; |
| 238 | let identity = &services.identity; |
| 239 | let surface = services.audit.surface; |
| 240 | let site = services.addresses.site.as_str(); |
| 241 | let api = services.addresses.api.as_str(); |
| 242 | let settings = |outcome: Outcome<SsoSettings>| match outcome { |
| 243 | Outcome::Ok(settings) => Outcome::Ok(with_addresses(settings, site, api)), |
| 244 | Outcome::Fail(failure) => Outcome::Fail(failure), |
| 245 | }; |
| 246 | Ok(match op { |
| 247 | SsoOp::GetSso => settings(g1t_kit::call(identity, "sso_settings", &json!({ "actor": actor, "slug": workspace })).await?), |
| 248 | SsoOp::ListSsoIdentities => { |
| 249 | let found: Outcome<SsoSettings> = g1t_kit::call(identity, "sso_settings", &json!({ "actor": actor, "slug": workspace })).await?; |
| 250 | match found { |
| 251 | Outcome::Ok(found) => Outcome::Ok(serde_json::to_value(found.identities).unwrap_or_default()), |
| 252 | Outcome::Fail(failure) => Outcome::Fail(failure), |
| 253 | } |
| 254 | } |
| 255 | SsoOp::ConfigureSso => { |
| 256 | let Some(protocol) = text(input, "protocol").as_deref().and_then(SsoProtocol::parse) else { |
| 257 | return Ok(Outcome::fail(FailureCode::Invalid, "protocol is oidc, oauth2 or saml.")); |
| 258 | }; |
| 259 | if input.get("allow_join").is_some_and(|v| !v.is_null()) && flag(input, "allow_join").is_none() { |
| 260 | return Ok(Outcome::fail(FailureCode::Invalid, "allow_join is true or false.")); |
| 261 | } |
| 262 | let before: Outcome<SsoSettings> = g1t_kit::call(identity, "sso_settings", &json!({ "actor": actor, "slug": workspace })).await?; |
| 263 | let before = match before { |
| 264 | Outcome::Ok(before) => before, |
| 265 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 266 | }; |
| 267 | let (config, allow_join) = merged(input, before.connection.as_ref(), protocol); |
| 268 | let saved: Outcome<SsoSettings> = g1t_kit::call( |
| 269 | identity, |
| 270 | "save_sso", |
| 271 | &json!({ |
| 272 | "actor": actor, |
| 273 | "slug": workspace, |
| 274 | "protocol": protocol, |
| 275 | "config": config, |
| 276 | "client_secret": text(input, "client_secret"), |
| 277 | "certificate": text(input, "certificate"), |
| 278 | "allow_join": allow_join, |
| 279 | "surface": surface, |
| 280 | }), |
| 281 | ) |
| 282 | .await?; |
| 283 | settings(saved) |
| 284 | } |
| 285 | SsoOp::SetSsoRequired => { |
| 286 | let Some(required) = flag(input, "required") else { |
| 287 | return Ok(Outcome::fail(FailureCode::Invalid, "required is true or false.")); |
| 288 | }; |
| 289 | let set: Outcome<bool> = g1t_kit::call( |
| 290 | identity, |
| 291 | "set_sso_required", |
| 292 | &json!({ "actor": actor, "slug": workspace, "required": required, "surface": surface }), |
| 293 | ) |
| 294 | .await?; |
| 295 | match set { |
| 296 | Outcome::Ok(required) => Outcome::Ok(json!({ "required": required })), |
| 297 | Outcome::Fail(failure) => Outcome::Fail(failure), |
| 298 | } |
| 299 | } |
| 300 | SsoOp::DeleteSso => { |
| 301 | let removed: Outcome<bool> = |
| 302 | g1t_kit::call(identity, "delete_sso", &json!({ "actor": actor, "slug": workspace, "surface": surface })).await?; |
| 303 | match removed { |
| 304 | Outcome::Ok(removed) => Outcome::Ok(json!({ "deleted": removed })), |
| 305 | Outcome::Fail(failure) => Outcome::Fail(failure), |
| 306 | } |
| 307 | } |
| 308 | SsoOp::EnableScim => { |
| 309 | let made: Outcome<ScimToken> = |
| 310 | g1t_kit::call(identity, "enable_scim", &json!({ "actor": actor, "slug": workspace, "surface": surface })).await?; |
| 311 | match made { |
| 312 | Outcome::Ok(made) => Outcome::Ok(json!({ "token": made.token, "hint": made.hint, "scim_url": scim_url(api, &made.workspace_id) })), |
| 313 | Outcome::Fail(failure) => Outcome::Fail(failure), |
| 314 | } |
| 315 | } |
| 316 | SsoOp::DisableScim => { |
| 317 | let off: Outcome<bool> = |
| 318 | g1t_kit::call(identity, "disable_scim", &json!({ "actor": actor, "slug": workspace, "surface": surface })).await?; |
| 319 | match off { |
| 320 | Outcome::Ok(off) => Outcome::Ok(json!({ "disabled": off })), |
| 321 | Outcome::Fail(failure) => Outcome::Fail(failure), |
| 322 | } |
| 323 | } |
| 324 | SsoOp::UnlinkSsoIdentity => { |
| 325 | let Some(username) = text(input, "username") else { |
| 326 | return Ok(Outcome::fail(FailureCode::Invalid, "Name the person to unlink.")); |
| 327 | }; |
| 328 | let unlinked: Outcome<bool> = g1t_kit::call( |
| 329 | identity, |
| 330 | "unlink_sso_identity", |
| 331 | &json!({ "actor": actor, "slug": workspace, "username": username, "surface": surface }), |
| 332 | ) |
| 333 | .await?; |
| 334 | match unlinked { |
| 335 | Outcome::Ok(unlinked) => Outcome::Ok(json!({ "unlinked": unlinked })), |
| 336 | Outcome::Fail(failure) => Outcome::Fail(failure), |
| 337 | } |
| 338 | } |
| 339 | }) |
| 340 | } |
| 341 | |
| 342 | #[cfg(test)] |
| 343 | mod tests { |
| 344 | use super::*; |
| 345 | use g1t_contracts::scopes::{Level, scope_for}; |
| 346 | |
| 347 | #[test] |
| 348 | fn each_operation_is_described_and_scoped() { |
| 349 | for op in SsoOp::ALL { |
| 350 | assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Sso(op)), "{}", op.name()); |
| 351 | assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name()); |
| 352 | assert!(op.input()["required"].as_array().unwrap().contains(&json!("workspace")), "{}", op.name()); |
| 353 | let level = scope_for(op.name()).unwrap().level(); |
| 354 | assert_eq!(level == Level::Read, !op.writes(), "{}", op.name()); |
| 355 | } |
| 356 | } |
| 357 | |
| 358 | #[test] |
| 359 | fn only_the_protocols_fields_are_sent() { |
| 360 | let config = config_of(&json!({ "issuer": " https://acme.okta.com ", "client_id": "c", "client_secret": "s", "certificate": "pem", "nonsense": "x", "scopes": "" })); |
| 361 | assert_eq!(config.issuer.as_deref(), Some("https://acme.okta.com")); |
| 362 | assert_eq!(config.client_id.as_deref(), Some("c")); |
| 363 | assert_eq!(config.scopes, None); |
| 364 | assert!(config.certificates.is_empty()); |
| 365 | } |
| 366 | |
| 367 | #[test] |
| 368 | fn a_field_left_out_keeps_what_is_saved() { |
| 369 | let saved = SsoConnection { |
| 370 | id: "sso_1".into(), |
| 371 | protocol: SsoProtocol::Oidc, |
| 372 | config: SsoConfig { issuer: Some("https://acme.okta.com".into()), client_id: Some("c".into()), join_domains: vec!["acme.com".into()], ..SsoConfig::default() }, |
| 373 | has_client_secret: true, |
| 374 | certificates: vec![], |
| 375 | allow_join: true, |
| 376 | tested_at: None, |
| 377 | tested_by: None, |
| 378 | created_at: String::new(), |
| 379 | updated_at: String::new(), |
| 380 | }; |
| 381 | // Rotating the secret alone changes nothing else. |
| 382 | let (config, allow_join) = merged(&json!({ "protocol": "oidc", "client_secret": "new" }), Some(&saved), SsoProtocol::Oidc); |
| 383 | assert_eq!(config.issuer.as_deref(), Some("https://acme.okta.com")); |
| 384 | let (nulls, _) = merged(&json!({ "protocol": "oidc", "issuer": null, "join_domains": null }), Some(&saved), SsoProtocol::Oidc); |
| 385 | assert_eq!(nulls.issuer.as_deref(), Some("https://acme.okta.com")); |
| 386 | assert_eq!(nulls.join_domains, vec!["acme.com".to_owned()]); |
| 387 | assert_eq!(config.join_domains, vec!["acme.com".to_owned()]); |
| 388 | assert!(allow_join); |
| 389 | // Sent empty is changed; another protocol starts afresh. |
| 390 | let (config, allow_join) = merged(&json!({ "protocol": "oidc", "join_domains": [], "allow_join": false }), Some(&saved), SsoProtocol::Oidc); |
| 391 | assert!(config.join_domains.is_empty()); |
| 392 | assert!(!allow_join); |
| 393 | let (config, allow_join) = merged(&json!({ "protocol": "saml" }), Some(&saved), SsoProtocol::Saml); |
| 394 | assert_eq!(config.issuer, None); |
| 395 | assert!(!allow_join); |
| 396 | } |
| 397 | |
| 398 | #[test] |
| 399 | fn the_answer_says_where_to_point_the_provider() { |
| 400 | let settings = SsoSettings { slug: "acme".into(), workspace_id: "wsp_1".into(), connection: None, required: false, you_linked: false, identities: vec![], scim: None }; |
| 401 | let out = with_addresses(settings, "https://g1t.sh", "https://api.g1t.sh"); |
| 402 | assert_eq!(out["service_provider"]["scim_url"], "https://api.g1t.sh/scim/v2/wsp_1"); |
| 403 | assert_eq!(out["service_provider"]["acs_url"], "https://g1t.sh/auth/sso/wsp_1/saml"); |
| 404 | assert_eq!(out["service_provider"]["redirect_uri"], "https://g1t.sh/auth/sso/wsp_1/callback"); |
| 405 | assert_eq!(out["service_provider"]["sign_in_url"], "https://g1t.sh/auth/sso/acme"); |
| 406 | assert_eq!(out["connection"], Value::Null); |
| 407 | } |
| 408 | } |