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.

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