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.

422 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 /// Who may join through it, when it lets people join: only those whose
112 /// address the provider vouches for is at one of these domains. Empty:
113 /// anyone it signs in.
114 #[serde(default, skip_serializing_if = "Vec::is_empty")]
115 pub join_domains: Vec<String>,
116}
117
118/// A SAML signing certificate, as shown.
119#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
120pub struct SsoCertificate {
121 /// SHA-256, `AB:CD:…`, as providers show it.
122 pub fingerprint: String,
123}
124
125/// A workspace's connection to its provider, as its owners see it.
126#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
127pub struct SsoConnection {
128 pub id: String,
129 pub protocol: SsoProtocol,
130 pub config: SsoConfig,
131 /// Whether a client secret is kept; it is never shown.
132 pub has_client_secret: bool,
133 pub certificates: Vec<SsoCertificate>,
134 /// Whether anyone the provider signs in may join as a member.
135 pub allow_join: bool,
136 /// When someone first signed in through it, and who (a username).
137 pub tested_at: Option<String>,
138 pub tested_by: Option<String>,
139 pub created_at: String,
140 pub updated_at: String,
141}
142
143/// Someone linked to who they are at the workspace's provider.
144#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
145pub struct SsoIdentity {
146 pub username: String,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub display_username: Option<String>,
149 /// The provider's id for them.
150 pub subject: String,
151 /// The address the provider gave, for showing.
152 pub email: Option<String>,
153 pub linked_at: String,
154 pub last_used_at: String,
155}
156
157/// A workspace's single sign-on, for its settings page.
158#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
159pub struct SsoSettings {
160 pub slug: String,
161 /// The workspace's id, which never changes: what the provider is
162 /// pointed at (`sso_addresses`), so renaming the workspace breaks
163 /// nothing.
164 #[serde(default)]
165 pub workspace_id: String,
166 pub connection: Option<SsoConnection>,
167 /// Whether members must sign in through it.
168 pub required: bool,
169 /// Whether the person asking has signed in through it: an owner must
170 /// before requiring it.
171 pub you_linked: bool,
172 pub identities: Vec<SsoIdentity>,
173 /// SCIM, when it is on: the provider says who is assigned.
174 #[serde(default)]
175 pub scim: Option<ScimStatus>,
176}
177
178/// A workspace's SCIM, as its owners see it.
179#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
180pub struct ScimStatus {
181 /// The token's last four characters.
182 pub hint: String,
183 pub created_at: String,
184 /// The username of whoever made the token.
185 pub created_by: Option<String>,
186 pub last_used_at: Option<String>,
187 /// Who the provider has told g1t of, at most 500.
188 pub users: Vec<ProvisionedUser>,
189}
190
191/// Someone the provider assigned to the workspace.
192#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
193pub struct ProvisionedUser {
194 pub id: String,
195 pub user_name: String,
196 pub display_name: Option<String>,
197 pub active: bool,
198 /// The g1t account they signed in as, once they have.
199 pub username: Option<String>,
200 pub updated_at: String,
201}
202
203/// `enable_scim`: a new SCIM token for the workspace, replacing any other.
204/// Shown once.
205#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
206pub struct ScimToken {
207 pub token: String,
208 pub hint: String,
209 /// What the SCIM URL names the workspace by.
210 #[serde(default)]
211 pub workspace_id: String,
212}
213
214/// `scim_request`: one SCIM request, as the API received it at
215/// `/scim/v2/{workspace}/…`. Returns `ScimAnswer`.
216#[derive(Debug, Serialize, Deserialize)]
217pub struct ScimRequestArgs {
218 pub workspace: String,
219 /// The bearer token sent.
220 pub token: String,
221 pub method: String,
222 /// After the workspace, such as `/Users/scu_…`.
223 pub path: String,
224 #[serde(default)]
225 pub query: Option<String>,
226 #[serde(default)]
227 pub body: Option<String>,
228 /// `https://api.g1t.sh/scim/v2/{workspace}`, for resources' locations.
229 pub base: String,
230}
231
232/// What to answer a SCIM request with.
233#[derive(Debug, Serialize, Deserialize)]
234pub struct ScimAnswer {
235 pub status: u16,
236 pub body: Option<serde_json::Value>,
237}
238
239/// Whether a workspace has single sign-on, for anyone: the sign-in page
240/// and the notice for someone it holds out.
241#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
242pub struct SsoSummary {
243 /// The workspace's id and name now, whatever named it: empty for none.
244 #[serde(default)]
245 pub workspace_id: String,
246 #[serde(default)]
247 pub slug: String,
248 pub enabled: bool,
249 pub protocol: Option<SsoProtocol>,
250 pub required: bool,
251 pub allow_join: bool,
252}
253
254#[derive(Debug, Serialize, Deserialize)]
255pub struct SsoSettingsArgs {
256 pub actor: User,
257 pub slug: String,
258 #[serde(default)]
259 pub surface: Option<Surface>,
260}
261
262/// `save_sso`: sets up or changes the workspace's connection. Owners only,
263/// on a paid plan. A secret or certificate left empty keeps the one saved.
264/// Changing which provider it is (protocol, issuer, entity id) unlinks
265/// everyone, stops requiring it, and needs a sign-in through it again.
266#[derive(Debug, Serialize, Deserialize)]
267pub struct SaveSsoArgs {
268 pub actor: User,
269 pub slug: String,
270 pub protocol: SsoProtocol,
271 #[serde(default)]
272 pub config: SsoConfig,
273 #[serde(default)]
274 pub client_secret: Option<String>,
275 /// SAML: certificates as pasted (PEM).
276 #[serde(default)]
277 pub certificate: Option<String>,
278 /// Off unless asked for: a provider anyone can sign in to (a consumer
279 /// one) would let anyone in.
280 #[serde(default)]
281 pub allow_join: bool,
282 #[serde(default)]
283 pub surface: Option<Surface>,
284}
285
286/// `set_sso_required`: whether members must sign in through the provider.
287/// Turning it on needs a paid plan and the owner to have signed in through
288/// it themselves; turning it off never does.
289#[derive(Debug, Serialize, Deserialize)]
290pub struct SetSsoRequiredArgs {
291 pub actor: User,
292 pub slug: String,
293 pub required: bool,
294 #[serde(default)]
295 pub surface: Option<Surface>,
296}
297
298/// `unlink_sso_identity`: forgets who someone is at the provider, so they
299/// link again (or another person can) the next time. Owners only.
300#[derive(Debug, Serialize, Deserialize)]
301pub struct UnlinkSsoIdentityArgs {
302 pub actor: User,
303 pub slug: String,
304 pub username: String,
305 #[serde(default)]
306 pub surface: Option<Surface>,
307}
308
309/// `sso_start`: the signed-in person sets off to the workspace's provider.
310#[derive(Debug, Serialize, Deserialize)]
311pub struct SsoStartArgs {
312 pub user: User,
313 /// The session it is for: what comes back counts for it alone.
314 pub session_token: String,
315 pub slug: String,
316 /// The callback (OpenID Connect, OAuth 2.0) or assertion consumer
317 /// service (SAML), exactly as registered with the provider.
318 pub redirect_uri: String,
319 /// SAML: g1t's entity id for the workspace.
320 #[serde(default)]
321 pub entity_id: Option<String>,
322 /// A same-site path to return to.
323 #[serde(default)]
324 pub next: String,
325}
326
327#[derive(Debug, Serialize, Deserialize)]
328pub struct SsoStart {
329 /// Where to send the browser.
330 pub url: String,
331 /// OpenID Connect and OAuth 2.0: the state, for a cookie that binds
332 /// the trip to this browser. Absent for SAML.
333 pub state: Option<String>,
334 /// SAML: a value for a SameSite=None cookie, which the provider's
335 /// cross-site POST carries back (`SsoSamlArgs::binding`), binding the
336 /// response to the browser that set off.
337 #[serde(default)]
338 pub binding: Option<String>,
339}
340
341/// `sso_finish`: the provider sent the browser back with a code.
342#[derive(Debug, Serialize, Deserialize)]
343pub struct SsoFinishArgs {
344 pub slug: String,
345 pub session_token: String,
346 pub state: String,
347 pub code: String,
348}
349
350/// `sso_saml`: the provider posted its response.
351#[derive(Debug, Serialize, Deserialize)]
352pub struct SsoSamlArgs {
353 pub slug: String,
354 pub saml_response: String,
355 /// The binding cookie's value (`SsoStart::binding`).
356 #[serde(default)]
357 pub binding: Option<String>,
358}
359
360#[derive(Debug, Serialize, Deserialize)]
361pub struct SsoFinished {
362 pub slug: String,
363 pub next: String,
364 /// Whether they joined the workspace just now.
365 pub joined: bool,
366 /// Whether this was the first sign-in through the connection, which
367 /// marks it tested.
368 pub first: bool,
369}
370
371/// `sso_metadata`: g1t's SAML service provider metadata for a workspace.
372#[derive(Debug, Serialize, Deserialize)]
373pub struct SsoMetadataArgs {
374 pub entity_id: String,
375 pub acs_url: String,
376}
377
378/// Where a workspace's provider is pointed, from the site's address: g1t's
379/// entity id (its metadata's address), the assertion consumer service, and
380/// the callback for OpenID Connect and OAuth 2.0. Mirrors `ssoAddresses`
381/// in `packages/contracts/src/sso.ts`.
382#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
383pub struct SsoAddresses {
384 pub redirect_uri: String,
385 pub entity_id: String,
386 pub acs_url: String,
387 pub metadata_url: String,
388 /// Where people start signing in through it.
389 pub sign_in_url: String,
390}
391
392/// What the provider is pointed at is keyed by the workspace's id, which a
393/// rename never changes; where people start is its name.
394pub fn sso_addresses(site: &str, workspace_id: &str, slug: &str) -> SsoAddresses {
395 let site = site.trim_end_matches('/');
396 let at = format!("{site}/auth/sso/{}", workspace_id.trim());
397 SsoAddresses {
398 redirect_uri: format!("{at}/callback"),
399 entity_id: format!("{at}/metadata"),
400 acs_url: format!("{at}/saml"),
401 metadata_url: format!("{at}/metadata"),
402 sign_in_url: format!("{site}/auth/sso/{}", slug.trim().to_lowercase()),
403 }
404}
405
406/// `admin_sso`: a workspace's single sign-on, for staff in sudo: as its
407/// owners see it, or `None` when it has none. Takes `SlugArgs`.
408pub type AdminSso = Option<SsoSettings>;
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 #[test]
415 fn addresses_are_the_workspaces_own() {
416 let at = sso_addresses("https://g1t.sh/", "wsp_1", " Acme ");
417 assert_eq!(at.redirect_uri, "https://g1t.sh/auth/sso/wsp_1/callback");
418 assert_eq!(at.entity_id, "https://g1t.sh/auth/sso/wsp_1/metadata");
419 assert_eq!(at.acs_url, "https://g1t.sh/auth/sso/wsp_1/saml");
420 assert_eq!(at.sign_in_url, "https://g1t.sh/auth/sso/acme");
421 }
422}