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.

408 linesCodeBlameRaw
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
8use g1t_contracts::sso::{ScimToken, SsoConfig, SsoConnection, SsoProtocol, SsoSettings, sso_addresses};
9use g1t_contracts::{FailureCode, Outcome, Viewer};
10use serde_json::{Value, json};
11use worker::Result;
12
13use crate::operations::Services;
14
15/// One operation.
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub 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.
29const 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
42impl 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
146fn 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
153fn 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.
166fn 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.
184fn 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).
194fn 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.
201fn 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
231pub 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)]
343mod 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}