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.

283 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
27use serde::{Deserialize, Serialize};
28
29use crate::User;
30use crate::audit::Surface;
31
32/// How a workspace's provider is spoken to.
33#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum SsoProtocol {
36 /// OpenID Connect: an issuer whose discovery document says the rest.
37 Oidc,
38 /// Plain OAuth 2.0: authorize, token and userinfo endpoints, and which
39 /// claims of the userinfo answer are the person's id and address.
40 Oauth2,
41 /// SAML 2.0: the provider's entity id, sign-in URL and certificates.
42 Saml,
43}
44
45impl SsoProtocol {
46 pub fn as_str(self) -> &'static str {
47 match self {
48 SsoProtocol::Oidc => "oidc",
49 SsoProtocol::Oauth2 => "oauth2",
50 SsoProtocol::Saml => "saml",
51 }
52 }
53
54 pub fn parse(text: &str) -> Option<SsoProtocol> {
55 match text {
56 "oidc" => Some(SsoProtocol::Oidc),
57 "oauth2" => Some(SsoProtocol::Oauth2),
58 "saml" => Some(SsoProtocol::Saml),
59 _ => None,
60 }
61 }
62}
63
64/// What a connection needs, by protocol; the fields of the others are
65/// absent. Stored as JSON, and shown to owners as it is: it holds no
66/// secret.
67#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
68pub struct SsoConfig {
69 /// OpenID Connect: the issuer, such as `https://acme.okta.com`.
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub issuer: Option<String>,
72 /// OpenID Connect and OAuth 2.0.
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub client_id: Option<String>,
75 /// Space-separated. OpenID Connect always asks for `openid`.
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub scopes: Option<String>,
78 /// OAuth 2.0.
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub authorization_url: Option<String>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub token_url: Option<String>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub userinfo_url: Option<String>,
85 /// The userinfo claim that is the person's stable id; `sub` when
86 /// absent, then `id`.
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub subject_claim: Option<String>,
89 /// The userinfo claim that is their address; `email` when absent.
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub email_claim: Option<String>,
92 /// SAML: the provider's entity id (its issuer).
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub idp_entity_id: Option<String>,
95 /// SAML: where its single sign-on service takes requests.
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub idp_sso_url: Option<String>,
98 /// SAML: its signing certificates, base64 DER; more than one while a
99 /// certificate is rotated.
100 #[serde(default, skip_serializing_if = "Vec::is_empty")]
101 pub certificates: Vec<String>,
102}
103
104/// A SAML signing certificate, as shown.
105#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
106pub struct SsoCertificate {
107 /// SHA-256, `AB:CD:…`, as providers show it.
108 pub fingerprint: String,
109}
110
111/// A workspace's connection to its provider, as its owners see it.
112#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
113pub struct SsoConnection {
114 pub id: String,
115 pub protocol: SsoProtocol,
116 pub config: SsoConfig,
117 /// Whether a client secret is kept; it is never shown.
118 pub has_client_secret: bool,
119 pub certificates: Vec<SsoCertificate>,
120 /// Whether anyone the provider signs in may join as a member.
121 pub allow_join: bool,
122 /// When someone first signed in through it, and who (a username).
123 pub tested_at: Option<String>,
124 pub tested_by: Option<String>,
125 pub created_at: String,
126 pub updated_at: String,
127}
128
129/// Someone linked to who they are at the workspace's provider.
130#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
131pub struct SsoIdentity {
132 pub username: String,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub display_username: Option<String>,
135 /// The provider's id for them.
136 pub subject: String,
137 /// The address the provider gave, for showing.
138 pub email: Option<String>,
139 pub linked_at: String,
140 pub last_used_at: String,
141}
142
143/// A workspace's single sign-on, for its settings page.
144#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
145pub struct SsoSettings {
146 pub slug: String,
147 pub connection: Option<SsoConnection>,
148 /// Whether members must sign in through it.
149 pub required: bool,
150 /// Whether the person asking has signed in through it: an owner must
151 /// before requiring it.
152 pub you_linked: bool,
153 pub identities: Vec<SsoIdentity>,
154}
155
156/// Whether a workspace has single sign-on, for anyone: the sign-in page
157/// and the notice for someone it holds out.
158#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
159pub struct SsoSummary {
160 pub enabled: bool,
161 pub protocol: Option<SsoProtocol>,
162 pub required: bool,
163 pub allow_join: bool,
164}
165
166#[derive(Debug, Serialize, Deserialize)]
167pub struct SsoSettingsArgs {
168 pub actor: User,
169 pub slug: String,
170 #[serde(default)]
171 pub surface: Option<Surface>,
172}
173
174/// `save_sso`: sets up or changes the workspace's connection. Owners only,
175/// on a paid plan. A secret or certificate left empty keeps the one saved.
176/// Changing which provider it is (protocol, issuer, entity id) unlinks
177/// everyone, stops requiring it, and needs a sign-in through it again.
178#[derive(Debug, Serialize, Deserialize)]
179pub struct SaveSsoArgs {
180 pub actor: User,
181 pub slug: String,
182 pub protocol: SsoProtocol,
183 #[serde(default)]
184 pub config: SsoConfig,
185 #[serde(default)]
186 pub client_secret: Option<String>,
187 /// SAML: certificates as pasted (PEM).
188 #[serde(default)]
189 pub certificate: Option<String>,
190 #[serde(default = "yes")]
191 pub allow_join: bool,
192 #[serde(default)]
193 pub surface: Option<Surface>,
194}
195
196fn yes() -> bool {
197 true
198}
199
200/// `set_sso_required`: whether members must sign in through the provider.
201/// Turning it on needs a paid plan and the owner to have signed in through
202/// it themselves; turning it off never does.
203#[derive(Debug, Serialize, Deserialize)]
204pub struct SetSsoRequiredArgs {
205 pub actor: User,
206 pub slug: String,
207 pub required: bool,
208 #[serde(default)]
209 pub surface: Option<Surface>,
210}
211
212/// `unlink_sso_identity`: forgets who someone is at the provider, so they
213/// link again (or another person can) the next time. Owners only.
214#[derive(Debug, Serialize, Deserialize)]
215pub struct UnlinkSsoIdentityArgs {
216 pub actor: User,
217 pub slug: String,
218 pub username: String,
219 #[serde(default)]
220 pub surface: Option<Surface>,
221}
222
223/// `sso_start`: the signed-in person sets off to the workspace's provider.
224#[derive(Debug, Serialize, Deserialize)]
225pub struct SsoStartArgs {
226 pub user: User,
227 /// The session it is for: what comes back counts for it alone.
228 pub session_token: String,
229 pub slug: String,
230 /// The callback (OpenID Connect, OAuth 2.0) or assertion consumer
231 /// service (SAML), exactly as registered with the provider.
232 pub redirect_uri: String,
233 /// SAML: g1t's entity id for the workspace.
234 #[serde(default)]
235 pub entity_id: Option<String>,
236 /// A same-site path to return to.
237 #[serde(default)]
238 pub next: String,
239}
240
241#[derive(Debug, Serialize, Deserialize)]
242pub struct SsoStart {
243 /// Where to send the browser.
244 pub url: String,
245 /// OpenID Connect and OAuth 2.0: the state, for a cookie that binds
246 /// the trip to this browser. Absent for SAML, whose response is posted
247 /// cross-site, without cookies; its request id binds it instead.
248 pub state: Option<String>,
249}
250
251/// `sso_finish`: the provider sent the browser back with a code.
252#[derive(Debug, Serialize, Deserialize)]
253pub struct SsoFinishArgs {
254 pub slug: String,
255 pub session_token: String,
256 pub state: String,
257 pub code: String,
258}
259
260/// `sso_saml`: the provider posted its response.
261#[derive(Debug, Serialize, Deserialize)]
262pub struct SsoSamlArgs {
263 pub slug: String,
264 pub saml_response: String,
265}
266
267#[derive(Debug, Serialize, Deserialize)]
268pub struct SsoFinished {
269 pub slug: String,
270 pub next: String,
271 /// Whether they joined the workspace just now.
272 pub joined: bool,
273 /// Whether this was the first sign-in through the connection, which
274 /// marks it tested.
275 pub first: bool,
276}
277
278/// `sso_metadata`: g1t's SAML service provider metadata for a workspace.
279#[derive(Debug, Serialize, Deserialize)]
280pub struct SsoMetadataArgs {
281 pub entity_id: String,
282 pub acs_url: String,
283}