Add single sign-on to the API, MCP and sudo
Owners manage a workspace's single sign-on with an access token: six operations, each a REST route and an action of the workspace MCP tool, in the OpenAPI document and the CLI's table: GET /workspaces/{workspace}/sso get_sso PUT /workspaces/{workspace}/sso configure_sso DELETE /workspaces/{workspace}/sso delete_sso PUT /workspaces/{workspace}/sso/required set_sso_required GET /workspaces/{workspace}/sso/identities list_sso_identities DELETE /workspaces/{workspace}/sso/identities/{username} unlink_sso_identity Answers carry service_provider: the redirect URI, entity id, ACS, metadata and sign-in addresses to give the provider. Configuring, removing and unlinking are marked destructive for MCP clients. Sudo's workspace page shows its single sign-on read-only: protocol, provider, whether it is required or lets people join, who tested it, the certificates by fingerprint, and the people linked. Refs #56
- G1t-Session:
- ses_01m54fhewne5tbgqqh4n8f5fvz
| 48 | 48 | mod tools; | |
| 49 | 49 | mod why; | |
| 50 | 50 | mod token_policy; | |
| 51 | + | mod sso; | |
| 51 | 52 | mod onboarding; | |
| 52 | 53 | mod toolkit; | |
| 53 | 54 |
| 19 | 19 | use crate::coordination::CoordinationOp; | |
| 20 | 20 | use crate::protection::ProtectionOp; | |
| 21 | 21 | use crate::token_policy::TokenOp; | |
| 22 | + | use crate::sso::SsoOp; | |
| 22 | 23 | use crate::onboarding::OnboardingOp; | |
| 23 | 24 | use crate::operations::Op; | |
| 24 | 25 | use crate::checks::ChecksOp; | |
| ⋯ | |||
| 87 | 88 | ], | |
| 88 | 89 | ), | |
| 89 | 90 | ( | |
| 91 | + | "Single sign-on", | |
| 92 | + | "A workspace's own identity provider over OpenID Connect, OAuth 2.0 or SAML 2.0, on every paid plan: setting it up, requiring it of members, the people linked to it, and unlinking one. People sign in to g1t as themselves and then through the provider to use the workspace; owners are never held out. Owners only, as people.", | |
| 93 | + | &[ | |
| 94 | + | Op::Sso(SsoOp::GetSso), | |
| 95 | + | Op::Sso(SsoOp::ConfigureSso), | |
| 96 | + | Op::Sso(SsoOp::SetSsoRequired), | |
| 97 | + | Op::Sso(SsoOp::DeleteSso), | |
| 98 | + | Op::Sso(SsoOp::ListSsoIdentities), | |
| 99 | + | Op::Sso(SsoOp::UnlinkSsoIdentity), | |
| 100 | + | ], | |
| 101 | + | ), | |
| 102 | + | ( | |
| 90 | 103 | "Members", | |
| 91 | 104 | "A workspace's members and owners: who belongs to it, their roles (owner or member, with billing manager and security manager on top), handing it to another member, and leaving it.", | |
| 92 | 105 | &[Op::ListMembers, Op::UpdateMember, Op::RemoveMember, Op::TransferOwnership, Op::LeaveWorkspace], | |
| ⋯ | |||
| 881 | 894 | Op::Telemetry(op) => op.title(), | |
| 882 | 895 | Op::Protection(op) => op.title(), | |
| 883 | 896 | Op::Tokens(op) => op.title(), | |
| 897 | + | Op::Sso(op) => op.title(), | |
| 884 | 898 | Op::Onboarding(op) => op.title(), | |
| 885 | 899 | Op::Artifacts(op) => op.title(), | |
| 886 | 900 | Op::Caches(op) => op.title(), | |
| 41 | 41 | use crate::coordination::CoordinationOp; | |
| 42 | 42 | use crate::protection::ProtectionOp; | |
| 43 | 43 | use crate::token_policy::TokenOp; | |
| 44 | + | use crate::sso::SsoOp; | |
| 44 | 45 | use crate::onboarding::OnboardingOp; | |
| 45 | 46 | use crate::rules::RulesOp; | |
| 46 | 47 | use crate::security::SecurityOp; | |
| ⋯ | |||
| 345 | 346 | /// A workspace's rules for personal access tokens, its members' | |
| 346 | 347 | /// tokens and approving them: token_policy.rs. | |
| 347 | 348 | Tokens(TokenOp), | |
| 349 | + | /// A workspace's single sign-on through its own identity provider: sso.rs. | |
| 350 | + | Sso(SsoOp), | |
| 348 | 351 | /// A person's own Getting started in a workspace: onboarding.rs. | |
| 349 | 352 | Onboarding(OnboardingOp), | |
| 350 | 353 | /// Workflow run artifacts, and how long they are kept: run_artifacts.rs. | |
| ⋯ | |||
| 828 | 831 | } | |
| 829 | 832 | ||
| 830 | 833 | impl Op { | |
| 831 | − | pub const ALL: [Op; 409] = [ | |
| 834 | + | pub const ALL: [Op; 415] = [ | |
| 832 | 835 | Op::Whoami, | |
| 833 | 836 | Op::GetWorkspace, | |
| 834 | 837 | Op::CreateWorkspace, | |
| ⋯ | |||
| 1190 | 1193 | Op::Tokens(TokenOp::ListTokenRequests), | |
| 1191 | 1194 | Op::Tokens(TokenOp::ReviewTokenRequest), | |
| 1192 | 1195 | Op::Tokens(TokenOp::RevokeMemberToken), | |
| 1196 | + | Op::Sso(SsoOp::GetSso), | |
| 1197 | + | Op::Sso(SsoOp::ConfigureSso), | |
| 1198 | + | Op::Sso(SsoOp::SetSsoRequired), | |
| 1199 | + | Op::Sso(SsoOp::DeleteSso), | |
| 1200 | + | Op::Sso(SsoOp::ListSsoIdentities), | |
| 1201 | + | Op::Sso(SsoOp::UnlinkSsoIdentity), | |
| 1193 | 1202 | Op::Onboarding(OnboardingOp::GetOnboarding), | |
| 1194 | 1203 | Op::Onboarding(OnboardingOp::UpdateOnboarding), | |
| 1195 | 1204 | Op::Packages(PackagesOp::ListPackages), | |
| ⋯ | |||
| 1464 | 1473 | Op::Telemetry(op) => op.name(), | |
| 1465 | 1474 | Op::Protection(op) => op.name(), | |
| 1466 | 1475 | Op::Tokens(op) => op.name(), | |
| 1476 | + | Op::Sso(op) => op.name(), | |
| 1467 | 1477 | Op::Onboarding(op) => op.name(), | |
| 1468 | 1478 | Op::Artifacts(op) => op.name(), | |
| 1469 | 1479 | Op::Caches(op) => op.name(), | |
| ⋯ | |||
| 2070 | 2080 | Op::Telemetry(op) => op.description(), | |
| 2071 | 2081 | Op::Protection(op) => op.description(), | |
| 2072 | 2082 | Op::Tokens(op) => op.description(), | |
| 2083 | + | Op::Sso(op) => op.description(), | |
| 2073 | 2084 | Op::Onboarding(op) => op.description(), | |
| 2074 | 2085 | Op::Artifacts(op) => op.description(), | |
| 2075 | 2086 | Op::Caches(op) => op.description(), | |
| ⋯ | |||
| 3753 | 3764 | Op::Telemetry(op) => op.input(), | |
| 3754 | 3765 | Op::Protection(op) => op.input(), | |
| 3755 | 3766 | Op::Tokens(op) => op.input(), | |
| 3767 | + | Op::Sso(op) => op.input(), | |
| 3756 | 3768 | Op::Onboarding(op) => op.input(), | |
| 3757 | 3769 | Op::Artifacts(op) => op.input(), | |
| 3758 | 3770 | Op::Caches(op) => op.input(), | |
| ⋯ | |||
| 3887 | 3899 | return op.needs_repo(); | |
| 3888 | 3900 | } | |
| 3889 | 3901 | // A workspace's, never one repository's. | |
| 3890 | − | if let Op::Tokens(_) | Op::Onboarding(_) = self { | |
| 3902 | + | if let Op::Tokens(_) | Op::Sso(_) | Op::Onboarding(_) = self { | |
| 3891 | 3903 | return false; | |
| 3892 | 3904 | } | |
| 3893 | 3905 | !matches!( | |
| ⋯ | |||
| 6398 | 6410 | Op::Telemetry(op) => crate::telemetry::run(op, services, viewer, input).await, | |
| 6399 | 6411 | Op::Protection(op) => crate::protection::run(op, services, viewer, input).await, | |
| 6400 | 6412 | Op::Tokens(op) => crate::token_policy::run(op, services, viewer, input).await, | |
| 6413 | + | Op::Sso(op) => crate::sso::run(op, services, viewer, input).await, | |
| 6401 | 6414 | Op::Onboarding(op) => crate::onboarding::run(op, services, viewer, input).await, | |
| 6402 | 6415 | Op::Artifacts(op) => crate::run_artifacts::run(op, services, viewer, input).await, | |
| 6403 | 6416 | Op::Caches(op) => crate::caches::run(op, services, viewer, input).await, | |
| 15168 | 15168 | "stored_bytes": 1210443776 | |
| 15169 | 15169 | }, | |
| 15170 | 15170 | "notes": "This month so far. `ingested_bytes` is what arrived, after decoding: what ingest is metered by. Needs the Admin role." | |
| 15171 | + | }, | |
| 15172 | + | "get_sso": { | |
| 15173 | + | "params": { | |
| 15174 | + | "workspace": "flagon-io" | |
| 15175 | + | }, | |
| 15176 | + | "response": { | |
| 15177 | + | "slug": "flagon-io", | |
| 15178 | + | "connection": { | |
| 15179 | + | "id": "sso_01m54gq2v0e8e3c5yj6k9t1r2a", | |
| 15180 | + | "protocol": "saml", | |
| 15181 | + | "config": { | |
| 15182 | + | "idp_entity_id": "http://www.okta.com/exk1a2b3c4d5", | |
| 15183 | + | "idp_sso_url": "https://flagon.okta.com/app/flagon_g1t/exk1a2b3c4d5/sso/saml", | |
| 15184 | + | "certificates": [ | |
| 15185 | + | "MIICsDCCAZigAwIBAgICA+gwDQYJKoZIhvcNAQELBQAw" | |
| 15186 | + | ] | |
| 15187 | + | }, | |
| 15188 | + | "has_client_secret": false, | |
| 15189 | + | "certificates": [ | |
| 15190 | + | { | |
| 15191 | + | "fingerprint": "3F:A1:9C:22:7B:E0:44:D1:8A:6F:0B:93:12:C7:5E:AA:41:08:DD:6C:39:F2:B5:7E:90:1A:C3:58:24:EF:6B:0D" | |
| 15192 | + | } | |
| 15193 | + | ], | |
| 15194 | + | "allow_join": true, | |
| 15195 | + | "tested_at": "2026-10-17T09:12:44.000Z", | |
| 15196 | + | "tested_by": "syntaqx", | |
| 15197 | + | "created_at": "2026-10-17T09:05:10.000Z", | |
| 15198 | + | "updated_at": "2026-10-17T09:05:10.000Z" | |
| 15199 | + | }, | |
| 15200 | + | "required": true, | |
| 15201 | + | "you_linked": true, | |
| 15202 | + | "identities": [ | |
| 15203 | + | { | |
| 15204 | + | "username": "syntaqx", | |
| 15205 | + | "subject": "chase@flagon.io", | |
| 15206 | + | "email": "chase@flagon.io", | |
| 15207 | + | "linked_at": "2026-10-17T09:12:44.000Z", | |
| 15208 | + | "last_used_at": "2026-10-17T14:02:31.000Z" | |
| 15209 | + | } | |
| 15210 | + | ], | |
| 15211 | + | "service_provider": { | |
| 15212 | + | "redirect_uri": "https://g1t.sh/auth/sso/flagon-io/callback", | |
| 15213 | + | "entity_id": "https://g1t.sh/auth/sso/flagon-io/metadata", | |
| 15214 | + | "acs_url": "https://g1t.sh/auth/sso/flagon-io/saml", | |
| 15215 | + | "metadata_url": "https://g1t.sh/auth/sso/flagon-io/metadata", | |
| 15216 | + | "sign_in_url": "https://g1t.sh/auth/sso/flagon-io" | |
| 15217 | + | } | |
| 15218 | + | } | |
| 15219 | + | }, | |
| 15220 | + | "configure_sso": { | |
| 15221 | + | "params": { | |
| 15222 | + | "workspace": "flagon-io", | |
| 15223 | + | "protocol": "saml", | |
| 15224 | + | "idp_entity_id": "http://www.okta.com/exk1a2b3c4d5", | |
| 15225 | + | "idp_sso_url": "https://flagon.okta.com/app/flagon_g1t/exk1a2b3c4d5/sso/saml", | |
| 15226 | + | "certificate": "-----BEGIN CERTIFICATE-----\nMIICsDCCAZig…\n-----END CERTIFICATE-----", | |
| 15227 | + | "allow_join": true | |
| 15228 | + | }, | |
| 15229 | + | "response": { | |
| 15230 | + | "slug": "flagon-io", | |
| 15231 | + | "connection": { | |
| 15232 | + | "id": "sso_01m54gq2v0e8e3c5yj6k9t1r2a", | |
| 15233 | + | "protocol": "saml", | |
| 15234 | + | "config": { | |
| 15235 | + | "idp_entity_id": "http://www.okta.com/exk1a2b3c4d5", | |
| 15236 | + | "idp_sso_url": "https://flagon.okta.com/app/flagon_g1t/exk1a2b3c4d5/sso/saml", | |
| 15237 | + | "certificates": [ | |
| 15238 | + | "MIICsDCCAZigAwIBAgICA+gwDQYJKoZIhvcNAQELBQAw" | |
| 15239 | + | ] | |
| 15240 | + | }, | |
| 15241 | + | "has_client_secret": false, | |
| 15242 | + | "certificates": [ | |
| 15243 | + | { | |
| 15244 | + | "fingerprint": "3F:A1:9C:22:7B:E0:44:D1:8A:6F:0B:93:12:C7:5E:AA:41:08:DD:6C:39:F2:B5:7E:90:1A:C3:58:24:EF:6B:0D" | |
| 15245 | + | } | |
| 15246 | + | ], | |
| 15247 | + | "allow_join": true, | |
| 15248 | + | "tested_at": "2026-10-17T09:12:44.000Z", | |
| 15249 | + | "tested_by": "syntaqx", | |
| 15250 | + | "created_at": "2026-10-17T09:05:10.000Z", | |
| 15251 | + | "updated_at": "2026-10-17T09:05:10.000Z" | |
| 15252 | + | }, | |
| 15253 | + | "required": true, | |
| 15254 | + | "you_linked": true, | |
| 15255 | + | "identities": [ | |
| 15256 | + | { | |
| 15257 | + | "username": "syntaqx", | |
| 15258 | + | "subject": "chase@flagon.io", | |
| 15259 | + | "email": "chase@flagon.io", | |
| 15260 | + | "linked_at": "2026-10-17T09:12:44.000Z", | |
| 15261 | + | "last_used_at": "2026-10-17T14:02:31.000Z" | |
| 15262 | + | } | |
| 15263 | + | ], | |
| 15264 | + | "service_provider": { | |
| 15265 | + | "redirect_uri": "https://g1t.sh/auth/sso/flagon-io/callback", | |
| 15266 | + | "entity_id": "https://g1t.sh/auth/sso/flagon-io/metadata", | |
| 15267 | + | "acs_url": "https://g1t.sh/auth/sso/flagon-io/saml", | |
| 15268 | + | "metadata_url": "https://g1t.sh/auth/sso/flagon-io/metadata", | |
| 15269 | + | "sign_in_url": "https://g1t.sh/auth/sso/flagon-io" | |
| 15270 | + | } | |
| 15271 | + | } | |
| 15272 | + | }, | |
| 15273 | + | "set_sso_required": { | |
| 15274 | + | "params": { | |
| 15275 | + | "workspace": "flagon-io", | |
| 15276 | + | "required": true | |
| 15277 | + | }, | |
| 15278 | + | "response": { | |
| 15279 | + | "required": true | |
| 15280 | + | } | |
| 15281 | + | }, | |
| 15282 | + | "delete_sso": { | |
| 15283 | + | "params": { | |
| 15284 | + | "workspace": "flagon-io" | |
| 15285 | + | }, | |
| 15286 | + | "response": { | |
| 15287 | + | "deleted": true | |
| 15288 | + | } | |
| 15289 | + | }, | |
| 15290 | + | "list_sso_identities": { | |
| 15291 | + | "params": { | |
| 15292 | + | "workspace": "flagon-io" | |
| 15293 | + | }, | |
| 15294 | + | "response": [ | |
| 15295 | + | { | |
| 15296 | + | "username": "syntaqx", | |
| 15297 | + | "subject": "chase@flagon.io", | |
| 15298 | + | "email": "chase@flagon.io", | |
| 15299 | + | "linked_at": "2026-10-17T09:12:44.000Z", | |
| 15300 | + | "last_used_at": "2026-10-17T14:02:31.000Z" | |
| 15301 | + | } | |
| 15302 | + | ] | |
| 15303 | + | }, | |
| 15304 | + | "unlink_sso_identity": { | |
| 15305 | + | "params": { | |
| 15306 | + | "workspace": "flagon-io", | |
| 15307 | + | "username": "ada" | |
| 15308 | + | }, | |
| 15309 | + | "response": { | |
| 15310 | + | "unlinked": true | |
| 15311 | + | } | |
| 15171 | 15312 | } | |
| 15172 | 15313 | } |
| 14 | 14 | use crate::coordination::CoordinationOp; | |
| 15 | 15 | use crate::protection::ProtectionOp; | |
| 16 | 16 | use crate::token_policy::TokenOp; | |
| 17 | + | use crate::sso::SsoOp; | |
| 17 | 18 | use crate::onboarding::OnboardingOp; | |
| 18 | 19 | use crate::operations::Op; | |
| 19 | 20 | use crate::checks::ChecksOp; | |
| ⋯ | |||
| 73 | 74 | route("POST", "/workspaces/:workspace/personal-access-tokens/:id", Op::Tokens(TokenOp::RevokeMemberToken), &[]), | |
| 74 | 75 | route("GET", "/workspaces/:workspace/personal-access-token-requests", Op::Tokens(TokenOp::ListTokenRequests), &[]), | |
| 75 | 76 | route("POST", "/workspaces/:workspace/personal-access-token-requests/:id", Op::Tokens(TokenOp::ReviewTokenRequest), &[]), | |
| 77 | + | route("GET", "/workspaces/:workspace/sso", Op::Sso(SsoOp::GetSso), &[]), | |
| 78 | + | route("PUT", "/workspaces/:workspace/sso", Op::Sso(SsoOp::ConfigureSso), &[]), | |
| 79 | + | route("DELETE", "/workspaces/:workspace/sso", Op::Sso(SsoOp::DeleteSso), &[]), | |
| 80 | + | route("PUT", "/workspaces/:workspace/sso/required", Op::Sso(SsoOp::SetSsoRequired), &[]), | |
| 81 | + | route("GET", "/workspaces/:workspace/sso/identities", Op::Sso(SsoOp::ListSsoIdentities), &[]), | |
| 82 | + | route("DELETE", "/workspaces/:workspace/sso/identities/:username", Op::Sso(SsoOp::UnlinkSsoIdentity), &[]), | |
| 76 | 83 | route("POST", "/workspaces/:workspace/invitations", Op::InviteMember, &[]), | |
| 77 | 84 | route("DELETE", "/workspaces/:workspace/invitations/:id", Op::RevokeWorkspaceInvite, &[]), | |
| 78 | 85 | // Who has access. GitHub's addresses, but for adding someone, which | |
| 1 | + | //! A workspace's single sign-on, over REST and MCP: its connection to the | |
| 2 | + | //! workspace's identity provider (OpenID Connect, OAuth 2.0 or SAML 2.0), | |
| 3 | + | //! requiring it of members, the people linked to it, and unlinking one. | |
| 4 | + | //! Identity decides and keeps all of it (services/identity/src/sso.rs); | |
| 5 | + | //! owners only, as people, and setting it up needs a paid plan. Signing in | |
| 6 | + | //! through it is a browser's trip to the provider, so it is not here. | |
| 7 | + | ||
| 8 | + | use g1t_contracts::sso::{SsoConfig, SsoProtocol, SsoSettings, sso_addresses}; | |
| 9 | + | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 10 | + | use serde_json::{Value, json}; | |
| 11 | + | use worker::Result; | |
| 12 | + | ||
| 13 | + | use crate::operations::Services; | |
| 14 | + | ||
| 15 | + | /// One operation. | |
| 16 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 17 | + | pub enum SsoOp { | |
| 18 | + | GetSso, | |
| 19 | + | ConfigureSso, | |
| 20 | + | SetSsoRequired, | |
| 21 | + | DeleteSso, | |
| 22 | + | ListSsoIdentities, | |
| 23 | + | UnlinkSsoIdentity, | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | /// The config fields an owner sends, as `SsoConfig` names them. | |
| 27 | + | const CONFIG_FIELDS: [&str; 10] = [ | |
| 28 | + | "issuer", | |
| 29 | + | "client_id", | |
| 30 | + | "scopes", | |
| 31 | + | "authorization_url", | |
| 32 | + | "token_url", | |
| 33 | + | "userinfo_url", | |
| 34 | + | "subject_claim", | |
| 35 | + | "email_claim", | |
| 36 | + | "idp_entity_id", | |
| 37 | + | "idp_sso_url", | |
| 38 | + | ]; | |
| 39 | + | ||
| 40 | + | impl SsoOp { | |
| 41 | + | /// Every one: `Op::ALL` lists each as `Op::Sso(…)`, which a test checks | |
| 42 | + | /// against this. | |
| 43 | + | #[cfg(test)] | |
| 44 | + | pub const ALL: [SsoOp; 6] = [ | |
| 45 | + | SsoOp::GetSso, | |
| 46 | + | SsoOp::ConfigureSso, | |
| 47 | + | SsoOp::SetSsoRequired, | |
| 48 | + | SsoOp::DeleteSso, | |
| 49 | + | SsoOp::ListSsoIdentities, | |
| 50 | + | SsoOp::UnlinkSsoIdentity, | |
| 51 | + | ]; | |
| 52 | + | ||
| 53 | + | pub fn name(self) -> &'static str { | |
| 54 | + | match self { | |
| 55 | + | SsoOp::GetSso => "get_sso", | |
| 56 | + | SsoOp::ConfigureSso => "configure_sso", | |
| 57 | + | SsoOp::SetSsoRequired => "set_sso_required", | |
| 58 | + | SsoOp::DeleteSso => "delete_sso", | |
| 59 | + | SsoOp::ListSsoIdentities => "list_sso_identities", | |
| 60 | + | SsoOp::UnlinkSsoIdentity => "unlink_sso_identity", | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | pub fn title(self) -> &'static str { | |
| 65 | + | match self { | |
| 66 | + | SsoOp::GetSso => "Get a workspace's single sign-on", | |
| 67 | + | SsoOp::ConfigureSso => "Set up or change a workspace's single sign-on", | |
| 68 | + | SsoOp::SetSsoRequired => "Require single sign-on, or stop", | |
| 69 | + | SsoOp::DeleteSso => "Remove a workspace's single sign-on", | |
| 70 | + | SsoOp::ListSsoIdentities => "List the people linked through single sign-on", | |
| 71 | + | SsoOp::UnlinkSsoIdentity => "Unlink someone from single sign-on", | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | pub fn description(self) -> &'static str { | |
| 76 | + | match self { | |
| 77 | + | SsoOp::GetSso => "A workspace's single sign-on through its own identity provider: connection (null when it has none: its id, protocol oidc, oauth2 or saml, config with the protocol's fields, has_client_secret, certificates by SHA-256 fingerprint for SAML, allow_join, tested_at and tested_by, when the first person signed in through it), required (whether members must sign in through it), you_linked (whether you have), identities (the people linked: username, subject and email at the provider, linked_at, last_used_at), and service_provider: the addresses to give the provider (redirect_uri for OpenID Connect and OAuth 2.0; entity_id, acs_url and metadata_url for SAML; sign_in_url, where people start). The client secret is never returned. Owners only, as people.", | |
| 78 | + | SsoOp::ConfigureSso => "Set up the workspace's single sign-on, or change it, with every field of the protocol. oidc: issuer (read for its discovery document first), client_id, client_secret, scopes (openid email profile by default). oauth2: authorization_url, token_url, userinfo_url, client_id, client_secret, scopes, subject_claim (sub, then id, by default) and email_claim (email). saml: idp_entity_id, idp_sso_url and certificate (the provider's signing certificates, PEM; several while rotating). client_secret and certificate left out keep what is saved. allow_join (true by default) lets anyone the provider signs in join as a member. Moving to another provider (protocol, issuer or entity id) unlinks everyone and stops requiring it. Needs a paid plan. Returns what get_sso does. Recorded as sso.created or sso.updated. Owners only, as people.", | |
| 79 | + | SsoOp::SetSsoRequired => "Whether members must sign in through the provider: then a member uses the workspace only from a session that did in the last day, and with tokens and keys once linked; held out otherwise, keeping their place. Owners are never held, and outside collaborators are not asked. Requiring it needs a paid plan and you to have signed in through it yourself (you_linked); stopping never does. Recorded as sso.required or sso.not_required. Owners only, as people.", | |
| 80 | + | SsoOp::DeleteSso => "Remove the workspace's single sign-on: forgets the provider and who everyone is there, and stops requiring it. Members keep their place. Never needs a plan. Recorded as sso.deleted. Owners only, as people.", | |
| 81 | + | SsoOp::ListSsoIdentities => "The people linked to the workspace's provider: username, subject (their id at the provider), email (as the provider gave it, for showing), linked_at and last_used_at. Owners only, as people.", | |
| 82 | + | SsoOp::UnlinkSsoIdentity => "Forget who someone is at the workspace's provider, so they (or another account there) link again on their next sign-in through it; their sessions go through it again. Recorded as sso.identity_unlinked. Owners only, as people.", | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /// Whether it changes anything: checked against the scope table in tests. | |
| 87 | + | #[cfg(test)] | |
| 88 | + | pub fn writes(self) -> bool { | |
| 89 | + | !matches!(self, SsoOp::GetSso | SsoOp::ListSsoIdentities) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | pub fn input(self) -> Value { | |
| 93 | + | let workspace = json!({ "type": "string", "description": "The workspace's name, e.g. \"acme\"." }); | |
| 94 | + | let (properties, required): (Value, &[&str]) = match self { | |
| 95 | + | SsoOp::GetSso | SsoOp::DeleteSso | SsoOp::ListSsoIdentities => (json!({ "workspace": workspace }), &["workspace"]), | |
| 96 | + | SsoOp::ConfigureSso => ( | |
| 97 | + | json!({ | |
| 98 | + | "workspace": workspace, | |
| 99 | + | "protocol": { "type": "string", "enum": ["oidc", "oauth2", "saml"], "description": "How g1t speaks to the provider." }, | |
| 100 | + | "issuer": { "type": "string", "description": "oidc: the issuer, where its /.well-known/openid-configuration lives." }, | |
| 101 | + | "client_id": { "type": "string", "description": "oidc, oauth2: the client id." }, | |
| 102 | + | "client_secret": { "type": "string", "description": "oidc, oauth2: the client secret; kept encrypted, never returned. Left out keeps the one saved." }, | |
| 103 | + | "scopes": { "type": "string", "description": "oidc, oauth2: space-separated scopes." }, | |
| 104 | + | "authorization_url": { "type": "string", "description": "oauth2: the authorization endpoint (https)." }, | |
| 105 | + | "token_url": { "type": "string", "description": "oauth2: the token endpoint (https)." }, | |
| 106 | + | "userinfo_url": { "type": "string", "description": "oauth2: the userinfo endpoint (https)." }, | |
| 107 | + | "subject_claim": { "type": "string", "description": "oauth2: the userinfo claim that is the person's stable id; a dotted path works." }, | |
| 108 | + | "email_claim": { "type": "string", "description": "oauth2: the userinfo claim that is their address." }, | |
| 109 | + | "idp_entity_id": { "type": "string", "description": "saml: the provider's entity id (its issuer)." }, | |
| 110 | + | "idp_sso_url": { "type": "string", "description": "saml: where the provider takes requests (HTTP-Redirect, https)." }, | |
| 111 | + | "certificate": { "type": "string", "description": "saml: the provider's signing certificates, PEM. Left out keeps the ones saved." }, | |
| 112 | + | "allow_join": { "type": "boolean", "description": "Anyone the provider signs in joins as a member. True by default." }, | |
| 113 | + | }), | |
| 114 | + | &["workspace", "protocol"], | |
| 115 | + | ), | |
| 116 | + | SsoOp::SetSsoRequired => ( | |
| 117 | + | json!({ | |
| 118 | + | "workspace": workspace, | |
| 119 | + | "required": { "type": "boolean", "description": "True to require it of members; false to stop." }, | |
| 120 | + | }), | |
| 121 | + | &["workspace", "required"], | |
| 122 | + | ), | |
| 123 | + | SsoOp::UnlinkSsoIdentity => ( | |
| 124 | + | json!({ | |
| 125 | + | "workspace": workspace, | |
| 126 | + | "username": { "type": "string", "description": "Whose link to forget." }, | |
| 127 | + | }), | |
| 128 | + | &["workspace", "username"], | |
| 129 | + | ), | |
| 130 | + | }; | |
| 131 | + | json!({ "type": "object", "properties": properties, "required": required }) | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | fn text(input: &Value, key: &str) -> Option<String> { | |
| 136 | + | match &input[key] { | |
| 137 | + | Value::String(text) if !text.trim().is_empty() => Some(text.trim().to_owned()), | |
| 138 | + | _ => None, | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | fn flag(input: &Value, key: &str) -> Option<bool> { | |
| 143 | + | match &input[key] { | |
| 144 | + | Value::Bool(value) => Some(*value), | |
| 145 | + | Value::String(text) => match text.trim() { | |
| 146 | + | "true" | "1" => Some(true), | |
| 147 | + | "false" | "0" => Some(false), | |
| 148 | + | _ => None, | |
| 149 | + | }, | |
| 150 | + | _ => None, | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | /// The protocol's fields as sent: the config, its secret and certificate. | |
| 155 | + | fn config_of(input: &Value) -> SsoConfig { | |
| 156 | + | let mut config = json!({}); | |
| 157 | + | for field in CONFIG_FIELDS { | |
| 158 | + | if let Some(value) = text(input, field) { | |
| 159 | + | config[field] = json!(value); | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | serde_json::from_value(config).unwrap_or_default() | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | /// The settings, with where to point the provider. | |
| 166 | + | fn with_addresses(settings: SsoSettings, site: &str) -> Value { | |
| 167 | + | let addresses = sso_addresses(site, &settings.slug); | |
| 168 | + | let mut out = serde_json::to_value(&settings).unwrap_or_default(); | |
| 169 | + | out["service_provider"] = serde_json::to_value(addresses).unwrap_or_default(); | |
| 170 | + | out | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | pub async fn run(op: SsoOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 174 | + | let Some(actor) = viewer.clone() else { | |
| 175 | + | return Ok(Outcome::fail(FailureCode::Unauthenticated, "This needs a g1t access token.")); | |
| 176 | + | }; | |
| 177 | + | let Some(workspace) = text(input, "workspace").map(|w| w.to_lowercase()) else { | |
| 178 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace.")); | |
| 179 | + | }; | |
| 180 | + | let identity = &services.identity; | |
| 181 | + | let surface = services.audit.surface; | |
| 182 | + | let site = services.addresses.site.as_str(); | |
| 183 | + | let settings = |outcome: Outcome<SsoSettings>| match outcome { | |
| 184 | + | Outcome::Ok(settings) => Outcome::Ok(with_addresses(settings, site)), | |
| 185 | + | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 186 | + | }; | |
| 187 | + | Ok(match op { | |
| 188 | + | SsoOp::GetSso => settings(g1t_kit::call(identity, "sso_settings", &json!({ "actor": actor, "slug": workspace })).await?), | |
| 189 | + | SsoOp::ListSsoIdentities => { | |
| 190 | + | let found: Outcome<SsoSettings> = g1t_kit::call(identity, "sso_settings", &json!({ "actor": actor, "slug": workspace })).await?; | |
| 191 | + | match found { | |
| 192 | + | Outcome::Ok(found) => Outcome::Ok(serde_json::to_value(found.identities).unwrap_or_default()), | |
| 193 | + | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | SsoOp::ConfigureSso => { | |
| 197 | + | let Some(protocol) = text(input, "protocol").as_deref().and_then(SsoProtocol::parse) else { | |
| 198 | + | return Ok(Outcome::fail(FailureCode::Invalid, "protocol is oidc, oauth2 or saml.")); | |
| 199 | + | }; | |
| 200 | + | if input.get("allow_join").is_some_and(|v| !v.is_null()) && flag(input, "allow_join").is_none() { | |
| 201 | + | return Ok(Outcome::fail(FailureCode::Invalid, "allow_join is true or false.")); | |
| 202 | + | } | |
| 203 | + | let saved: Outcome<SsoSettings> = g1t_kit::call( | |
| 204 | + | identity, | |
| 205 | + | "save_sso", | |
| 206 | + | &json!({ | |
| 207 | + | "actor": actor, | |
| 208 | + | "slug": workspace, | |
| 209 | + | "protocol": protocol, | |
| 210 | + | "config": config_of(input), | |
| 211 | + | "client_secret": text(input, "client_secret"), | |
| 212 | + | "certificate": text(input, "certificate"), | |
| 213 | + | "allow_join": flag(input, "allow_join").unwrap_or(true), | |
| 214 | + | "surface": surface, | |
| 215 | + | }), | |
| 216 | + | ) | |
| 217 | + | .await?; | |
| 218 | + | settings(saved) | |
| 219 | + | } | |
| 220 | + | SsoOp::SetSsoRequired => { | |
| 221 | + | let Some(required) = flag(input, "required") else { | |
| 222 | + | return Ok(Outcome::fail(FailureCode::Invalid, "required is true or false.")); | |
| 223 | + | }; | |
| 224 | + | let set: Outcome<bool> = g1t_kit::call( | |
| 225 | + | identity, | |
| 226 | + | "set_sso_required", | |
| 227 | + | &json!({ "actor": actor, "slug": workspace, "required": required, "surface": surface }), | |
| 228 | + | ) | |
| 229 | + | .await?; | |
| 230 | + | match set { | |
| 231 | + | Outcome::Ok(required) => Outcome::Ok(json!({ "required": required })), | |
| 232 | + | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | SsoOp::DeleteSso => { | |
| 236 | + | let removed: Outcome<bool> = | |
| 237 | + | g1t_kit::call(identity, "delete_sso", &json!({ "actor": actor, "slug": workspace, "surface": surface })).await?; | |
| 238 | + | match removed { | |
| 239 | + | Outcome::Ok(removed) => Outcome::Ok(json!({ "deleted": removed })), | |
| 240 | + | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | SsoOp::UnlinkSsoIdentity => { | |
| 244 | + | let Some(username) = text(input, "username") else { | |
| 245 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Name the person to unlink.")); | |
| 246 | + | }; | |
| 247 | + | let unlinked: Outcome<bool> = g1t_kit::call( | |
| 248 | + | identity, | |
| 249 | + | "unlink_sso_identity", | |
| 250 | + | &json!({ "actor": actor, "slug": workspace, "username": username, "surface": surface }), | |
| 251 | + | ) | |
| 252 | + | .await?; | |
| 253 | + | match unlinked { | |
| 254 | + | Outcome::Ok(unlinked) => Outcome::Ok(json!({ "unlinked": unlinked })), | |
| 255 | + | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | }) | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | #[cfg(test)] | |
| 262 | + | mod tests { | |
| 263 | + | use super::*; | |
| 264 | + | use g1t_contracts::scopes::{Level, scope_for}; | |
| 265 | + | ||
| 266 | + | #[test] | |
| 267 | + | fn each_operation_is_described_and_scoped() { | |
| 268 | + | for op in SsoOp::ALL { | |
| 269 | + | assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Sso(op)), "{}", op.name()); | |
| 270 | + | assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name()); | |
| 271 | + | assert!(op.input()["required"].as_array().unwrap().contains(&json!("workspace")), "{}", op.name()); | |
| 272 | + | let level = scope_for(op.name()).unwrap().level(); | |
| 273 | + | assert_eq!(level == Level::Read, !op.writes(), "{}", op.name()); | |
| 274 | + | } | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | #[test] | |
| 278 | + | fn only_the_protocols_fields_are_sent() { | |
| 279 | + | let config = config_of(&json!({ "issuer": " https://acme.okta.com ", "client_id": "c", "client_secret": "s", "certificate": "pem", "nonsense": "x", "scopes": "" })); | |
| 280 | + | assert_eq!(config.issuer.as_deref(), Some("https://acme.okta.com")); | |
| 281 | + | assert_eq!(config.client_id.as_deref(), Some("c")); | |
| 282 | + | assert_eq!(config.scopes, None); | |
| 283 | + | assert!(config.certificates.is_empty()); | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | #[test] | |
| 287 | + | fn the_answer_says_where_to_point_the_provider() { | |
| 288 | + | let settings = SsoSettings { slug: "acme".into(), connection: None, required: false, you_linked: false, identities: vec![] }; | |
| 289 | + | let out = with_addresses(settings, "https://g1t.sh"); | |
| 290 | + | assert_eq!(out["service_provider"]["acs_url"], "https://g1t.sh/auth/sso/acme/saml"); | |
| 291 | + | assert_eq!(out["service_provider"]["redirect_uri"], "https://g1t.sh/auth/sso/acme/callback"); | |
| 292 | + | assert_eq!(out["connection"], Value::Null); | |
| 293 | + | } | |
| 294 | + | } |
| 30 | 30 | use crate::coordination::CoordinationOp; | |
| 31 | 31 | use crate::protection::ProtectionOp; | |
| 32 | 32 | use crate::token_policy::TokenOp; | |
| 33 | + | use crate::sso::SsoOp; | |
| 33 | 34 | use crate::onboarding::OnboardingOp; | |
| 34 | 35 | use crate::operations::Op; | |
| 35 | 36 | use crate::checks::ChecksOp; | |
| ⋯ | |||
| 459 | 460 | a("list_token_requests", Op::Tokens(TokenOp::ListTokenRequests), "Tokens waiting for approval"), | |
| 460 | 461 | a("review_token_request", Op::Tokens(TokenOp::ReviewTokenRequest), "Approve or deny one"), | |
| 461 | 462 | a("revoke_member_token", Op::Tokens(TokenOp::RevokeMemberToken), "Revoke a member's token in it"), | |
| 463 | + | a("get_sso", Op::Sso(SsoOp::GetSso), "Its single sign-on: the provider, whether it is required, who is linked, and where to point the provider"), | |
| 464 | + | a("configure_sso", Op::Sso(SsoOp::ConfigureSso), "Set up or change its single sign-on over OpenID Connect, OAuth 2.0 or SAML"), | |
| 465 | + | a("set_sso_required", Op::Sso(SsoOp::SetSsoRequired), "Require single sign-on of members, or stop"), | |
| 466 | + | a("delete_sso", Op::Sso(SsoOp::DeleteSso), "Remove its single sign-on"), | |
| 467 | + | a("list_sso_identities", Op::Sso(SsoOp::ListSsoIdentities), "The people linked through single sign-on"), | |
| 468 | + | a("unlink_sso_identity", Op::Sso(SsoOp::UnlinkSsoIdentity), "Unlink someone from single sign-on"), | |
| 462 | 469 | ], | |
| 463 | 470 | }, | |
| 464 | 471 | Tool { | |
| ⋯ | |||
| 638 | 645 | | Op::DeleteWorkspace | |
| 639 | 646 | | Op::UpdateWorkspace | |
| 640 | 647 | | Op::Tokens(TokenOp::RevokeMemberToken | TokenOp::SetTokenPolicy) | |
| 648 | + | | Op::Sso(SsoOp::ConfigureSso | SsoOp::DeleteSso | SsoOp::UnlinkSsoIdentity) | |
| 641 | 649 | | Op::RemoveMember | |
| 642 | 650 | | Op::TransferOwnership | |
| 643 | 651 | | Op::LeaveWorkspace | |
| 136 | 136 | `sso.not_required`, `sso.identity_linked`, `sso.identity_unlinked`, and | |
| 137 | 137 | `member.joined_by_sso`. | |
| 138 | 138 | ||
| 139 | + | ## API and MCP | |
| 140 | + | ||
| 141 | + | Owners can do all of it with an access token, through the REST API or the | |
| 142 | + | `workspace` tool's actions on the MCP server: | |
| 143 | + | ||
| 144 | + | | | REST | MCP action | Scope | | |
| 145 | + | | --- | --- | --- | --- | | |
| 146 | + | | Read it, with where to point the provider | `GET /workspaces/{workspace}/sso` | `get_sso` | `workspace:read` | | |
| 147 | + | | Set it up or change it | `PUT /workspaces/{workspace}/sso` | `configure_sso` | `workspace:admin` | | |
| 148 | + | | Require it, or stop | `PUT /workspaces/{workspace}/sso/required` | `set_sso_required` | `workspace:admin` | | |
| 149 | + | | Remove it | `DELETE /workspaces/{workspace}/sso` | `delete_sso` | `workspace:admin` | | |
| 150 | + | | The linked people | `GET /workspaces/{workspace}/sso/identities` | `list_sso_identities` | `access:read` | | |
| 151 | + | | Unlink someone | `DELETE /workspaces/{workspace}/sso/identities/{username}` | `unlink_sso_identity` | `access:admin` | | |
| 152 | + | ||
| 153 | + | `configure_sso` takes `protocol` (`oidc`, `oauth2` or `saml`) and the | |
| 154 | + | protocol's fields as the page names them: `issuer`, `client_id`, | |
| 155 | + | `client_secret`, `scopes`; `authorization_url`, `token_url`, | |
| 156 | + | `userinfo_url`, `subject_claim`, `email_claim`; `idp_entity_id`, | |
| 157 | + | `idp_sso_url`, `certificate` (PEM). Its answer, like `get_sso`'s, has | |
| 158 | + | `service_provider`: the `redirect_uri`, `entity_id`, `acs_url`, | |
| 159 | + | `metadata_url` and `sign_in_url` to give your provider and your people. | |
| 160 | + | The client secret is never returned. | |
| 161 | + | ||
| 162 | + | ```sh | |
| 163 | + | curl -X PUT https://api.g1t.sh/workspaces/acme/sso -H "Authorization: Bearer $G1T_TOKEN" -d '{"protocol": "oidc", "issuer": "https://acme.okta.com", "client_id": "0oa1b2c3", "client_secret": "…"}' | |
| 164 | + | ``` | |
| 165 | + | ||
| 166 | + | Signing in through the provider is a trip in the browser, so it has no | |
| 167 | + | API: send people to `sign_in_url`. | |
| 168 | + | ||
| 139 | 169 | ## Not yet | |
| 140 | 170 | ||
| 141 | 171 | Provisioning and deprovisioning with SCIM is not available yet. Until it |
Binary or large file; its contents are not shown.
| 1 | + | import { Link } from "react-router"; | |
| 2 | + | ||
| 3 | + | import type { SsoSettings } from "@g1t/contracts"; | |
| 4 | + | ||
| 5 | + | import { Badge, EmptyState, Notice, Section, When } from "~/components/ui"; | |
| 6 | + | ||
| 7 | + | const PROTOCOLS = { oidc: "OpenID Connect", oauth2: "OAuth 2.0", saml: "SAML 2.0" } as const; | |
| 8 | + | ||
| 9 | + | /** Which provider a connection is, by its own address. */ | |
| 10 | + | function provider(sso: NonNullable<SsoSettings["connection"]>): string { | |
| 11 | + | const config = sso.config; | |
| 12 | + | return config.issuer ?? config.idp_entity_id ?? config.authorization_url ?? "—"; | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | /** | |
| 16 | + | * A workspace's single sign-on, as its owners see it: the provider, whether | |
| 17 | + | * it is required, and who is linked. Read only: owners change it on the | |
| 18 | + | * site, and are never held out, so staff never need to. | |
| 19 | + | */ | |
| 20 | + | export function SsoSection({ sso, unavailable }: { sso: SsoSettings | null; unavailable: string | null }) { | |
| 21 | + | const connection = sso?.connection ?? null; | |
| 22 | + | return ( | |
| 23 | + | <Section | |
| 24 | + | id="sso" | |
| 25 | + | title="Single sign-on" | |
| 26 | + | description="The workspace's own identity provider. Owners set it up and require it at /-/sso; they are never held out." | |
| 27 | + | className="mt-6" | |
| 28 | + | > | |
| 29 | + | {unavailable ? ( | |
| 30 | + | <Notice tone="error">Single sign-on could not be read: {unavailable}</Notice> | |
| 31 | + | ) : !connection ? ( | |
| 32 | + | <EmptyState title="Not set up" /> | |
| 33 | + | ) : ( | |
| 34 | + | <div className="space-y-5"> | |
| 35 | + | <dl className="grid gap-x-6 gap-y-3 text-sm sm:grid-cols-2"> | |
| 36 | + | <div> | |
| 37 | + | <dt className="text-xs text-faint">Protocol</dt> | |
| 38 | + | <dd className="mt-0.5 flex flex-wrap items-center gap-1.5"> | |
| 39 | + | {PROTOCOLS[connection.protocol]} | |
| 40 | + | {sso?.required ? <Badge tone="lavender">Required of members</Badge> : <Badge>Not required</Badge>} | |
| 41 | + | {connection.allow_join && <Badge tone="info">Provider's people join</Badge>} | |
| 42 | + | </dd> | |
| 43 | + | </div> | |
| 44 | + | <div> | |
| 45 | + | <dt className="text-xs text-faint">Provider</dt> | |
| 46 | + | <dd className="mt-0.5 font-mono text-xs break-all">{provider(connection)}</dd> | |
| 47 | + | </div> | |
| 48 | + | <div> | |
| 49 | + | <dt className="text-xs text-faint">Tested</dt> | |
| 50 | + | <dd className="mt-0.5"> | |
| 51 | + | {connection.tested_at ? ( | |
| 52 | + | <> | |
| 53 | + | By{" "} | |
| 54 | + | {connection.tested_by ? ( | |
| 55 | + | <Link to={`/users/${connection.tested_by}`} className="font-mono hover:underline"> | |
| 56 | + | {connection.tested_by} | |
| 57 | + | </Link> | |
| 58 | + | ) : ( | |
| 59 | + | "someone" | |
| 60 | + | )}{" "} | |
| 61 | + | <When at={connection.tested_at} /> | |
| 62 | + | </> | |
| 63 | + | ) : ( | |
| 64 | + | <Badge tone="warn">Not yet</Badge> | |
| 65 | + | )} | |
| 66 | + | </dd> | |
| 67 | + | </div> | |
| 68 | + | <div> | |
| 69 | + | <dt className="text-xs text-faint">Set up</dt> | |
| 70 | + | <dd className="mt-0.5"> | |
| 71 | + | <When at={connection.created_at} />, changed <When at={connection.updated_at} /> | |
| 72 | + | </dd> | |
| 73 | + | </div> | |
| 74 | + | {connection.protocol !== "saml" && ( | |
| 75 | + | <div> | |
| 76 | + | <dt className="text-xs text-faint">Client id</dt> | |
| 77 | + | <dd className="mt-0.5 font-mono text-xs break-all"> | |
| 78 | + | {connection.config.client_id ?? "—"} {connection.has_client_secret ? "(secret kept)" : "(no secret)"} | |
| 79 | + | </dd> | |
| 80 | + | </div> | |
| 81 | + | )} | |
| 82 | + | {connection.certificates.length > 0 && ( | |
| 83 | + | <div> | |
| 84 | + | <dt className="text-xs text-faint">Signing certificates, SHA-256</dt> | |
| 85 | + | <dd className="mt-0.5 space-y-0.5 font-mono text-xs break-all"> | |
| 86 | + | {connection.certificates.map((certificate) => ( | |
| 87 | + | <p key={certificate.fingerprint}>{certificate.fingerprint}</p> | |
| 88 | + | ))} | |
| 89 | + | </dd> | |
| 90 | + | </div> | |
| 91 | + | )} | |
| 92 | + | </dl> | |
| 93 | + | {sso!.identities.length === 0 ? ( | |
| 94 | + | <p className="text-sm text-muted">No one has signed in through it yet.</p> | |
| 95 | + | ) : ( | |
| 96 | + | <div className="-mx-4 -mb-4 overflow-x-auto border-t border-line sm:-mx-5 sm:-mb-5"> | |
| 97 | + | <table className="w-full min-w-120 text-sm"> | |
| 98 | + | <thead> | |
| 99 | + | <tr className="border-b border-line text-left text-xs text-muted"> | |
| 100 | + | <th className="px-4 py-2 font-medium sm:pl-5">Person</th> | |
| 101 | + | <th className="px-4 py-2 font-medium">At the provider</th> | |
| 102 | + | <th className="px-4 py-2 font-medium">Linked</th> | |
| 103 | + | <th className="px-4 py-2 font-medium sm:pr-5">Last signed in</th> | |
| 104 | + | </tr> | |
| 105 | + | </thead> | |
| 106 | + | <tbody> | |
| 107 | + | {sso!.identities.map((identity) => ( | |
| 108 | + | <tr key={identity.username} className="border-b border-line last:border-0"> | |
| 109 | + | <td className="px-4 py-2.5 font-mono sm:pl-5"> | |
| 110 | + | <Link to={`/users/${identity.username}`} className="hover:underline"> | |
| 111 | + | {identity.username} | |
| 112 | + | </Link> | |
| 113 | + | </td> | |
| 114 | + | <td className="px-4 py-2.5 break-all text-muted">{identity.email ?? identity.subject}</td> | |
| 115 | + | <td className="px-4 py-2.5 text-xs whitespace-nowrap text-muted"> | |
| 116 | + | <When at={identity.linked_at} /> | |
| 117 | + | </td> | |
| 118 | + | <td className="px-4 py-2.5 text-xs whitespace-nowrap text-muted sm:pr-5"> | |
| 119 | + | <When at={identity.last_used_at} /> | |
| 120 | + | </td> | |
| 121 | + | </tr> | |
| 122 | + | ))} | |
| 123 | + | </tbody> | |
| 124 | + | </table> | |
| 125 | + | </div> | |
| 126 | + | )} | |
| 127 | + | </div> | |
| 128 | + | )} | |
| 129 | + | </Section> | |
| 130 | + | ); | |
| 131 | + | } |
| 42 | 42 | } from "~/components/ui"; | |
| 43 | 43 | import { type Subject, billingAction } from "~/lib/billing-actions.server"; | |
| 44 | 44 | import { ComplimentarySection } from "~/components/complimentary"; | |
| 45 | + | import { SsoSection } from "~/components/sso"; | |
| 45 | 46 | import { parseSlug, text } from "~/lib/forms"; | |
| 46 | 47 | import { usd } from "~/lib/money"; | |
| 47 | 48 | import { type ActionData, DONE, type SectionError, doneKey } from "~/lib/review"; | |
| ⋯ | |||
| 94 | 95 | const figures = billedTo | |
| 95 | 96 | ? { charged: share?.chargedMicros ?? 0, cost: share?.costMicros ?? 0, paid: share?.paidMicros ?? 0 } | |
| 96 | 97 | : { charged: summary.chargedMicros, cost: summary.costMicros, paid: summary.paidMicros }; | |
| 97 | − | const [enterprises, sales, invoices, plan, overages, book, credits, complimentary] = await Promise.all([ | |
| 98 | + | const [enterprises, sales, invoices, plan, overages, book, credits, complimentary, sso] = await Promise.all([ | |
| 98 | 99 | billedTo | |
| 99 | 100 | ? Promise.resolve([]) | |
| 100 | 101 | : admin | |
| ⋯ | |||
| 111 | 112 | settle(priceBook()), | |
| 112 | 113 | settle(admin.credits({ workspace: slug })), | |
| 113 | 114 | settle(admin.complimentary(slug)), | |
| 115 | + | settle(identity.sso(slug)), | |
| 114 | 116 | ]); | |
| 115 | 117 | const done = doneKey(request.url); | |
| 116 | 118 | return { | |
| ⋯ | |||
| 139 | 141 | creditsError: credits.ok ? null : credits.error, | |
| 140 | 142 | complimentary: complimentary.ok ? complimentary.value : null, | |
| 141 | 143 | complimentaryError: complimentary.ok ? null : complimentary.error, | |
| 144 | + | sso: sso.ok ? sso.value : null, | |
| 145 | + | ssoError: sso.ok ? null : sso.error, | |
| 142 | 146 | ledger: billedTo ? detail.ledger.filter((entry) => !entry.workspace || entry.workspace === slug) : detail.ledger, | |
| 143 | 147 | audit: billedTo ? detail.audit.filter((entry) => mentions(entry, slug)) : detail.audit, | |
| 144 | 148 | done: done && !SALES_DONE.has(done) ? DONE[done] : null, | |
| ⋯ | |||
| 159 | 163 | ||
| 160 | 164 | const SECTIONS = [ | |
| 161 | 165 | { id: "members", label: "Members" }, | |
| 166 | + | { id: "sso", label: "Single sign-on" }, | |
| 162 | 167 | { id: "sales", label: "Sales" }, | |
| 163 | 168 | { id: "plan", label: "Plan" }, | |
| 164 | 169 | { id: "billing", label: "Billing" }, | |
| ⋯ | |||
| 193 | 198 | creditsError, | |
| 194 | 199 | complimentary, | |
| 195 | 200 | complimentaryError, | |
| 201 | + | sso, | |
| 202 | + | ssoError, | |
| 196 | 203 | ledger, | |
| 197 | 204 | audit, | |
| 198 | 205 | done, | |
| ⋯ | |||
| 311 | 318 | )} | |
| 312 | 319 | </Section> | |
| 313 | 320 | ||
| 321 | + | <SsoSection sso={sso} unavailable={ssoError} /> | |
| 322 | + | ||
| 314 | 323 | {/* Sales */} | |
| 315 | 324 | <div className="mt-6"> | |
| 316 | 325 | <SalesSection | |
| 1308 | 1308 | ("list_token_requests", Scope::AccessRead), | |
| 1309 | 1309 | ("review_token_request", Scope::AccessAdmin), | |
| 1310 | 1310 | ("revoke_member_token", Scope::AccessAdmin), | |
| 1311 | + | // A workspace's single sign-on: its settings, and who is linked. | |
| 1312 | + | ("get_sso", Scope::WorkspaceRead), | |
| 1313 | + | ("configure_sso", Scope::WorkspaceAdmin), | |
| 1314 | + | ("set_sso_required", Scope::WorkspaceAdmin), | |
| 1315 | + | ("delete_sso", Scope::WorkspaceAdmin), | |
| 1316 | + | ("list_sso_identities", Scope::AccessRead), | |
| 1317 | + | ("unlink_sso_identity", Scope::AccessAdmin), | |
| 1311 | 1318 | // Who has access. | |
| 1312 | 1319 | ("list_collaborators", Scope::AccessRead), | |
| 1313 | 1320 | ("get_collaborator_permission", Scope::AccessRead), |
| 281 | 281 | pub entity_id: String, | |
| 282 | 282 | pub acs_url: String, | |
| 283 | 283 | } | |
| 284 | + | ||
| 285 | + | /// Where a workspace's provider is pointed, from the site's address: g1t's | |
| 286 | + | /// entity id (its metadata's address), the assertion consumer service, and | |
| 287 | + | /// the callback for OpenID Connect and OAuth 2.0. Mirrors `ssoAddresses` | |
| 288 | + | /// in `packages/contracts/src/sso.ts`. | |
| 289 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 290 | + | pub struct SsoAddresses { | |
| 291 | + | pub redirect_uri: String, | |
| 292 | + | pub entity_id: String, | |
| 293 | + | pub acs_url: String, | |
| 294 | + | pub metadata_url: String, | |
| 295 | + | /// Where people start signing in through it. | |
| 296 | + | pub sign_in_url: String, | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | pub fn sso_addresses(site: &str, slug: &str) -> SsoAddresses { | |
| 300 | + | let start = format!("{}/auth/sso/{}", site.trim_end_matches('/'), slug.trim().to_lowercase()); | |
| 301 | + | SsoAddresses { | |
| 302 | + | redirect_uri: format!("{start}/callback"), | |
| 303 | + | entity_id: format!("{start}/metadata"), | |
| 304 | + | acs_url: format!("{start}/saml"), | |
| 305 | + | metadata_url: format!("{start}/metadata"), | |
| 306 | + | sign_in_url: start, | |
| 307 | + | } | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | /// `admin_sso`: a workspace's single sign-on, for staff in sudo: as its | |
| 311 | + | /// owners see it, or `None` when it has none. Takes `SlugArgs`. | |
| 312 | + | pub type AdminSso = Option<SsoSettings>; | |
| 313 | + | ||
| 314 | + | #[cfg(test)] | |
| 315 | + | mod tests { | |
| 316 | + | use super::*; | |
| 317 | + | ||
| 318 | + | #[test] | |
| 319 | + | fn addresses_are_the_workspaces_own() { | |
| 320 | + | let at = sso_addresses("https://g1t.sh/", " Acme "); | |
| 321 | + | assert_eq!(at.redirect_uri, "https://g1t.sh/auth/sso/acme/callback"); | |
| 322 | + | assert_eq!(at.entity_id, "https://g1t.sh/auth/sso/acme/metadata"); | |
| 323 | + | assert_eq!(at.acs_url, "https://g1t.sh/auth/sso/acme/saml"); | |
| 324 | + | assert_eq!(at.sign_in_url, "https://g1t.sh/auth/sso/acme"); | |
| 325 | + | } | |
| 326 | + | } |
| 6398 | 6398 | ], | |
| 6399 | 6399 | }, | |
| 6400 | 6400 | Operation { | |
| 6401 | + | id: "configure_sso", | |
| 6402 | + | method: "PUT", | |
| 6403 | + | path: "/workspaces/{workspace}/sso", | |
| 6404 | + | tag: "Single sign-on", | |
| 6405 | + | summary: "Set up or change a workspace's single sign-on", | |
| 6406 | + | scope: Some("workspace:admin"), | |
| 6407 | + | params: &[ | |
| 6408 | + | Param { name: "workspace", location: In::Path, kind: "string", required: true, values: &[] }, | |
| 6409 | + | ], | |
| 6410 | + | body: &[ | |
| 6411 | + | Field { name: "allow_join", kind: "boolean", required: false, values: &[] }, | |
| 6412 | + | Field { name: "authorization_url", kind: "string", required: false, values: &[] }, | |
| 6413 | + | Field { name: "certificate", kind: "string", required: false, values: &[] }, | |
| 6414 | + | Field { name: "client_id", kind: "string", required: false, values: &[] }, | |
| 6415 | + | Field { name: "client_secret", kind: "string", required: false, values: &[] }, | |
| 6416 | + | Field { name: "email_claim", kind: "string", required: false, values: &[] }, | |
| 6417 | + | Field { name: "idp_entity_id", kind: "string", required: false, values: &[] }, | |
| 6418 | + | Field { name: "idp_sso_url", kind: "string", required: false, values: &[] }, | |
| 6419 | + | Field { name: "issuer", kind: "string", required: false, values: &[] }, | |
| 6420 | + | Field { name: "protocol", kind: "string", required: true, values: &["oidc", "oauth2", "saml"] }, | |
| 6421 | + | Field { name: "scopes", kind: "string", required: false, values: &[] }, | |
| 6422 | + | Field { name: "subject_claim", kind: "string", required: false, values: &[] }, | |
| 6423 | + | Field { name: "token_url", kind: "string", required: false, values: &[] }, | |
| 6424 | + | Field { name: "userinfo_url", kind: "string", required: false, values: &[] }, | |
| 6425 | + | ], | |
| 6426 | + | }, | |
| 6427 | + | Operation { | |
| 6428 | + | id: "delete_sso", | |
| 6429 | + | method: "DELETE", | |
| 6430 | + | path: "/workspaces/{workspace}/sso", | |
| 6431 | + | tag: "Single sign-on", | |
| 6432 | + | summary: "Remove a workspace's single sign-on", | |
| 6433 | + | scope: Some("workspace:admin"), | |
| 6434 | + | params: &[ | |
| 6435 | + | Param { name: "workspace", location: In::Path, kind: "string", required: true, values: &[] }, | |
| 6436 | + | ], | |
| 6437 | + | body: &[], | |
| 6438 | + | }, | |
| 6439 | + | Operation { | |
| 6440 | + | id: "get_sso", | |
| 6441 | + | method: "GET", | |
| 6442 | + | path: "/workspaces/{workspace}/sso", | |
| 6443 | + | tag: "Single sign-on", | |
| 6444 | + | summary: "Get a workspace's single sign-on", | |
| 6445 | + | scope: Some("workspace:read"), | |
| 6446 | + | params: &[ | |
| 6447 | + | Param { name: "workspace", location: In::Path, kind: "string", required: true, values: &[] }, | |
| 6448 | + | ], | |
| 6449 | + | body: &[], | |
| 6450 | + | }, | |
| 6451 | + | Operation { | |
| 6452 | + | id: "list_sso_identities", | |
| 6453 | + | method: "GET", | |
| 6454 | + | path: "/workspaces/{workspace}/sso/identities", | |
| 6455 | + | tag: "Single sign-on", | |
| 6456 | + | summary: "List the people linked through single sign-on", | |
| 6457 | + | scope: Some("access:read"), | |
| 6458 | + | params: &[ | |
| 6459 | + | Param { name: "workspace", location: In::Path, kind: "string", required: true, values: &[] }, | |
| 6460 | + | ], | |
| 6461 | + | body: &[], | |
| 6462 | + | }, | |
| 6463 | + | Operation { | |
| 6464 | + | id: "set_sso_required", | |
| 6465 | + | method: "PUT", | |
| 6466 | + | path: "/workspaces/{workspace}/sso/required", | |
| 6467 | + | tag: "Single sign-on", | |
| 6468 | + | summary: "Require single sign-on, or stop", | |
| 6469 | + | scope: Some("workspace:admin"), | |
| 6470 | + | params: &[ | |
| 6471 | + | Param { name: "workspace", location: In::Path, kind: "string", required: true, values: &[] }, | |
| 6472 | + | ], | |
| 6473 | + | body: &[ | |
| 6474 | + | Field { name: "required", kind: "boolean", required: true, values: &[] }, | |
| 6475 | + | ], | |
| 6476 | + | }, | |
| 6477 | + | Operation { | |
| 6478 | + | id: "unlink_sso_identity", | |
| 6479 | + | method: "DELETE", | |
| 6480 | + | path: "/workspaces/{workspace}/sso/identities/{username}", | |
| 6481 | + | tag: "Single sign-on", | |
| 6482 | + | summary: "Unlink someone from single sign-on", | |
| 6483 | + | scope: Some("access:admin"), | |
| 6484 | + | params: &[ | |
| 6485 | + | Param { name: "workspace", location: In::Path, kind: "string", required: true, values: &[] }, | |
| 6486 | + | Param { name: "username", location: In::Path, kind: "string", required: true, values: &[] }, | |
| 6487 | + | ], | |
| 6488 | + | body: &[], | |
| 6489 | + | }, | |
| 6490 | + | Operation { | |
| 6401 | 6491 | id: "check_starred", | |
| 6402 | 6492 | method: "GET", | |
| 6403 | 6493 | path: "/user/starred/{owner}/{name}", |
| 338 | 338 | growth: () => call("admin_growth", {}), | |
| 339 | 339 | workspaces: (query) => call("admin_workspaces", { query: query ?? null }), | |
| 340 | 340 | workspace: (slug) => call("admin_workspace", { slug }), | |
| 341 | + | sso: (slug) => call("admin_sso", { slug }), | |
| 341 | 342 | invites: (query, options = {}) => call("admin_invites", { query: query ?? null, to_g1t: options.toG1t ?? false }), | |
| 342 | 343 | grantInvites: (username, amount, note, staff) => call("admin_grant_invites", { username, amount, note, staff }), | |
| 343 | 344 | revokeInvite: (id, staff) => call("admin_revoke_invite", { id, staff }), |
| 9 | 9 | import type { PeopleClient } from "./people"; | |
| 10 | 10 | import type { DeployKeysClient } from "./deploy-keys"; | |
| 11 | 11 | import type { EmailConfirmed, EmailOwner } from "./accounts"; | |
| 12 | + | import type { SsoSettings } from "./sso"; | |
| 12 | 13 | ||
| 13 | 14 | export type User = { | |
| 14 | 15 | id: string; | |
| ⋯ | |||
| 395 | 396 | workspaces(query?: string): Promise<AdminWorkspace[]>; | |
| 396 | 397 | /** One workspace with all its members, or null. */ | |
| 397 | 398 | workspace(slug: string): Promise<AdminWorkspaceDetail | null>; | |
| 399 | + | /** A workspace's single sign-on as its owners see it (sso.ts), or null when it has none. */ | |
| 400 | + | sso(slug: string): Promise<SsoSettings | null>; | |
| 398 | 401 | ||
| 399 | 402 | /** Invites, newest first; `query` is a code's start, or part of an email, inviter or redeemer. */ | |
| 400 | 403 | invites(query?: string | null, options?: { toG1t?: boolean }): Promise<Invite[]>; | |
Comments
Sign in to comment.