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.

396 linesCodeBlameRaw
1//! Single sign-on: a workspace's own identity provider, over OpenID
2//! Connect, OAuth 2.0 or SAML 2.0. On every paid plan; a free workspace
3//! sees it and cannot set it up. Mirrors `packages/contracts/src/sso.ts`.
4//!
5//! It never signs anyone in to g1t. A person signs in to g1t as
6//! themselves, then proves themselves to the workspace's provider to use
7//! the workspace, the first time linking who they are there to their g1t
8//! account. A workspace can require it of its members: then a member uses
9//! it only from a session that went through its provider in the last
10//! day, and an access token of theirs only once they have linked. Owners
11//! are never held out, so a provider that breaks never locks a workspace
12//! out of itself.
13//!
14//! The identity service (`POST /rpc/<method>`):
15//!
16//! - `sso_settings` takes `SsoSettingsArgs`, returns `Outcome<SsoSettings>`.
17//! - `save_sso` takes `SaveSsoArgs`, returns `Outcome<SsoSettings>`.
18//! - `set_sso_required` takes `SetSsoRequiredArgs`, returns `Outcome<bool>`.
19//! - `delete_sso` takes `SsoSettingsArgs`, returns `Outcome<bool>`.
20//! - `unlink_sso_identity` takes `UnlinkSsoIdentityArgs`, returns `Outcome<bool>`.
21//! - `sso_start` takes `SsoStartArgs`, returns `Outcome<SsoStart>`.
22//! - `sso_finish` takes `SsoFinishArgs`, returns `Outcome<SsoFinished>`.
23//! - `sso_saml` takes `SsoSamlArgs`, returns `Outcome<SsoFinished>`.
24//! - `sso_summary` takes `SlugArgs`, returns `SsoSummary`.
25//! - `sso_metadata` takes `SsoMetadataArgs`, returns the XML as a string.
26//! - `enable_scim` takes `SsoSettingsArgs`, returns `Outcome<ScimToken>`.
27//! - `disable_scim` takes `SsoSettingsArgs`, returns `Outcome<bool>`.
28//! - `scim_request` takes `ScimRequestArgs`, returns `ScimAnswer`.
29//!
30//! **SCIM.** With a SCIM token, the provider tells g1t who is assigned to
31//! the workspace (`/scim/v2/{workspace}/Users` on the API). Then only an
32//! active provisioned user may sign in through single sign-on, and one
33//! deactivated or deleted leaves the workspace at once: their membership,
34//! roles, link and sessions. Owners are never removed this way.
35
36use serde::{Deserialize, Serialize};
37
38use crate::User;
39use crate::audit::Surface;
40
41/// How a workspace's provider is spoken to.
42#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum SsoProtocol {
45 /// OpenID Connect: an issuer whose discovery document says the rest.
46 Oidc,
47 /// Plain OAuth 2.0: authorize, token and userinfo endpoints, and which
48 /// claims of the userinfo answer are the person's id and address.
49 Oauth2,
50 /// SAML 2.0: the provider's entity id, sign-in URL and certificates.
51 Saml,
52}
53
54impl SsoProtocol {
55 pub fn as_str(self) -> &'static str {
56 match self {
57 SsoProtocol::Oidc => "oidc",
58 SsoProtocol::Oauth2 => "oauth2",
59 SsoProtocol::Saml => "saml",
60 }
61 }
62
63 pub fn parse(text: &str) -> Option<SsoProtocol> {
64 match text {
65 "oidc" => Some(SsoProtocol::Oidc),
66 "oauth2" => Some(SsoProtocol::Oauth2),
67 "saml" => Some(SsoProtocol::Saml),
68 _ => None,
69 }
70 }
71}
72
73/// What a connection needs, by protocol; the fields of the others are
74/// absent. Stored as JSON, and shown to owners as it is: it holds no
75/// secret.
76#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
77pub struct SsoConfig {
78 /// OpenID Connect: the issuer, such as `https://acme.okta.com`.
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub issuer: Option<String>,
81 /// OpenID Connect and OAuth 2.0.
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub client_id: Option<String>,
84 /// Space-separated. OpenID Connect always asks for `openid`.
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub scopes: Option<String>,
87 /// OAuth 2.0.
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub authorization_url: Option<String>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub token_url: Option<String>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub userinfo_url: Option<String>,
94 /// The userinfo claim that is the person's stable id; `sub` when
95 /// absent, then `id`.
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub subject_claim: Option<String>,
98 /// The userinfo claim that is their address; `email` when absent.
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub email_claim: Option<String>,
101 /// SAML: the provider's entity id (its issuer).
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub idp_entity_id: Option<String>,
104 /// SAML: where its single sign-on service takes requests.
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub idp_sso_url: Option<String>,
107 /// SAML: its signing certificates, base64 DER; more than one while a
108 /// certificate is rotated.
109 #[serde(default, skip_serializing_if = "Vec::is_empty")]
110 pub certificates: Vec<String>,
111}
112
113/// A SAML signing certificate, as shown.
114#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
115pub struct SsoCertificate {
116 /// SHA-256, `AB:CD:…`, as providers show it.
117 pub fingerprint: String,
118}
119
120/// A workspace's connection to its provider, as its owners see it.
121#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
122pub struct SsoConnection {
123 pub id: String,
124 pub protocol: SsoProtocol,
125 pub config: SsoConfig,
126 /// Whether a client secret is kept; it is never shown.
127 pub has_client_secret: bool,
128 pub certificates: Vec<SsoCertificate>,
129 /// Whether anyone the provider signs in may join as a member.
130 pub allow_join: bool,
131 /// When someone first signed in through it, and who (a username).
132 pub tested_at: Option<String>,
133 pub tested_by: Option<String>,
134 pub created_at: String,
135 pub updated_at: String,
136}
137
138/// Someone linked to who they are at the workspace's provider.
139#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
140pub struct SsoIdentity {
141 pub username: String,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub display_username: Option<String>,
144 /// The provider's id for them.
145 pub subject: String,
146 /// The address the provider gave, for showing.
147 pub email: Option<String>,
148 pub linked_at: String,
149 pub last_used_at: String,
150}
151
152/// A workspace's single sign-on, for its settings page.
153#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
154pub struct SsoSettings {
155 pub slug: String,
156 pub connection: Option<SsoConnection>,
157 /// Whether members must sign in through it.
158 pub required: bool,
159 /// Whether the person asking has signed in through it: an owner must
160 /// before requiring it.
161 pub you_linked: bool,
162 pub identities: Vec<SsoIdentity>,
163 /// SCIM, when it is on: the provider says who is assigned.
164 #[serde(default)]
165 pub scim: Option<ScimStatus>,
166}
167
168/// A workspace's SCIM, as its owners see it.
169#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
170pub struct ScimStatus {
171 /// The token's last four characters.
172 pub hint: String,
173 pub created_at: String,
174 /// The username of whoever made the token.
175 pub created_by: Option<String>,
176 pub last_used_at: Option<String>,
177 /// Who the provider has told g1t of, at most 500.
178 pub users: Vec<ProvisionedUser>,
179}
180
181/// Someone the provider assigned to the workspace.
182#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
183pub struct ProvisionedUser {
184 pub id: String,
185 pub user_name: String,
186 pub display_name: Option<String>,
187 pub active: bool,
188 /// The g1t account they signed in as, once they have.
189 pub username: Option<String>,
190 pub updated_at: String,
191}
192
193/// `enable_scim`: a new SCIM token for the workspace, replacing any other.
194/// Shown once.
195#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
196pub struct ScimToken {
197 pub token: String,
198 pub hint: String,
199}
200
201/// `scim_request`: one SCIM request, as the API received it at
202/// `/scim/v2/{workspace}/…`. Returns `ScimAnswer`.
203#[derive(Debug, Serialize, Deserialize)]
204pub struct ScimRequestArgs {
205 pub workspace: String,
206 /// The bearer token sent.
207 pub token: String,
208 pub method: String,
209 /// After the workspace, such as `/Users/scu_…`.
210 pub path: String,
211 #[serde(default)]
212 pub query: Option<String>,
213 #[serde(default)]
214 pub body: Option<String>,
215 /// `https://api.g1t.sh/scim/v2/{workspace}`, for resources' locations.
216 pub base: String,
217}
218
219/// What to answer a SCIM request with.
220#[derive(Debug, Serialize, Deserialize)]
221pub struct ScimAnswer {
222 pub status: u16,
223 pub body: Option<serde_json::Value>,
224}
225
226/// Whether a workspace has single sign-on, for anyone: the sign-in page
227/// and the notice for someone it holds out.
228#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
229pub struct SsoSummary {
230 pub enabled: bool,
231 pub protocol: Option<SsoProtocol>,
232 pub required: bool,
233 pub allow_join: bool,
234}
235
236#[derive(Debug, Serialize, Deserialize)]
237pub struct SsoSettingsArgs {
238 pub actor: User,
239 pub slug: String,
240 #[serde(default)]
241 pub surface: Option<Surface>,
242}
243
244/// `save_sso`: sets up or changes the workspace's connection. Owners only,
245/// on a paid plan. A secret or certificate left empty keeps the one saved.
246/// Changing which provider it is (protocol, issuer, entity id) unlinks
247/// everyone, stops requiring it, and needs a sign-in through it again.
248#[derive(Debug, Serialize, Deserialize)]
249pub struct SaveSsoArgs {
250 pub actor: User,
251 pub slug: String,
252 pub protocol: SsoProtocol,
253 #[serde(default)]
254 pub config: SsoConfig,
255 #[serde(default)]
256 pub client_secret: Option<String>,
257 /// SAML: certificates as pasted (PEM).
258 #[serde(default)]
259 pub certificate: Option<String>,
260 #[serde(default = "yes")]
261 pub allow_join: bool,
262 #[serde(default)]
263 pub surface: Option<Surface>,
264}
265
266fn yes() -> bool {
267 true
268}
269
270/// `set_sso_required`: whether members must sign in through the provider.
271/// Turning it on needs a paid plan and the owner to have signed in through
272/// it themselves; turning it off never does.
273#[derive(Debug, Serialize, Deserialize)]
274pub struct SetSsoRequiredArgs {
275 pub actor: User,
276 pub slug: String,
277 pub required: bool,
278 #[serde(default)]
279 pub surface: Option<Surface>,
280}
281
282/// `unlink_sso_identity`: forgets who someone is at the provider, so they
283/// link again (or another person can) the next time. Owners only.
284#[derive(Debug, Serialize, Deserialize)]
285pub struct UnlinkSsoIdentityArgs {
286 pub actor: User,
287 pub slug: String,
288 pub username: String,
289 #[serde(default)]
290 pub surface: Option<Surface>,
291}
292
293/// `sso_start`: the signed-in person sets off to the workspace's provider.
294#[derive(Debug, Serialize, Deserialize)]
295pub struct SsoStartArgs {
296 pub user: User,
297 /// The session it is for: what comes back counts for it alone.
298 pub session_token: String,
299 pub slug: String,
300 /// The callback (OpenID Connect, OAuth 2.0) or assertion consumer
301 /// service (SAML), exactly as registered with the provider.
302 pub redirect_uri: String,
303 /// SAML: g1t's entity id for the workspace.
304 #[serde(default)]
305 pub entity_id: Option<String>,
306 /// A same-site path to return to.
307 #[serde(default)]
308 pub next: String,
309}
310
311#[derive(Debug, Serialize, Deserialize)]
312pub struct SsoStart {
313 /// Where to send the browser.
314 pub url: String,
315 /// OpenID Connect and OAuth 2.0: the state, for a cookie that binds
316 /// the trip to this browser. Absent for SAML, whose response is posted
317 /// cross-site, without cookies; its request id binds it instead.
318 pub state: Option<String>,
319}
320
321/// `sso_finish`: the provider sent the browser back with a code.
322#[derive(Debug, Serialize, Deserialize)]
323pub struct SsoFinishArgs {
324 pub slug: String,
325 pub session_token: String,
326 pub state: String,
327 pub code: String,
328}
329
330/// `sso_saml`: the provider posted its response.
331#[derive(Debug, Serialize, Deserialize)]
332pub struct SsoSamlArgs {
333 pub slug: String,
334 pub saml_response: String,
335}
336
337#[derive(Debug, Serialize, Deserialize)]
338pub struct SsoFinished {
339 pub slug: String,
340 pub next: String,
341 /// Whether they joined the workspace just now.
342 pub joined: bool,
343 /// Whether this was the first sign-in through the connection, which
344 /// marks it tested.
345 pub first: bool,
346}
347
348/// `sso_metadata`: g1t's SAML service provider metadata for a workspace.
349#[derive(Debug, Serialize, Deserialize)]
350pub struct SsoMetadataArgs {
351 pub entity_id: String,
352 pub acs_url: String,
353}
354
355/// Where a workspace's provider is pointed, from the site's address: g1t's
356/// entity id (its metadata's address), the assertion consumer service, and
357/// the callback for OpenID Connect and OAuth 2.0. Mirrors `ssoAddresses`
358/// in `packages/contracts/src/sso.ts`.
359#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
360pub struct SsoAddresses {
361 pub redirect_uri: String,
362 pub entity_id: String,
363 pub acs_url: String,
364 pub metadata_url: String,
365 /// Where people start signing in through it.
366 pub sign_in_url: String,
367}
368
369pub fn sso_addresses(site: &str, slug: &str) -> SsoAddresses {
370 let start = format!("{}/auth/sso/{}", site.trim_end_matches('/'), slug.trim().to_lowercase());
371 SsoAddresses {
372 redirect_uri: format!("{start}/callback"),
373 entity_id: format!("{start}/metadata"),
374 acs_url: format!("{start}/saml"),
375 metadata_url: format!("{start}/metadata"),
376 sign_in_url: start,
377 }
378}
379
380/// `admin_sso`: a workspace's single sign-on, for staff in sudo: as its
381/// owners see it, or `None` when it has none. Takes `SlugArgs`.
382pub type AdminSso = Option<SsoSettings>;
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 #[test]
389 fn addresses_are_the_workspaces_own() {
390 let at = sso_addresses("https://g1t.sh/", " Acme ");
391 assert_eq!(at.redirect_uri, "https://g1t.sh/auth/sso/acme/callback");
392 assert_eq!(at.entity_id, "https://g1t.sh/auth/sso/acme/metadata");
393 assert_eq!(at.acs_url, "https://g1t.sh/auth/sso/acme/saml");
394 assert_eq!(at.sign_in_url, "https://g1t.sh/auth/sso/acme");
395 }
396}