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.

294 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::{SsoConfig, 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}
25
26/// The config fields an owner sends, as `SsoConfig` names them.
27const CONFIG_FIELDS: [&str; 10] = [
28 "issuer",
29 "client_id",
30 "scopes",
31 "authorization_url",
32 "token_url",
33 "userinfo_url",
34 "subject_claim",
35 "email_claim",
36 "idp_entity_id",
37 "idp_sso_url",
38];
39
40impl SsoOp {
41 /// Every one: `Op::ALL` lists each as `Op::Sso(…)`, which a test checks
42 /// against this.
43 #[cfg(test)]
44 pub const ALL: [SsoOp; 6] = [
45 SsoOp::GetSso,
46 SsoOp::ConfigureSso,
47 SsoOp::SetSsoRequired,
48 SsoOp::DeleteSso,
49 SsoOp::ListSsoIdentities,
50 SsoOp::UnlinkSsoIdentity,
51 ];
52
53 pub fn name(self) -> &'static str {
54 match self {
55 SsoOp::GetSso => "get_sso",
56 SsoOp::ConfigureSso => "configure_sso",
57 SsoOp::SetSsoRequired => "set_sso_required",
58 SsoOp::DeleteSso => "delete_sso",
59 SsoOp::ListSsoIdentities => "list_sso_identities",
60 SsoOp::UnlinkSsoIdentity => "unlink_sso_identity",
61 }
62 }
63
64 pub fn title(self) -> &'static str {
65 match self {
66 SsoOp::GetSso => "Get a workspace's single sign-on",
67 SsoOp::ConfigureSso => "Set up or change a workspace's single sign-on",
68 SsoOp::SetSsoRequired => "Require single sign-on, or stop",
69 SsoOp::DeleteSso => "Remove a workspace's single sign-on",
70 SsoOp::ListSsoIdentities => "List the people linked through single sign-on",
71 SsoOp::UnlinkSsoIdentity => "Unlink someone from single sign-on",
72 }
73 }
74
75 pub fn description(self) -> &'static str {
76 match self {
77 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.",
78 SsoOp::ConfigureSso => "Set up the workspace's single sign-on, or change it, with every field of the 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 (true by default) lets anyone the provider signs in join as a member. 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.",
79 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.",
80 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.",
81 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.",
82 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.",
83 }
84 }
85
86 /// Whether it changes anything: checked against the scope table in tests.
87 #[cfg(test)]
88 pub fn writes(self) -> bool {
89 !matches!(self, SsoOp::GetSso | SsoOp::ListSsoIdentities)
90 }
91
92 pub fn input(self) -> Value {
93 let workspace = json!({ "type": "string", "description": "The workspace's name, e.g. \"acme\"." });
94 let (properties, required): (Value, &[&str]) = match self {
95 SsoOp::GetSso | SsoOp::DeleteSso | SsoOp::ListSsoIdentities => (json!({ "workspace": workspace }), &["workspace"]),
96 SsoOp::ConfigureSso => (
97 json!({
98 "workspace": workspace,
99 "protocol": { "type": "string", "enum": ["oidc", "oauth2", "saml"], "description": "How g1t speaks to the provider." },
100 "issuer": { "type": "string", "description": "oidc: the issuer, where its /.well-known/openid-configuration lives." },
101 "client_id": { "type": "string", "description": "oidc, oauth2: the client id." },
102 "client_secret": { "type": "string", "description": "oidc, oauth2: the client secret; kept encrypted, never returned. Left out keeps the one saved." },
103 "scopes": { "type": "string", "description": "oidc, oauth2: space-separated scopes." },
104 "authorization_url": { "type": "string", "description": "oauth2: the authorization endpoint (https)." },
105 "token_url": { "type": "string", "description": "oauth2: the token endpoint (https)." },
106 "userinfo_url": { "type": "string", "description": "oauth2: the userinfo endpoint (https)." },
107 "subject_claim": { "type": "string", "description": "oauth2: the userinfo claim that is the person's stable id; a dotted path works." },
108 "email_claim": { "type": "string", "description": "oauth2: the userinfo claim that is their address." },
109 "idp_entity_id": { "type": "string", "description": "saml: the provider's entity id (its issuer)." },
110 "idp_sso_url": { "type": "string", "description": "saml: where the provider takes requests (HTTP-Redirect, https)." },
111 "certificate": { "type": "string", "description": "saml: the provider's signing certificates, PEM. Left out keeps the ones saved." },
112 "allow_join": { "type": "boolean", "description": "Anyone the provider signs in joins as a member. True by default." },
113 }),
114 &["workspace", "protocol"],
115 ),
116 SsoOp::SetSsoRequired => (
117 json!({
118 "workspace": workspace,
119 "required": { "type": "boolean", "description": "True to require it of members; false to stop." },
120 }),
121 &["workspace", "required"],
122 ),
123 SsoOp::UnlinkSsoIdentity => (
124 json!({
125 "workspace": workspace,
126 "username": { "type": "string", "description": "Whose link to forget." },
127 }),
128 &["workspace", "username"],
129 ),
130 };
131 json!({ "type": "object", "properties": properties, "required": required })
132 }
133}
134
135fn text(input: &Value, key: &str) -> Option<String> {
136 match &input[key] {
137 Value::String(text) if !text.trim().is_empty() => Some(text.trim().to_owned()),
138 _ => None,
139 }
140}
141
142fn flag(input: &Value, key: &str) -> Option<bool> {
143 match &input[key] {
144 Value::Bool(value) => Some(*value),
145 Value::String(text) => match text.trim() {
146 "true" | "1" => Some(true),
147 "false" | "0" => Some(false),
148 _ => None,
149 },
150 _ => None,
151 }
152}
153
154/// The protocol's fields as sent: the config, its secret and certificate.
155fn config_of(input: &Value) -> SsoConfig {
156 let mut config = json!({});
157 for field in CONFIG_FIELDS {
158 if let Some(value) = text(input, field) {
159 config[field] = json!(value);
160 }
161 }
162 serde_json::from_value(config).unwrap_or_default()
163}
164
165/// The settings, with where to point the provider.
166fn with_addresses(settings: SsoSettings, site: &str) -> Value {
167 let addresses = sso_addresses(site, &settings.slug);
168 let mut out = serde_json::to_value(&settings).unwrap_or_default();
169 out["service_provider"] = serde_json::to_value(addresses).unwrap_or_default();
170 out
171}
172
173pub async fn run(op: SsoOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
174 let Some(actor) = viewer.clone() else {
175 return Ok(Outcome::fail(FailureCode::Unauthenticated, "This needs a g1t access token."));
176 };
177 let Some(workspace) = text(input, "workspace").map(|w| w.to_lowercase()) else {
178 return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace."));
179 };
180 let identity = &services.identity;
181 let surface = services.audit.surface;
182 let site = services.addresses.site.as_str();
183 let settings = |outcome: Outcome<SsoSettings>| match outcome {
184 Outcome::Ok(settings) => Outcome::Ok(with_addresses(settings, site)),
185 Outcome::Fail(failure) => Outcome::Fail(failure),
186 };
187 Ok(match op {
188 SsoOp::GetSso => settings(g1t_kit::call(identity, "sso_settings", &json!({ "actor": actor, "slug": workspace })).await?),
189 SsoOp::ListSsoIdentities => {
190 let found: Outcome<SsoSettings> = g1t_kit::call(identity, "sso_settings", &json!({ "actor": actor, "slug": workspace })).await?;
191 match found {
192 Outcome::Ok(found) => Outcome::Ok(serde_json::to_value(found.identities).unwrap_or_default()),
193 Outcome::Fail(failure) => Outcome::Fail(failure),
194 }
195 }
196 SsoOp::ConfigureSso => {
197 let Some(protocol) = text(input, "protocol").as_deref().and_then(SsoProtocol::parse) else {
198 return Ok(Outcome::fail(FailureCode::Invalid, "protocol is oidc, oauth2 or saml."));
199 };
200 if input.get("allow_join").is_some_and(|v| !v.is_null()) && flag(input, "allow_join").is_none() {
201 return Ok(Outcome::fail(FailureCode::Invalid, "allow_join is true or false."));
202 }
203 let saved: Outcome<SsoSettings> = g1t_kit::call(
204 identity,
205 "save_sso",
206 &json!({
207 "actor": actor,
208 "slug": workspace,
209 "protocol": protocol,
210 "config": config_of(input),
211 "client_secret": text(input, "client_secret"),
212 "certificate": text(input, "certificate"),
213 "allow_join": flag(input, "allow_join").unwrap_or(true),
214 "surface": surface,
215 }),
216 )
217 .await?;
218 settings(saved)
219 }
220 SsoOp::SetSsoRequired => {
221 let Some(required) = flag(input, "required") else {
222 return Ok(Outcome::fail(FailureCode::Invalid, "required is true or false."));
223 };
224 let set: Outcome<bool> = g1t_kit::call(
225 identity,
226 "set_sso_required",
227 &json!({ "actor": actor, "slug": workspace, "required": required, "surface": surface }),
228 )
229 .await?;
230 match set {
231 Outcome::Ok(required) => Outcome::Ok(json!({ "required": required })),
232 Outcome::Fail(failure) => Outcome::Fail(failure),
233 }
234 }
235 SsoOp::DeleteSso => {
236 let removed: Outcome<bool> =
237 g1t_kit::call(identity, "delete_sso", &json!({ "actor": actor, "slug": workspace, "surface": surface })).await?;
238 match removed {
239 Outcome::Ok(removed) => Outcome::Ok(json!({ "deleted": removed })),
240 Outcome::Fail(failure) => Outcome::Fail(failure),
241 }
242 }
243 SsoOp::UnlinkSsoIdentity => {
244 let Some(username) = text(input, "username") else {
245 return Ok(Outcome::fail(FailureCode::Invalid, "Name the person to unlink."));
246 };
247 let unlinked: Outcome<bool> = g1t_kit::call(
248 identity,
249 "unlink_sso_identity",
250 &json!({ "actor": actor, "slug": workspace, "username": username, "surface": surface }),
251 )
252 .await?;
253 match unlinked {
254 Outcome::Ok(unlinked) => Outcome::Ok(json!({ "unlinked": unlinked })),
255 Outcome::Fail(failure) => Outcome::Fail(failure),
256 }
257 }
258 })
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use g1t_contracts::scopes::{Level, scope_for};
265
266 #[test]
267 fn each_operation_is_described_and_scoped() {
268 for op in SsoOp::ALL {
269 assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Sso(op)), "{}", op.name());
270 assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name());
271 assert!(op.input()["required"].as_array().unwrap().contains(&json!("workspace")), "{}", op.name());
272 let level = scope_for(op.name()).unwrap().level();
273 assert_eq!(level == Level::Read, !op.writes(), "{}", op.name());
274 }
275 }
276
277 #[test]
278 fn only_the_protocols_fields_are_sent() {
279 let config = config_of(&json!({ "issuer": " https://acme.okta.com ", "client_id": "c", "client_secret": "s", "certificate": "pem", "nonsense": "x", "scopes": "" }));
280 assert_eq!(config.issuer.as_deref(), Some("https://acme.okta.com"));
281 assert_eq!(config.client_id.as_deref(), Some("c"));
282 assert_eq!(config.scopes, None);
283 assert!(config.certificates.is_empty());
284 }
285
286 #[test]
287 fn the_answer_says_where_to_point_the_provider() {
288 let settings = SsoSettings { slug: "acme".into(), connection: None, required: false, you_linked: false, identities: vec![] };
289 let out = with_addresses(settings, "https://g1t.sh");
290 assert_eq!(out["service_provider"]["acs_url"], "https://g1t.sh/auth/sso/acme/saml");
291 assert_eq!(out["service_provider"]["redirect_uri"], "https://g1t.sh/auth/sso/acme/callback");
292 assert_eq!(out["connection"], Value::Null);
293 }
294}