| 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 | |
| 36 | use serde::{Deserialize, Serialize}; |
| 37 | |
| 38 | use crate::User; |
| 39 | use 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")] |
| 44 | pub 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 | |
| 54 | impl 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)] |
| 77 | pub 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)] |
| 115 | pub 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)] |
| 122 | pub 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)] |
| 140 | pub 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)] |
| 154 | pub 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)] |
| 170 | pub 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)] |
| 183 | pub 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)] |
| 196 | pub 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)] |
| 204 | pub 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)] |
| 221 | pub 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)] |
| 229 | pub 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)] |
| 237 | pub 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)] |
| 249 | pub 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 | |
| 266 | fn 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)] |
| 274 | pub 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)] |
| 285 | pub 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)] |
| 295 | pub 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)] |
| 312 | pub 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)] |
| 323 | pub 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)] |
| 332 | pub struct SsoSamlArgs { |
| 333 | pub slug: String, |
| 334 | pub saml_response: String, |
| 335 | } |
| 336 | |
| 337 | #[derive(Debug, Serialize, Deserialize)] |
| 338 | pub 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)] |
| 350 | pub 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)] |
| 360 | pub 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 | |
| 369 | pub 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`. |
| 382 | pub type AdminSso = Option<SsoSettings>; |
| 383 | |
| 384 | #[cfg(test)] |
| 385 | mod 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 | } |