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.

Commit

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
syntaqxcommitted Parentfabf299Browse files
19 files+795−33 files not counted0/19 viewed
+1−0
4848 mod tools;
4949 mod why;
5050 mod token_policy;
51+mod sso;
5152 mod onboarding;
5253 mod toolkit;
5354
+14−0
1919 use crate::coordination::CoordinationOp;
2020 use crate::protection::ProtectionOp;
2121 use crate::token_policy::TokenOp;
22+use crate::sso::SsoOp;
2223 use crate::onboarding::OnboardingOp;
2324 use crate::operations::Op;
2425 use crate::checks::ChecksOp;
8788 ],
8889 ),
8990 (
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+ (
90103 "Members",
91104 "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.",
92105 &[Op::ListMembers, Op::UpdateMember, Op::RemoveMember, Op::TransferOwnership, Op::LeaveWorkspace],
881894 Op::Telemetry(op) => op.title(),
882895 Op::Protection(op) => op.title(),
883896 Op::Tokens(op) => op.title(),
897+ Op::Sso(op) => op.title(),
884898 Op::Onboarding(op) => op.title(),
885899 Op::Artifacts(op) => op.title(),
886900 Op::Caches(op) => op.title(),
+15−2
4141 use crate::coordination::CoordinationOp;
4242 use crate::protection::ProtectionOp;
4343 use crate::token_policy::TokenOp;
44+use crate::sso::SsoOp;
4445 use crate::onboarding::OnboardingOp;
4546 use crate::rules::RulesOp;
4647 use crate::security::SecurityOp;
345346 /// A workspace's rules for personal access tokens, its members'
346347 /// tokens and approving them: token_policy.rs.
347348 Tokens(TokenOp),
349+ /// A workspace's single sign-on through its own identity provider: sso.rs.
350+ Sso(SsoOp),
348351 /// A person's own Getting started in a workspace: onboarding.rs.
349352 Onboarding(OnboardingOp),
350353 /// Workflow run artifacts, and how long they are kept: run_artifacts.rs.
828831 }
829832
830833 impl Op {
831− pub const ALL: [Op; 409] = [
834+ pub const ALL: [Op; 415] = [
832835 Op::Whoami,
833836 Op::GetWorkspace,
834837 Op::CreateWorkspace,
11901193 Op::Tokens(TokenOp::ListTokenRequests),
11911194 Op::Tokens(TokenOp::ReviewTokenRequest),
11921195 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),
11931202 Op::Onboarding(OnboardingOp::GetOnboarding),
11941203 Op::Onboarding(OnboardingOp::UpdateOnboarding),
11951204 Op::Packages(PackagesOp::ListPackages),
14641473 Op::Telemetry(op) => op.name(),
14651474 Op::Protection(op) => op.name(),
14661475 Op::Tokens(op) => op.name(),
1476+ Op::Sso(op) => op.name(),
14671477 Op::Onboarding(op) => op.name(),
14681478 Op::Artifacts(op) => op.name(),
14691479 Op::Caches(op) => op.name(),
20702080 Op::Telemetry(op) => op.description(),
20712081 Op::Protection(op) => op.description(),
20722082 Op::Tokens(op) => op.description(),
2083+ Op::Sso(op) => op.description(),
20732084 Op::Onboarding(op) => op.description(),
20742085 Op::Artifacts(op) => op.description(),
20752086 Op::Caches(op) => op.description(),
37533764 Op::Telemetry(op) => op.input(),
37543765 Op::Protection(op) => op.input(),
37553766 Op::Tokens(op) => op.input(),
3767+ Op::Sso(op) => op.input(),
37563768 Op::Onboarding(op) => op.input(),
37573769 Op::Artifacts(op) => op.input(),
37583770 Op::Caches(op) => op.input(),
38873899 return op.needs_repo();
38883900 }
38893901 // 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 {
38913903 return false;
38923904 }
38933905 !matches!(
63986410 Op::Telemetry(op) => crate::telemetry::run(op, services, viewer, input).await,
63996411 Op::Protection(op) => crate::protection::run(op, services, viewer, input).await,
64006412 Op::Tokens(op) => crate::token_policy::run(op, services, viewer, input).await,
6413+ Op::Sso(op) => crate::sso::run(op, services, viewer, input).await,
64016414 Op::Onboarding(op) => crate::onboarding::run(op, services, viewer, input).await,
64026415 Op::Artifacts(op) => crate::run_artifacts::run(op, services, viewer, input).await,
64036416 Op::Caches(op) => crate::caches::run(op, services, viewer, input).await,
+141−0
1516815168 "stored_bytes": 1210443776
1516915169 },
1517015170 "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+ }
1517115312 }
1517215313 }
+7−0
1414 use crate::coordination::CoordinationOp;
1515 use crate::protection::ProtectionOp;
1616 use crate::token_policy::TokenOp;
17+use crate::sso::SsoOp;
1718 use crate::onboarding::OnboardingOp;
1819 use crate::operations::Op;
1920 use crate::checks::ChecksOp;
7374 route("POST", "/workspaces/:workspace/personal-access-tokens/:id", Op::Tokens(TokenOp::RevokeMemberToken), &[]),
7475 route("GET", "/workspaces/:workspace/personal-access-token-requests", Op::Tokens(TokenOp::ListTokenRequests), &[]),
7576 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), &[]),
7683 route("POST", "/workspaces/:workspace/invitations", Op::InviteMember, &[]),
7784 route("DELETE", "/workspaces/:workspace/invitations/:id", Op::RevokeWorkspaceInvite, &[]),
7885 // Who has access. GitHub's addresses, but for adding someone, which
+294−0
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+}
+8−0
3030 use crate::coordination::CoordinationOp;
3131 use crate::protection::ProtectionOp;
3232 use crate::token_policy::TokenOp;
33+use crate::sso::SsoOp;
3334 use crate::onboarding::OnboardingOp;
3435 use crate::operations::Op;
3536 use crate::checks::ChecksOp;
459460 a("list_token_requests", Op::Tokens(TokenOp::ListTokenRequests), "Tokens waiting for approval"),
460461 a("review_token_request", Op::Tokens(TokenOp::ReviewTokenRequest), "Approve or deny one"),
461462 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"),
462469 ],
463470 },
464471 Tool {
638645 | Op::DeleteWorkspace
639646 | Op::UpdateWorkspace
640647 | Op::Tokens(TokenOp::RevokeMemberToken | TokenOp::SetTokenPolicy)
648+ | Op::Sso(SsoOp::ConfigureSso | SsoOp::DeleteSso | SsoOp::UnlinkSsoIdentity)
641649 | Op::RemoveMember
642650 | Op::TransferOwnership
643651 | Op::LeaveWorkspace
+30−0
136136 `sso.not_required`, `sso.identity_linked`, `sso.identity_unlinked`, and
137137 `member.joined_by_sso`.
138138
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+
139169 ## Not yet
140170
141171 Provisioning and deprovisioning with SCIM is not available yet. Until it
+0−0

Binary or large file; its contents are not shown.

+131−0
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+}
+10−1
4242 } from "~/components/ui";
4343 import { type Subject, billingAction } from "~/lib/billing-actions.server";
4444 import { ComplimentarySection } from "~/components/complimentary";
45+import { SsoSection } from "~/components/sso";
4546 import { parseSlug, text } from "~/lib/forms";
4647 import { usd } from "~/lib/money";
4748 import { type ActionData, DONE, type SectionError, doneKey } from "~/lib/review";
9495 const figures = billedTo
9596 ? { charged: share?.chargedMicros ?? 0, cost: share?.costMicros ?? 0, paid: share?.paidMicros ?? 0 }
9697 : { 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([
9899 billedTo
99100 ? Promise.resolve([])
100101 : admin
111112 settle(priceBook()),
112113 settle(admin.credits({ workspace: slug })),
113114 settle(admin.complimentary(slug)),
115+ settle(identity.sso(slug)),
114116 ]);
115117 const done = doneKey(request.url);
116118 return {
139141 creditsError: credits.ok ? null : credits.error,
140142 complimentary: complimentary.ok ? complimentary.value : null,
141143 complimentaryError: complimentary.ok ? null : complimentary.error,
144+ sso: sso.ok ? sso.value : null,
145+ ssoError: sso.ok ? null : sso.error,
142146 ledger: billedTo ? detail.ledger.filter((entry) => !entry.workspace || entry.workspace === slug) : detail.ledger,
143147 audit: billedTo ? detail.audit.filter((entry) => mentions(entry, slug)) : detail.audit,
144148 done: done && !SALES_DONE.has(done) ? DONE[done] : null,
159163
160164 const SECTIONS = [
161165 { id: "members", label: "Members" },
166+ { id: "sso", label: "Single sign-on" },
162167 { id: "sales", label: "Sales" },
163168 { id: "plan", label: "Plan" },
164169 { id: "billing", label: "Billing" },
193198 creditsError,
194199 complimentary,
195200 complimentaryError,
201+ sso,
202+ ssoError,
196203 ledger,
197204 audit,
198205 done,
311318 )}
312319 </Section>
313320
321+ <SsoSection sso={sso} unavailable={ssoError} />
322+
314323 {/* Sales */}
315324 <div className="mt-6">
316325 <SalesSection
+7−0
13081308 ("list_token_requests", Scope::AccessRead),
13091309 ("review_token_request", Scope::AccessAdmin),
13101310 ("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),
13111318 // Who has access.
13121319 ("list_collaborators", Scope::AccessRead),
13131320 ("get_collaborator_permission", Scope::AccessRead),
+43−0
281281 pub entity_id: String,
282282 pub acs_url: String,
283283 }
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+}
+90−0
63986398 ],
63996399 },
64006400 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 {
64016491 id: "check_starred",
64026492 method: "GET",
64036493 path: "/user/starred/{owner}/{name}",
+1−0
338338 growth: () => call("admin_growth", {}),
339339 workspaces: (query) => call("admin_workspaces", { query: query ?? null }),
340340 workspace: (slug) => call("admin_workspace", { slug }),
341+ sso: (slug) => call("admin_sso", { slug }),
341342 invites: (query, options = {}) => call("admin_invites", { query: query ?? null, to_g1t: options.toG1t ?? false }),
342343 grantInvites: (username, amount, note, staff) => call("admin_grant_invites", { username, amount, note, staff }),
343344 revokeInvite: (id, staff) => call("admin_revoke_invite", { id, staff }),
+3−0
99 import type { PeopleClient } from "./people";
1010 import type { DeployKeysClient } from "./deploy-keys";
1111 import type { EmailConfirmed, EmailOwner } from "./accounts";
12+import type { SsoSettings } from "./sso";
1213
1314 export type User = {
1415 id: string;
395396 workspaces(query?: string): Promise<AdminWorkspace[]>;
396397 /** One workspace with all its members, or null. */
397398 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>;
398401
399402 /** Invites, newest first; `query` is a code's start, or part of an email, inviter or redeemer. */
400403 invites(query?: string | null, options?: { toG1t?: boolean }): Promise<Invite[]>;
Loading the diff…
Loading the diff…
Loading the diff…

Comments

Sign in to comment.