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 workspace single sign-on on every paid plan

A workspace connects its own identity provider over OpenID Connect, OAuth 2.0 or SAML 2.0 at Workspace -> Access -> Single sign-on. Any paid plan has it at no extra price; a free workspace sees the page disabled with a way to start the plan. It never signs anyone in to g1t: people sign in as themselves, then through the workspace's provider, which links who they are there to their account (one each way). Owners test it by signing in through it, can let the provider's people join without an invitation, and can require it: members then use the workspace only from a session that went through the provider in the last day, or with tokens and keys once linked. Owners are never held out, so a broken provider cannot lock a workspace out. SAML responses are verified in identity (exclusive c14n, RSA-SHA2, one reference to the signed parent, unique IDs, no DTD, SP-initiated only) and checked against signxml- and libxml2-signed fixtures. The provider's cross-site POST is answered in the Worker ahead of React Router. SCIM is not part of this. Closes #56

G1t-Session:
ses_01m54fhewne5tbgqqh4n8f5fvz
syntaqxcommitted Parent613208bBrowse files
37 files+3918−650/37 viewed
+185−36
671671
672672 [[package]]
673673 name = "const-oid"
674+version = "0.9.6"
675+source = "registry+https://github.com/rust-lang/crates.io-index"
676+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
677+
678+[[package]]
679+name = "const-oid"
674680 version = "0.10.2"
675681 source = "registry+https://github.com/rust-lang/crates.io-index"
676682 checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
901907
902908 [[package]]
903909 name = "der"
910+version = "0.7.10"
911+source = "registry+https://github.com/rust-lang/crates.io-index"
912+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
913+dependencies = [
914+ "const-oid 0.9.6",
915+ "zeroize",
916+]
917+
918+[[package]]
919+name = "der"
904920 version = "0.8.2"
905921 source = "registry+https://github.com/rust-lang/crates.io-index"
906922 checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a"
907923 dependencies = [
908− "const-oid",
924+ "const-oid 0.10.2",
909925 "pem-rfc7468",
910926 "zeroize",
911927 ]
932948 checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
933949 dependencies = [
934950 "block-buffer 0.10.4",
951+ "const-oid 0.9.6",
935952 "crypto-common 0.1.7",
936953 "subtle",
937954 ]
943960 checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
944961 dependencies = [
945962 "block-buffer 0.12.1",
946− "const-oid",
963+ "const-oid 0.10.2",
947964 "crypto-common 0.2.2",
948965 "ctutils",
949966 ]
977994 source = "registry+https://github.com/rust-lang/crates.io-index"
978995 checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0"
979996 dependencies = [
980− "der",
997+ "der 0.8.2",
981998 "digest 0.11.3",
982999 "elliptic-curve",
9831000 "rfc6979",
984− "signature",
985− "spki",
1001+ "signature 3.0.0",
1002+ "spki 0.8.0",
9861003 "zeroize",
9871004 ]
9881005
9921009 source = "registry+https://github.com/rust-lang/crates.io-index"
9931010 checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
9941011 dependencies = [
995− "pkcs8",
996− "signature",
1012+ "pkcs8 0.11.0",
1013+ "signature 3.0.0",
9971014 ]
9981015
9991016 [[package]]
10071024 "rand_core 0.10.1",
10081025 "serde",
10091026 "sha2 0.11.0",
1010− "signature",
1027+ "signature 3.0.0",
10111028 "subtle",
10121029 "zeroize",
10131030 ]
10271044 "hkdf",
10281045 "hybrid-array",
10291046 "pem-rfc7468",
1030− "pkcs8",
1047+ "pkcs8 0.11.0",
10311048 "rand_core 0.10.1",
10321049 "sec1",
10331050 "subtle",
14431460 "getrandom 0.2.17",
14441461 "hex",
14451462 "hmac 0.12.1",
1463+ "miniz_oxide",
14461464 "pbkdf2 0.12.2",
1465+ "roxmltree",
1466+ "rsa 0.9.10",
14471467 "serde",
14481468 "serde_json",
14491469 "sha1 0.10.7",
23482368 ]
23492369
23502370 [[package]]
2371+name = "lazy_static"
2372+version = "1.5.1"
2373+source = "registry+https://github.com/rust-lang/crates.io-index"
2374+checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
2375+dependencies = [
2376+ "spin",
2377+]
2378+
2379+[[package]]
23512380 name = "libc"
23522381 version = "0.2.189"
23532382 source = "registry+https://github.com/rust-lang/crates.io-index"
24662495 "hybrid-array",
24672496 "kem",
24682497 "module-lattice",
2469− "pkcs8",
2498+ "pkcs8 0.11.0",
24702499 "rand_core 0.10.1",
24712500 "sha3 0.11.0",
24722501 ]
25322561 dependencies = [
25332562 "num-integer",
25342563 "num-traits",
2535− "rand",
2564+ "rand 0.10.3",
25362565 "rand_core 0.10.1",
25372566 ]
25382567
25392568 [[package]]
2569+name = "num-bigint-dig"
2570+version = "0.8.6"
2571+source = "registry+https://github.com/rust-lang/crates.io-index"
2572+checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
2573+dependencies = [
2574+ "lazy_static",
2575+ "libm",
2576+ "num-integer",
2577+ "num-iter",
2578+ "num-traits",
2579+ "rand 0.8.8",
2580+ "smallvec",
2581+ "zeroize",
2582+]
2583+
2584+[[package]]
25402585 name = "num-complex"
25412586 version = "0.4.6"
25422587 source = "registry+https://github.com/rust-lang/crates.io-index"
25882633 checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
25892634 dependencies = [
25902635 "autocfg",
2636+ "libm",
25912637 ]
25922638
25932639 [[package]]
26832729 "delegate",
26842730 "futures",
26852731 "log",
2686− "rand",
2732+ "rand 0.10.3",
26872733 "sha2 0.11.0",
26882734 "thiserror",
26892735 "tokio",
28232869
28242870 [[package]]
28252871 name = "pkcs1"
2872+version = "0.7.5"
2873+source = "registry+https://github.com/rust-lang/crates.io-index"
2874+checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
2875+dependencies = [
2876+ "der 0.7.10",
2877+ "pkcs8 0.10.2",
2878+ "spki 0.7.3",
2879+]
2880+
2881+[[package]]
2882+name = "pkcs1"
28262883 version = "0.8.0-rc.4"
28272884 source = "registry+https://github.com/rust-lang/crates.io-index"
28282885 checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e"
28292886 dependencies = [
2830− "der",
2831− "spki",
2887+ "der 0.8.2",
2888+ "spki 0.8.0",
28322889 ]
28332890
28342891 [[package]]
28402897 "aes 0.9.3",
28412898 "aes-gcm 0.11.1",
28422899 "cbc",
2843− "der",
2900+ "der 0.8.2",
28442901 "pbkdf2 0.13.0",
28452902 "rand_core 0.10.1",
28462903 "scrypt",
28472904 "sha2 0.11.0",
2848− "spki",
2905+ "spki 0.8.0",
28492906 ]
28502907
28512908 [[package]]
28522909 name = "pkcs8"
2910+version = "0.10.2"
2911+source = "registry+https://github.com/rust-lang/crates.io-index"
2912+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
2913+dependencies = [
2914+ "der 0.7.10",
2915+ "spki 0.7.3",
2916+]
2917+
2918+[[package]]
2919+name = "pkcs8"
28532920 version = "0.11.0"
28542921 source = "registry+https://github.com/rust-lang/crates.io-index"
28552922 checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
28562923 dependencies = [
2857− "der",
2924+ "der 0.8.2",
28582925 "pkcs5",
28592926 "rand_core 0.10.1",
2860− "spki",
2927+ "spki 0.8.0",
28612928 ]
28622929
28632930 [[package]]
29463013 checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
29473014
29483015 [[package]]
3016+name = "ppv-lite86"
3017+version = "0.2.21"
3018+source = "registry+https://github.com/rust-lang/crates.io-index"
3019+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
3020+dependencies = [
3021+ "zerocopy",
3022+]
3023+
3024+[[package]]
29493025 name = "primefield"
29503026 version = "0.14.0"
29513027 source = "registry+https://github.com/rust-lang/crates.io-index"
30203096 "bytes",
30213097 "getrandom 0.4.3",
30223098 "lru-slab",
3023− "rand",
3099+ "rand 0.10.3",
30243100 "rand_pcg",
30253101 "ring",
30263102 "rustc-hash",
30643140
30653141 [[package]]
30663142 name = "rand"
3143+version = "0.8.8"
3144+source = "registry+https://github.com/rust-lang/crates.io-index"
3145+checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
3146+dependencies = [
3147+ "rand_chacha",
3148+ "rand_core 0.6.4",
3149+]
3150+
3151+[[package]]
3152+name = "rand"
30673153 version = "0.10.3"
30683154 source = "registry+https://github.com/rust-lang/crates.io-index"
30693155 checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
30743160 ]
30753161
30763162 [[package]]
3163+name = "rand_chacha"
3164+version = "0.3.1"
3165+source = "registry+https://github.com/rust-lang/crates.io-index"
3166+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
3167+dependencies = [
3168+ "ppv-lite86",
3169+ "rand_core 0.6.4",
3170+]
3171+
3172+[[package]]
30773173 name = "rand_core"
30783174 version = "0.6.4"
30793175 source = "registry+https://github.com/rust-lang/crates.io-index"
32223318 ]
32233319
32243320 [[package]]
3321+name = "roxmltree"
3322+version = "0.20.0"
3323+source = "registry+https://github.com/rust-lang/crates.io-index"
3324+checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
3325+
3326+[[package]]
32253327 name = "rpassword"
32263328 version = "7.5.4"
32273329 source = "registry+https://github.com/rust-lang/crates.io-index"
32343336
32353337 [[package]]
32363338 name = "rsa"
3339+version = "0.9.10"
3340+source = "registry+https://github.com/rust-lang/crates.io-index"
3341+checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
3342+dependencies = [
3343+ "const-oid 0.9.6",
3344+ "digest 0.10.7",
3345+ "num-bigint-dig",
3346+ "num-integer",
3347+ "num-traits",
3348+ "pkcs1 0.7.5",
3349+ "pkcs8 0.10.2",
3350+ "rand_core 0.6.4",
3351+ "sha2 0.10.9",
3352+ "signature 2.2.0",
3353+ "spki 0.7.3",
3354+ "subtle",
3355+ "zeroize",
3356+]
3357+
3358+[[package]]
3359+name = "rsa"
32373360 version = "0.10.0-rc.18"
32383361 source = "registry+https://github.com/rust-lang/crates.io-index"
32393362 checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf"
32403363 dependencies = [
3241− "const-oid",
3364+ "const-oid 0.10.2",
32423365 "crypto-bigint",
32433366 "crypto-primes",
32443367 "digest 0.11.3",
3245− "pkcs1",
3246− "pkcs8",
3368+ "pkcs1 0.8.0-rc.4",
3369+ "pkcs8 0.11.0",
32473370 "rand_core 0.10.1",
32483371 "sha2 0.11.0",
3249− "signature",
3250− "spki",
3372+ "signature 3.0.0",
3373+ "spki 0.8.0",
32513374 "zeroize",
32523375 ]
32533376
32943417 "curve25519-dalek",
32953418 "data-encoding",
32963419 "delegate",
3297− "der",
3420+ "der 0.8.2",
32983421 "digest 0.11.3",
32993422 "ecdsa",
33003423 "ed25519-dalek",
33193442 "p521",
33203443 "pageant",
33213444 "pbkdf2 0.13.0",
3322− "pkcs1",
3445+ "pkcs1 0.8.0-rc.4",
33233446 "pkcs5",
3324− "pkcs8",
3447+ "pkcs8 0.11.0",
33253448 "polyval 0.7.3",
3326− "rand",
3449+ "rand 0.10.3",
33273450 "rand_core 0.10.1",
3328− "rsa",
3451+ "rsa 0.10.0-rc.18",
33293452 "russh-cryptovec",
33303453 "russh-util",
33313454 "salsa20",
33343457 "sha1 0.11.0",
33353458 "sha2 0.11.0",
33363459 "sha3 0.12.0",
3337− "signature",
3338− "spki",
3460+ "signature 3.0.0",
3461+ "spki 0.8.0",
33393462 "ssh-encoding",
33403463 "ssh-key",
33413464 "subtle",
35633686 dependencies = [
35643687 "base16ct",
35653688 "ctutils",
3566− "der",
3689+ "der 0.8.2",
35673690 "hybrid-array",
35683691 "subtle",
35693692 "zeroize",
38293952
38303953 [[package]]
38313954 name = "signature"
3955+version = "2.2.0"
3956+source = "registry+https://github.com/rust-lang/crates.io-index"
3957+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
3958+dependencies = [
3959+ "digest 0.10.7",
3960+ "rand_core 0.6.4",
3961+]
3962+
3963+[[package]]
3964+name = "signature"
38323965 version = "3.0.0"
38333966 source = "registry+https://github.com/rust-lang/crates.io-index"
38343967 checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
38884021 ]
38894022
38904023 [[package]]
4024+name = "spin"
4025+version = "0.9.9"
4026+source = "registry+https://github.com/rust-lang/crates.io-index"
4027+checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
4028+
4029+[[package]]
38914030 name = "spki"
4031+version = "0.7.3"
4032+source = "registry+https://github.com/rust-lang/crates.io-index"
4033+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
4034+dependencies = [
4035+ "base64ct",
4036+ "der 0.7.10",
4037+]
4038+
4039+[[package]]
4040+name = "spki"
38924041 version = "0.8.0"
38934042 source = "registry+https://github.com/rust-lang/crates.io-index"
38944043 checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f"
38954044 dependencies = [
38964045 "base64ct",
3897− "der",
4046+ "der 0.8.2",
38984047 ]
38994048
39004049 [[package]]
39524101 "p384",
39534102 "p521",
39544103 "rand_core 0.10.1",
3955− "rsa",
4104+ "rsa 0.10.0-rc.18",
39564105 "sec1",
39574106 "sha1 0.11.0",
39584107 "sha2 0.11.0",
3959− "signature",
4108+ "signature 3.0.0",
39604109 "ssh-cipher",
39614110 "ssh-encoding",
39624111 "zeroize",
43804529 "http",
43814530 "httparse",
43824531 "log",
4383− "rand",
4532+ "rand 0.10.3",
43844533 "sha1 0.11.0",
43854534 "thiserror",
43864535 ]
+1−0
194194 { label: 'Workspaces and tokens', slug: 'guides/workspaces' },
195195 { label: 'Access and roles', slug: 'guides/access-and-roles' },
196196 { label: 'Permissions', slug: 'guides/permissions' },
197+ { label: 'Single sign-on', slug: 'guides/single-sign-on' },
197198 { label: 'Teams', slug: 'guides/teams' },
198199 { label: 'People and teams', slug: 'guides/people-and-teams' },
199200 { label: 'Managing a repository', slug: 'guides/managing-repositories' },
+144−0
1+---
2+title: Single sign-on
3+description: Connect a workspace to your identity provider over SAML 2.0, OpenID Connect or OAuth 2.0, let its people join, and require it of members. On every paid plan.
4+---
5+
6+A workspace can connect to its own identity provider, such as Okta,
7+Microsoft Entra ID, Google Workspace, Auth0, OneLogin or JumpCloud, over
8+**OpenID Connect**, **SAML 2.0** or plain **OAuth 2.0**. It comes with
9+every paid plan at no extra price. A free workspace sees the page with
10+everything turned off until an owner starts the plan.
11+
12+Single sign-on never signs anyone in to g1t. People sign in to g1t as
13+themselves, with their password, GitHub and two-factor authentication as
14+usual, then sign in through your provider to use the workspace. The
15+first time they do, g1t links who they are at your provider to their g1t
16+account. Their personal repositories and other workspaces are never your
17+provider's to open.
18+
19+## Set it up
20+
21+Owners only, at **Workspace → Access → Single sign-on**,
22+`g1t.sh/<workspace>/-/sso`.
23+
24+1. Choose the protocol. Use **OpenID Connect** when your provider offers
25+ it; **SAML 2.0** for providers set up for SAML apps; **OAuth 2.0** for
26+ one with neither.
27+2. In your provider, create an app with the addresses the page shows:
28+
29+ | | |
30+ | --- | --- |
31+ | Redirect URI (OpenID Connect, OAuth 2.0) | `https://g1t.sh/auth/sso/<workspace>/callback` |
32+ | Entity id, audience (SAML) | `https://g1t.sh/auth/sso/<workspace>/metadata` |
33+ | Assertion consumer service URL, HTTP-POST (SAML) | `https://g1t.sh/auth/sso/<workspace>/saml` |
34+ | Metadata (SAML) | `https://g1t.sh/auth/sso/<workspace>/metadata` |
35+
36+3. Copy what the provider gives you back into the page:
37+
38+ - **OpenID Connect**: the issuer (where its
39+ `/.well-known/openid-configuration` lives), the client id and the
40+ client secret. Scopes default to `openid email profile`.
41+ - **SAML 2.0**: the provider's entity id (its issuer), its single
42+ sign-on URL, and its signing certificate (PEM). Paste more than one
43+ while you rotate certificates.
44+ - **OAuth 2.0**: the authorization, token and userinfo URLs, the client
45+ id and secret, the scopes, and which claims of the userinfo answer
46+ are the person's stable id (`sub`, then `id`, by default) and their
47+ address (`email`).
48+
49+4. Choose **Set up single sign-on**. For OpenID Connect g1t reads the
50+ issuer's discovery document first, and says so if it cannot.
51+
52+The client secret is kept encrypted and never shown again; leave it empty
53+when you save other changes to keep it.
54+
55+## Test it
56+
57+Choose **Test sign-in**. g1t sends you to your provider and back, links
58+your account, and marks the connection **Tested** with your name. You
59+must have signed in through it yourself before you can require it of
60+everyone.
61+
62+## Let people join
63+
64+With **Let people from your provider join** on (the default), anyone your
65+provider signs in becomes a member of the workspace when they first sign
66+in through it, with no invitation: send them to
67+`g1t.sh/auth/sso/<workspace>`. They need a g1t account with a confirmed
68+email address first, and the workspace's other rules still apply, such as
69+[requiring two-factor authentication](/guides/workspaces/#require-two-factor-authentication).
70+Turn it off and only people who are already members can sign in through
71+it.
72+
73+## Require it
74+
75+Turn on **Require single sign-on** under **Test and require**. From then
76+on a member uses the workspace only:
77+
78+- **On the site**, from a session that signed in through your provider in
79+ the last day. After a day they go through it again, which is usually
80+ instant: your provider remembers them.
81+- **With an access token, SSH key or git credentials**, once they have
82+ linked their account.
83+
84+A member who has not keeps their place but cannot use the workspace: its
85+private repositories, pages and API answer as if they were not a member,
86+including repositories they have a role on directly, and every page shows
87+a notice with a link to sign in through your provider.
88+
89+**Owners are never held out**, so a provider that breaks or a certificate
90+that expires never locks you out of your own workspace. Outside
91+collaborators are not asked: they are not your provider's people.
92+
93+## Linked people
94+
95+The page lists everyone who has signed in through your provider, who they
96+are there, and when they last did. **Unlink** someone to let them link
97+another account at your provider; their sessions go through it again.
98+Each account at your provider links to one g1t account, and each g1t
99+account to one account at your provider.
100+
101+Changing the provider itself (the protocol, the issuer, or the SAML entity
102+id) unlinks everyone and stops requiring it, until someone has signed in
103+through the new one. Changing the client, the scopes or the certificate
104+keeps everyone linked.
105+
106+## What g1t checks
107+
108+- **OpenID Connect and OAuth 2.0** use the authorization code flow with
109+ PKCE. For OpenID Connect, the ID token must be issued by your issuer, for
110+ your client id, unexpired, and answer this sign-in's nonce. g1t receives
111+ it straight from your provider's token endpoint over TLS.
112+- **SAML** responses must be signed, the assertion or the whole response,
113+ with RSA and SHA-256, SHA-384 or SHA-512, by one of the certificates you
114+ saved. The response must answer the request g1t sent (sign-ins started
115+ at your provider are refused), be addressed to the workspace's assertion
116+ consumer service, name g1t's entity id as the audience, come from your
117+ provider's entity id, and be in its validity window. Leave the assertion
118+ unencrypted: it reaches g1t over HTTPS.
119+
120+## If the plan ends
121+
122+The settings stay. Members cannot sign in through your provider until the
123+plan starts again, so a workspace that requires it holds its members out
124+meanwhile; owners can still turn the requirement off. Turning it off and
125+removing it never need a plan.
126+
127+## Remove it
128+
129+**Danger zone → Remove** forgets your provider and who everyone is there,
130+and stops requiring it. Members keep their place in the workspace.
131+
132+## Audit log
133+
134+Recorded in the workspace's [audit log](/guides/audit-log/):
135+`sso.created`, `sso.updated`, `sso.deleted`, `sso.required`,
136+`sso.not_required`, `sso.identity_linked`, `sso.identity_unlinked`, and
137+`member.joined_by_sso`.
138+
139+## Not yet
140+
141+Provisioning and deprovisioning with SCIM is not available yet. Until it
142+is, someone you remove at your provider keeps their g1t session for up to
143+a day and their tokens until an owner removes them from the workspace or
144+unlinks them.
+5−0
587587 `PATCH /workspaces/{workspace}`. Recorded as `workspace.two_factor_required`
588588 and `workspace.two_factor_not_required`.
589589
590+A workspace on a paid plan can also require
591+[single sign-on](/guides/single-sign-on/) through its own identity
592+provider.
593+
590594 ## The workspace's page
591595
592596 A workspace's own page, `g1t.sh/<workspace>`, has its icon, name, address
811815 | **Access** | **Members** | Members | Who is in the workspace and their roles, with search and filters; owners [invite people](#add-people), change roles, and see **Outside collaborators**. `-/members` |
812816 | | **Invitations** | Owners | Every invitation to join, filtered by where it stands; send one again, revoke it, copy its link. `-/members/invitations` |
813817 | | [**Permissions**](/guides/permissions/) | Owners | Who may do what, part by part: the base permission, repositories, teams and chat. `-/permissions` |
818+| | [**Single sign-on**](/guides/single-sign-on/) | Owners | Your identity provider over SAML 2.0, OpenID Connect or OAuth 2.0, the people linked to it, and requiring it. Every paid plan. `-/sso` |
814819 | | [**Access tokens**](#workspace-access-tokens) | Members | The workspace's own tokens. Owners create and delete them. `-/tokens` |
815820 | | **Personal access tokens** | Owners | The workspace's rules for members' tokens, and approving them. `-/personal-access-tokens` |
816821 | **Money** | [**Usage**](/guides/usage-and-billing/#the-usage-page) | Members | What the workspace used, by product, project and day. `-/usage` |
+10−5
55
66 /**
77 * The notice across the top for someone a workspace holds out until they
8− * meet its policy, such as one that requires two-factor authentication:
9− * they keep their place and cannot use it until they turn it on.
8+ * meet its policy, such as one that requires two-factor authentication or
9+ * single sign-on: they keep their place and cannot use it until they do.
1010 */
1111 export function PolicyNotice({ held }: { held: PolicyHold[] }) {
1212 if (held.length === 0) return null;
1313 const twoFactor = held.some((hold) => hold.gap === "two_factor");
14+ // Single sign-on is one workspace's at a time: the first that asks for it.
15+ const sso = held.find((hold) => hold.gap === "sso");
1416 return (
1517 <div
1618 role="alert"
1820 >
1921 <ShieldAlert size={15} aria-hidden="true" className="hidden shrink-0 text-warn sm:block" />
2022 <span>
21− {held.length === 1
22− ? held[0].reason
23− : `${held.map((hold) => hold.slug).join(", ")} require two-factor authentication. Turn it on to use them again.`}
23+ {held.map((hold) => hold.reason).join(" ")}
2424 </span>
2525 {twoFactor && (
2626 <Link to="/settings/two-factor" className="font-medium underline underline-offset-4">
2727 Turn on two-factor authentication
2828 </Link>
2929 )}
30+ {sso && (
31+ <Link to={`/auth/sso/${sso.slug}?next=${encodeURIComponent(`/${sso.slug}`)}`} className="font-medium underline underline-offset-4">
32+ Sign in with {sso.slug}&apos;s single sign-on
33+ </Link>
34+ )}
3035 </div>
3136 );
3237 }
+4−2
5252 if (STANDALONE.has(path)) return "standalone";
5353 // Marketing pages are the site's front, whoever reads them: never inside the app's rail and sidebar.
5454 if (MARKETING.has(path)) return "public";
55− // An invite link, and signing in with GitHub, are the front door.
56− if (path.startsWith("/invite/") || path === "/auth/github" || path.startsWith("/auth/github/")) return "standalone";
55+ // An invite link, signing in with GitHub, and a workspace's single sign-on are the front door.
56+ if (path.startsWith("/invite/") || path === "/auth/github" || path.startsWith("/auth/github/") || path.startsWith("/auth/sso/")) {
57+ return "standalone";
58+ }
5759 if (path === "/invitations" && viewer.signedIn && !viewer.workspace) return "standalone";
5860 return viewer.signedIn ? "app" : "public";
5961 }
+8−0
1+import { env } from "cloudflare:workers";
2+
3+import { ssoClient } from "@g1t/contracts";
4+
5+import { instrumented } from "./perf.server";
6+
7+/** A workspace's single sign-on, kept by the identity service. */
8+export const sso = ssoClient(instrumented("identity", env.IDENTITY));
+40−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { ssoAddresses } from "@g1t/contracts/sso";
5+
6+import { SSO_ERROR_COOKIE, afterSso, errorCookie, readErrorCookie, samlPath } from "./sso.ts";
7+
8+test("only a workspace's SAML address is answered before React Router", () => {
9+ assert.equal(samlPath("/auth/sso/acme/saml"), "acme");
10+ assert.equal(samlPath("/auth/sso/Acme-Co/saml/"), "acme-co");
11+ assert.equal(samlPath("/auth/sso/acme/callback"), null);
12+ assert.equal(samlPath("/auth/sso/acme/saml/extra"), null);
13+ assert.equal(samlPath("/auth/sso/-acme/saml"), null);
14+ assert.equal(samlPath("/acme/-/sso"), null);
15+});
16+
17+test("afterwards it goes on to the workspace, never off the site", () => {
18+ assert.equal(afterSso({ slug: "acme", next: "/" }), "/acme");
19+ assert.equal(afterSso({ slug: "acme", next: "/acme/-/sso" }), "/acme/-/sso");
20+ assert.equal(afterSso({ slug: "acme", next: "//evil.test" }), "/acme");
21+ assert.equal(afterSso({ slug: "acme", next: "https://evil.test/" }), "/acme");
22+});
23+
24+test("why a SAML sign-in failed survives the trip in a cookie", () => {
25+ const set = errorCookie("The assertion is not for g1t; set the audience = x.");
26+ assert.match(set, /HttpOnly; Secure; SameSite=Lax; Max-Age=60/);
27+ const value = set.split(";")[0];
28+ assert.equal(readErrorCookie(`a=b; ${value}`), "The assertion is not for g1t; set the audience = x.");
29+ assert.equal(readErrorCookie(`${SSO_ERROR_COOKIE}=%E0%A4%A`), null);
30+ assert.equal(readErrorCookie(null), null);
31+});
32+
33+test("a workspace's provider is pointed at its own addresses", () => {
34+ assert.deepEqual(ssoAddresses("https://g1t.sh/", "Acme"), {
35+ entityId: "https://g1t.sh/auth/sso/acme/metadata",
36+ metadataUrl: "https://g1t.sh/auth/sso/acme/metadata",
37+ acsUrl: "https://g1t.sh/auth/sso/acme/saml",
38+ callbackUrl: "https://g1t.sh/auth/sso/acme/callback",
39+ });
40+});
+53−0
1+/**
2+ * The parts of a workspace's single sign-on that the site itself decides:
3+ * the cookies that bind a trip to the provider to this browser, where to
4+ * go afterwards, and the one address the Worker answers before React
5+ * Router (a SAML response, posted from the provider's site).
6+ */
7+
8+import { safeNext } from "./next.ts";
9+
10+/** The OAuth state of a trip to a workspace's provider, for ten minutes. */
11+export const SSO_STATE_COOKIE = "g1t_sso_state";
12+/** Why a SAML sign-in did not work, shown once on the page it lands on. */
13+export const SSO_ERROR_COOKIE = "g1t_sso_error";
14+
15+/** The workspace a `POST /auth/sso/<slug>/saml` is for, or null. */
16+export function samlPath(pathname: string): string | null {
17+ const match = /^\/auth\/sso\/([a-z0-9](?:[a-z0-9-]{0,38}))\/saml\/?$/i.exec(pathname);
18+ return match ? match[1].toLowerCase() : null;
19+}
20+
21+/** Where to go once signed in through a workspace's provider. */
22+export function afterSso(finished: { slug: string; next: string }): string {
23+ const next = safeNext(finished.next);
24+ return next === "/" ? `/${finished.slug}` : next;
25+}
26+
27+/** A `Set-Cookie` value carrying `message`, for a minute: Lax, HttpOnly. */
28+export function errorCookie(message: string): string {
29+ const value = encodeURIComponent(message.slice(0, 600));
30+ return `${SSO_ERROR_COOKIE}=${value}; Path=/auth/sso; HttpOnly; Secure; SameSite=Lax; Max-Age=60`;
31+}
32+
33+/** Clears that cookie. */
34+export function clearErrorCookie(): string {
35+ return `${SSO_ERROR_COOKIE}=; Path=/auth/sso; HttpOnly; Secure; SameSite=Lax; Max-Age=0`;
36+}
37+
38+/** The message in that cookie, or null. */
39+export function readErrorCookie(header: string | null): string | null {
40+ for (const part of (header ?? "").split(";")) {
41+ const [key, ...rest] = part.trim().split("=");
42+ if (key !== SSO_ERROR_COOKIE) continue;
43+ try {
44+ return decodeURIComponent(rest.join("=")) || null;
45+ } catch {
46+ return null;
47+ }
48+ }
49+ return null;
50+}
51+
52+/** How each protocol is named on the page. */
53+export const PROTOCOL_NAMES = { oidc: "OpenID Connect", oauth2: "OAuth 2.0", saml: "SAML 2.0" } as const;
+2−0
7474 // An invite to a workspace is how someone without one gets one, and an
7575 // invitation to a repository is answered before anything else.
7676 path.startsWith("/invite/") ||
77+ // So is joining one through its single sign-on.
78+ path.startsWith("/auth/sso/") ||
7779 /^\/[^/]+\/[^/]+\/invitations$/.test(path)
7880 ) {
7981 return null;
+2−0
5353 { key: "members", label: "Members", path: "members" },
5454 { key: "invitations", label: "Invitations", path: "members/invitations", owner: true },
5555 { key: "permissions", label: "Permissions", path: "permissions", owner: true },
56+ { key: "sso", label: "Single sign-on", path: "sso", owner: true },
5657 { key: "tokens", label: "Access tokens", path: "tokens" },
5758 { key: "personal-access-tokens", label: "Personal access tokens", path: "personal-access-tokens", owner: true },
5859 ],
250251 people: "people",
251252 members: "workspace",
252253 permissions: "workspace",
254+ sso: "workspace",
253255 teams: "people",
254256 workspace: "workspace",
255257 usage: "workspace",
+2−2
518518 </Link>
519519 </p>
520520 );
521− // A workspace that requires two-factor authentication of someone who
522− // has not turned it on: they keep their place, and cannot use it yet.
521+ // A workspace that requires two-factor authentication or single sign-on
522+ // of someone who has not met it: they keep their place, and cannot use it yet.
523523 const held = user?.held &&
524524 user.held.length > 0 &&
525525 pathname !== "/settings/two-factor" && <PolicyNotice held={user.held} />;
+10−0
2424 route("auth/github", "routes/auth-github.ts"),
2525 route("auth/github/callback", "routes/auth-github-callback.tsx"),
2626 route("auth/github/username", "routes/auth-github-username.tsx"),
27+ // A workspace's single sign-on: setting off to its identity provider,
28+ // coming back from it (OpenID Connect and OAuth 2.0), what a SAML sign-in
29+ // that did not work says (the provider's POST is answered before React
30+ // Router, workers/sso-saml.ts), and g1t's SAML metadata.
31+ route("auth/sso/:owner", "routes/auth-sso.tsx"),
32+ route("auth/sso/:owner/callback", "routes/auth-sso-callback.tsx"),
33+ route("auth/sso/:owner/saml", "routes/auth-sso-saml.tsx"),
34+ route("auth/sso/:owner/metadata", "routes/auth-sso-metadata.ts"),
2735 route("integrations/github/install", "routes/github-install.ts"),
2836 route("integrations/github/setup", "routes/github-setup.tsx"),
2937 // Your own settings: one page each, so links can name the one they mean.
121129 route("-/members", "routes/workspace/members.tsx"),
122130 route("-/members/invitations", "routes/workspace/member-invitations.tsx"),
123131 route("-/permissions", "routes/workspace/permissions.tsx"),
132+ // Single sign-on through the workspace's own identity provider.
133+ route("-/sso", "routes/workspace/sso.tsx"),
124134 route("-/insights", "routes/workspace/insights.tsx"),
125135 // Pinning its projects, for the person signed in.
126136 route("-/pins", "routes/workspace/pins.ts"),
+45−0
1+import { data, redirect } from "react-router";
2+
3+import type { Route } from "./+types/auth-sso-callback";
4+import { cookie, readCookie, stateMatches } from "../lib/github";
5+import { page } from "../lib/meta";
6+import { SSO_STATE_COOKIE, afterSso } from "../lib/sso";
7+import { sso } from "../lib/sso.server";
8+import { sessionTokenOf } from "../lib/session.server";
9+import { SsoProblem } from "./auth-sso";
10+
11+export function meta({ params, ...args }: Route.MetaArgs) {
12+ return page(args, { title: `Single sign-on · ${params.owner} · g1t` });
13+}
14+
15+/**
16+ * Where a workspace's provider returns over OpenID Connect or OAuth 2.0.
17+ * The state must match this browser's cookie, and the session must be the
18+ * one that set off; identity then spends the state once, exchanges the
19+ * code, and links, admits and marks the session.
20+ */
21+export async function loader({ request, params }: Route.LoaderArgs) {
22+ const slug = params.owner.toLowerCase();
23+ const url = new URL(request.url);
24+ const clear = cookie(SSO_STATE_COOKIE, "", 0);
25+ const failed = (error: string) => data({ slug, error }, { headers: { "set-cookie": clear } });
26+ const refused = url.searchParams.get("error");
27+ if (refused) {
28+ const said = url.searchParams.get("error_description")?.slice(0, 300);
29+ return failed(refused === "access_denied" ? "The sign-in was cancelled at the identity provider." : `The identity provider refused the sign-in${said ? `: ${said}` : ` (${refused.slice(0, 60)})`}.`);
30+ }
31+ const code = url.searchParams.get("code");
32+ const state = url.searchParams.get("state");
33+ if (!code || !stateMatches(readCookie(request.headers.get("cookie"), SSO_STATE_COOKIE), state)) {
34+ return failed("This sign-in did not start in this browser, or took too long. Start again.");
35+ }
36+ const token = sessionTokenOf(request);
37+ if (!token) return failed("Sign in to g1t again, then start single sign-on again.");
38+ const finished = await sso.finish({ slug, session_token: token, state: state!, code });
39+ if (!finished.ok) return failed(finished.error.message);
40+ throw redirect(afterSso(finished.value), { headers: { "set-cookie": clear } });
41+}
42+
43+export default function SsoCallback({ loaderData }: Route.ComponentProps) {
44+ return <SsoProblem slug={loaderData.slug} error={loaderData.error} />;
45+}
+21−0
1+import { ssoAddresses } from "@g1t/contracts";
2+
3+import type { Route } from "./+types/auth-sso-metadata";
4+import { sso } from "../lib/sso.server";
5+
6+/**
7+ * g1t's SAML service provider metadata for a workspace, for its identity
8+ * provider to read: the entity id (this address) and where to post the
9+ * response. The same for everyone; it says nothing about the workspace.
10+ */
11+export async function loader({ request, params }: Route.LoaderArgs) {
12+ const addresses = ssoAddresses(new URL(request.url).origin, params.owner);
13+ const xml = await sso.metadata(addresses.entityId, addresses.acsUrl);
14+ return new Response(xml, {
15+ headers: {
16+ "content-type": "application/samlmetadata+xml; charset=utf-8",
17+ "cache-control": "public, max-age=3600",
18+ "content-disposition": `inline; filename="g1t-${params.owner.toLowerCase()}-metadata.xml"`,
19+ },
20+ });
21+}
+26−0
1+import { data, redirect } from "react-router";
2+
3+import type { Route } from "./+types/auth-sso-saml";
4+import { page } from "../lib/meta";
5+import { clearErrorCookie, readErrorCookie } from "../lib/sso";
6+import { SsoProblem } from "./auth-sso";
7+
8+export function meta({ params, ...args }: Route.MetaArgs) {
9+ return page(args, { title: `Single sign-on · ${params.owner} · g1t` });
10+}
11+
12+/**
13+ * The assertion consumer service, as a page: the provider's POST is
14+ * answered by the Worker (workers/sso-saml.ts), which comes back here with
15+ * why it did not work. Opened by itself, it starts again.
16+ */
17+export async function loader({ request, params }: Route.LoaderArgs) {
18+ const slug = params.owner.toLowerCase();
19+ const error = readErrorCookie(request.headers.get("cookie"));
20+ if (!error) throw redirect(`/auth/sso/${slug}`);
21+ return data({ slug, error }, { headers: { "set-cookie": clearErrorCookie() } });
22+}
23+
24+export default function SsoSaml({ loaderData }: Route.ComponentProps) {
25+ return <SsoProblem slug={loaderData.slug} error={loaderData.error} />;
26+}
+72−0
1+import { Link, data, redirect } from "react-router";
2+
3+import { ssoAddresses } from "@g1t/contracts";
4+
5+import type { Route } from "./+types/auth-sso";
6+import { AuthCard } from "../components/auth-card";
7+import { cookie } from "../lib/github";
8+import { page } from "../lib/meta";
9+import { SSO_STATE_COOKIE } from "../lib/sso";
10+import { sso } from "../lib/sso.server";
11+import { nextPath, requireUser, sessionTokenOf } from "../lib/session.server";
12+
13+export function meta({ params, ...args }: Route.MetaArgs) {
14+ return page(args, { title: `Single sign-on · ${params.owner} · g1t` });
15+}
16+
17+/**
18+ * Sends the signed-in person to a workspace's identity provider. Signed
19+ * out, they sign in to g1t first and come back here. The state the
20+ * provider will send back is kept in a short-lived cookie (OpenID Connect
21+ * and OAuth 2.0); a SAML request's id binds its answer instead.
22+ */
23+export async function loader({ request, params, context }: Route.LoaderArgs) {
24+ const user = requireUser(context, request);
25+ const slug = params.owner.toLowerCase();
26+ const token = sessionTokenOf(request);
27+ if (!token) {
28+ return data({ slug, error: "Single sign-on is for people signed in on this site, not for access tokens." }, { status: 403 });
29+ }
30+ const summary = await sso.summary(slug);
31+ if (!summary.enabled) return data({ slug, error: `${slug} has not set up single sign-on.` }, { status: 404 });
32+ const addresses = ssoAddresses(new URL(request.url).origin, slug);
33+ const started = await sso.start({
34+ user,
35+ session_token: token,
36+ slug,
37+ redirect_uri: summary.protocol === "saml" ? addresses.acsUrl : addresses.callbackUrl,
38+ entity_id: addresses.entityId,
39+ next: nextPath(request),
40+ });
41+ if (!started.ok) return { slug, error: started.error.message };
42+ const headers = started.value.state ? { "set-cookie": cookie(SSO_STATE_COOKIE, started.value.state, 600) } : undefined;
43+ throw redirect(started.value.url, { headers });
44+}
45+
46+export default function SsoStart({ loaderData }: Route.ComponentProps) {
47+ return <SsoProblem slug={loaderData.slug} error={loaderData.error} />;
48+}
49+
50+/** What a single sign-on that did not work says, with the ways on. */
51+export function SsoProblem({ slug, error }: { slug: string; error: string }) {
52+ return (
53+ <AuthCard
54+ title="Single sign-on"
55+ subtitle={`Signing in through ${slug}'s identity provider`}
56+ footer={
57+ <Link to="/" className="text-fg underline underline-offset-4">
58+ Back to g1t
59+ </Link>
60+ }
61+ >
62+ <p className="text-sm text-danger" role="alert">
63+ {error}
64+ </p>
65+ <p className="mt-6 text-sm">
66+ <Link to={`/auth/sso/${slug}`} className="font-medium text-accent underline underline-offset-4">
67+ Try again
68+ </Link>
69+ </p>
70+ </AuthCard>
71+ );
72+}
+6−0
263263 note: "Longer by arrangement. Older entries are deleted each day.",
264264 },
265265 {
266+ what: "Single sign-on",
267+ free: "Not available",
268+ plan: "Included, at no extra price",
269+ note: "Your identity provider over SAML 2.0, OpenID Connect or OAuth 2.0, and requiring it of members.",
270+ },
271+ {
266272 what: "Secret scanning, push protection, vulnerability alerts, security updates",
267273 free: "Included",
268274 plan: "Included",
+456−0
1+import { useState } from "react";
2+import { Form, data } from "react-router";
3+import { KeyRound, Sparkles } from "lucide-react";
4+
5+import { type SsoConfig, type SsoProtocol, type SsoSettings, ssoAddresses } from "@g1t/contracts";
6+
7+import type { Route } from "./+types/sso";
8+import { page } from "../../lib/meta";
9+import { DangerAction, DangerZone } from "../../components/danger-zone";
10+import { planHref } from "../../components/start-plan";
11+import { ButtonLink, CopyLine, ErrorText, Field, Input, SubmitButton, Textarea, TimeAgo } from "../../components/ui";
12+import {
13+ AlertDialog,
14+ AlertDialogCancel,
15+ AlertDialogContent,
16+ AlertDialogDescription,
17+ AlertDialogFooter,
18+ AlertDialogHeader,
19+ AlertDialogTitle,
20+ AlertDialogTrigger,
21+} from "../../components/ui/alert-dialog";
22+import { Badge } from "../../components/ui/badge";
23+import { Button } from "../../components/ui/button";
24+import { RadioGroup, RadioOption } from "../../components/ui/radio-group";
25+import { SwitchCard } from "../../components/ui/switch";
26+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table";
27+import { Unavailable } from "../../components/boundary";
28+import { isFailed, part } from "../../lib/partial";
29+import { billing } from "../../lib/services.server";
30+import { PROTOCOL_NAMES } from "../../lib/sso";
31+import { sso } from "../../lib/sso.server";
32+import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
33+
34+export function meta({ params, ...args }: Route.MetaArgs) {
35+ return page(args, { title: `Single sign-on · ${params.owner} · g1t` });
36+}
37+
38+export async function loader({ params, context, request }: Route.LoaderArgs) {
39+ // Owners only; to anyone else the page does not exist.
40+ const viewer = getViewer(context);
41+ if (!viewer || roleIn(viewer, params.owner) !== "owner") throw data(null, { status: 404 });
42+ const slug = params.owner.toLowerCase();
43+ const [settings, free] = await Promise.all([
44+ sso.settings(viewer, slug),
45+ // Whether it can be set up here: every paid plan has it.
46+ part(billing.freeWorkspaces([slug]), "The workspace's plan"),
47+ ]);
48+ return {
49+ settings: unwrap(settings),
50+ plan: isFailed(free) ? free : free.includes(slug) ? ("free" as const) : ("paid" as const),
51+ addresses: ssoAddresses(new URL(request.url).origin, slug),
52+ };
53+}
54+
55+type Answer = { saved?: string; error?: string; intent?: string };
56+
57+export async function action({ request, params, context }: Route.ActionArgs): Promise<Answer> {
58+ assertSameOrigin(request);
59+ const user = requireUser(context, request);
60+ const slug = params.owner.toLowerCase();
61+ const form = await request.formData();
62+ const intent = String(form.get("intent") ?? "");
63+ const text = (name: string) => String(form.get(name) ?? "").trim() || null;
64+ const answer = (result: { ok: true } | { ok: false; error: { message: string } }, saved: string): Answer =>
65+ result.ok ? { saved, intent } : { error: result.error.message, intent };
66+ switch (intent) {
67+ case "save": {
68+ const protocol = String(form.get("protocol") ?? "oidc") as SsoProtocol;
69+ const config: SsoConfig = {
70+ issuer: text("issuer"),
71+ client_id: text("client_id"),
72+ scopes: text("scopes"),
73+ authorization_url: text("authorization_url"),
74+ token_url: text("token_url"),
75+ userinfo_url: text("userinfo_url"),
76+ subject_claim: text("subject_claim"),
77+ email_claim: text("email_claim"),
78+ idp_entity_id: text("idp_entity_id"),
79+ idp_sso_url: text("idp_sso_url"),
80+ };
81+ const saved = await sso.save(user, slug, {
82+ protocol,
83+ config,
84+ client_secret: text("client_secret"),
85+ certificate: text("certificate"),
86+ allow_join: form.get("allow_join") === "on",
87+ });
88+ return answer(saved, "Saved.");
89+ }
90+ case "required": {
91+ const required = form.get("required") === "on";
92+ return answer(await sso.setRequired(user, slug, required), required ? "Single sign-on is required." : "Single sign-on is not required.");
93+ }
94+ case "unlink": {
95+ const username = String(form.get("username") ?? "");
96+ return answer(await sso.unlink(user, slug, username), `Unlinked ${username}.`);
97+ }
98+ case "delete":
99+ return answer(await sso.remove(user, slug), "Single sign-on is removed.");
100+ default:
101+ return { error: "Unknown change.", intent };
102+ }
103+}
104+
105+export default function WorkspaceSso({ loaderData, actionData, params }: Route.ComponentProps) {
106+ const { settings, plan, addresses } = loaderData;
107+ const slug = params.owner.toLowerCase();
108+ const free = plan === "free";
109+ const said = (intent: string) => (actionData?.intent === intent ? actionData : undefined);
110+ return (
111+ <div className="max-w-3xl space-y-10">
112+ <section aria-labelledby="sso-about">
113+ <h2 id="sso-about" className="flex items-center gap-2 font-medium">
114+ <KeyRound size={16} className="text-faint" aria-hidden />
115+ Single sign-on
116+ </h2>
117+ <p className="mt-1.5 text-sm text-muted">
118+ Connect {slug} to your identity provider over OpenID Connect, OAuth 2.0 or SAML. People sign in to g1t as
119+ themselves, then through your provider to use {slug}: the first time, g1t links who they are there to their g1t
120+ account. Require it, and members use {slug} only after signing in through it.
121+ </p>
122+ {isFailed(plan) ? (
123+ <div className="mt-4">
124+ <Unavailable what={plan.what} />
125+ </div>
126+ ) : (
127+ free && <PlanNeeded slug={slug} />
128+ )}
129+ </section>
130+
131+ <ProviderSection
132+ // Starts from what is saved whenever that changes.
133+ key={settings.connection?.updated_at ?? "none"}
134+ settings={settings}
135+ addresses={addresses}
136+ disabled={free}
137+ answer={said("save")}
138+ />
139+
140+ {settings.connection && (
141+ <RequireSection settings={settings} slug={slug} disabled={free} answer={said("required")} />
142+ )}
143+
144+ {settings.connection && <PeopleSection settings={settings} answer={said("unlink")} />}
145+
146+ {settings.connection && (
147+ <DangerZone>
148+ <DangerAction
149+ title="Remove single sign-on"
150+ action={<RemoveDialog slug={slug} />}
151+ >
152+ Forgets the provider and who everyone is there, and stops requiring it. Members keep their place in {slug}.
153+ {said("delete")?.error && <ErrorText>{said("delete")?.error}</ErrorText>}
154+ </DangerAction>
155+ </DangerZone>
156+ )}
157+ </div>
158+ );
159+}
160+
161+/** Removing it, once the owner says so in a dialog. */
162+function RemoveDialog({ slug }: { slug: string }) {
163+ return (
164+ <AlertDialog>
165+ <AlertDialogTrigger asChild>
166+ <Button variant="destructive">Remove</Button>
167+ </AlertDialogTrigger>
168+ <AlertDialogContent>
169+ <AlertDialogHeader>
170+ <AlertDialogTitle>Remove single sign-on from {slug}?</AlertDialogTitle>
171+ <AlertDialogDescription>
172+ g1t forgets your provider and who everyone is there, and stops requiring it. Setting it up again links everyone
173+ afresh.
174+ </AlertDialogDescription>
175+ </AlertDialogHeader>
176+ <Form method="post">
177+ <input type="hidden" name="intent" value="delete" />
178+ <AlertDialogFooter>
179+ <AlertDialogCancel>Cancel</AlertDialogCancel>
180+ <SubmitButton variant="destructive" match={{ intent: "delete" }} pending="Removing…">
181+ Remove single sign-on
182+ </SubmitButton>
183+ </AlertDialogFooter>
184+ </Form>
185+ </AlertDialogContent>
186+ </AlertDialog>
187+ );
188+}
189+
190+/** For a free workspace: it is there, and comes with any paid plan. */
191+function PlanNeeded({ slug }: { slug: string }) {
192+ return (
193+ <div role="note" className="mt-4 rounded-xl border border-accent/30 bg-accent/5 p-4">
194+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
195+ <Sparkles size={16} className="hidden shrink-0 text-accent sm:block" aria-hidden />
196+ <div className="min-w-0 grow">
197+ <p className="text-sm font-medium">Single sign-on comes with every paid plan</p>
198+ <p className="mt-1 text-sm text-muted">
199+ {slug} is a free workspace. Start the plan to connect your identity provider: there is nothing extra to pay
200+ for it.
201+ </p>
202+ </div>
203+ <span className="shrink-0">
204+ <ButtonLink to={planHref(slug)}>Start the plan</ButtonLink>
205+ </span>
206+ </div>
207+ </div>
208+ );
209+}
210+
211+function Saved({ answer }: { answer?: Answer }) {
212+ if (!answer) return null;
213+ return answer.error ? <ErrorText>{answer.error}</ErrorText> : <span className="text-sm text-success">{answer.saved}</span>;
214+}
215+
216+function ProviderSection({
217+ settings,
218+ addresses,
219+ disabled,
220+ answer,
221+}: {
222+ settings: SsoSettings;
223+ addresses: ReturnType<typeof ssoAddresses>;
224+ disabled: boolean;
225+ answer?: Answer;
226+}) {
227+ const connection = settings.connection;
228+ const [protocol, setProtocol] = useState<SsoProtocol>(connection?.protocol ?? "oidc");
229+ // What is saved for the protocol shown; another protocol starts empty.
230+ const saved = connection?.protocol === protocol ? connection.config : {};
231+ const value = (name: keyof SsoConfig) => (saved[name] as string | null | undefined) ?? "";
232+ const keepsSecret = connection?.protocol === protocol && connection.has_client_secret;
233+ return (
234+ <section aria-labelledby="sso-provider">
235+ <h2 id="sso-provider" className="font-medium">
236+ Identity provider
237+ </h2>
238+ <Form method="post" className="mt-5 space-y-5">
239+ <input type="hidden" name="intent" value="save" />
240+ <fieldset disabled={disabled} className="space-y-5 disabled:opacity-60">
241+ <RadioGroup name="protocol" value={protocol} onValueChange={(next) => setProtocol(next as SsoProtocol)} className="sm:grid-cols-3">
242+ <RadioOption value="oidc" label={PROTOCOL_NAMES.oidc} description="Okta, Microsoft Entra ID, Google, Auth0 and most others." />
243+ <RadioOption value="saml" label={PROTOCOL_NAMES.saml} description="For providers set up for SAML apps." />
244+ <RadioOption value="oauth2" label={PROTOCOL_NAMES.oauth2} description="A provider without OpenID Connect." />
245+ </RadioGroup>
246+
247+ <div className="space-y-2">
248+ <p className="text-sm font-medium text-muted">In your provider, set up an app with these</p>
249+ {protocol === "saml" ? (
250+ <>
251+ <Field label="Entity id (audience) and metadata">
252+ <CopyLine text={addresses.entityId} breakAtSlashes />
253+ </Field>
254+ <Field label="Assertion consumer service (ACS) URL, HTTP-POST">
255+ <CopyLine text={addresses.acsUrl} breakAtSlashes />
256+ </Field>
257+ </>
258+ ) : (
259+ <Field label="Redirect URI (callback)">
260+ <CopyLine text={addresses.callbackUrl} breakAtSlashes />
261+ </Field>
262+ )}
263+ </div>
264+
265+ {protocol === "oidc" && (
266+ <>
267+ <Field label="Issuer" hint="Where its /.well-known/openid-configuration lives, such as https://acme.okta.com.">
268+ <Input name="issuer" type="url" required defaultValue={value("issuer")} placeholder="https://" />
269+ </Field>
270+ <ClientFields value={value} keepsSecret={keepsSecret} defaultScopes="openid email profile" />
271+ </>
272+ )}
273+
274+ {protocol === "oauth2" && (
275+ <>
276+ <Field label="Authorization URL">
277+ <Input name="authorization_url" type="url" required defaultValue={value("authorization_url")} placeholder="https://" />
278+ </Field>
279+ <Field label="Token URL">
280+ <Input name="token_url" type="url" required defaultValue={value("token_url")} placeholder="https://" />
281+ </Field>
282+ <Field label="Userinfo URL" hint="Read with the access token: who signed in.">
283+ <Input name="userinfo_url" type="url" required defaultValue={value("userinfo_url")} placeholder="https://" />
284+ </Field>
285+ <ClientFields value={value} keepsSecret={keepsSecret} defaultScopes="" />
286+ <div className="grid gap-4 sm:grid-cols-2">
287+ <Field label="Subject claim" hint="The person's stable id in the userinfo answer. sub, then id, when empty.">
288+ <Input name="subject_claim" defaultValue={value("subject_claim")} placeholder="sub" />
289+ </Field>
290+ <Field label="Email claim" hint="Shown beside each person; never used to sign in.">
291+ <Input name="email_claim" defaultValue={value("email_claim")} placeholder="email" />
292+ </Field>
293+ </div>
294+ </>
295+ )}
296+
297+ {protocol === "saml" && (
298+ <>
299+ <Field label="Identity provider entity id" hint="Its issuer, as its metadata says it.">
300+ <Input name="idp_entity_id" required defaultValue={value("idp_entity_id")} />
301+ </Field>
302+ <Field label="Single sign-on URL" hint="Where it takes SAML requests (HTTP-Redirect).">
303+ <Input name="idp_sso_url" type="url" required defaultValue={value("idp_sso_url")} placeholder="https://" />
304+ </Field>
305+ <Field
306+ label="Signing certificate"
307+ hint={
308+ connection?.protocol === "saml" && connection.certificates.length > 0 ? (
309+ <>
310+ Saved, by SHA-256:{" "}
311+ {connection.certificates.map((c) => (
312+ <span key={c.fingerprint} className="block font-mono break-all">
313+ {c.fingerprint}
314+ </span>
315+ ))}
316+ Paste new ones to replace them; several while you rotate.
317+ </>
318+ ) : (
319+ "PEM, starting -----BEGIN CERTIFICATE-----. Sign the assertion or the response with SHA-256, and leave the assertion unencrypted."
320+ )
321+ }
322+ >
323+ <Textarea name="certificate" rows={5} required={!(connection?.protocol === "saml" && connection.certificates.length > 0)} />
324+ </Field>
325+ </>
326+ )}
327+
328+ <SwitchCard name="allow_join" title="Let people from your provider join" defaultChecked={connection?.allow_join ?? true}>
329+ Anyone your provider signs in becomes a member of the workspace, with no invitation. Off: only members can sign in
330+ through it.
331+ </SwitchCard>
332+ </fieldset>
333+ <div className="flex flex-wrap items-center gap-3">
334+ <SubmitButton match={{ intent: "save" }} pending="Checking…" disabled={disabled}>
335+ {connection ? "Save" : "Set up single sign-on"}
336+ </SubmitButton>
337+ <Saved answer={answer} />
338+ </div>
339+ </Form>
340+ </section>
341+ );
342+}
343+
344+function ClientFields({ value, keepsSecret, defaultScopes }: { value: (name: keyof SsoConfig) => string; keepsSecret: boolean; defaultScopes: string }) {
345+ return (
346+ <>
347+ <div className="grid gap-4 sm:grid-cols-2">
348+ <Field label="Client id">
349+ <Input name="client_id" required defaultValue={value("client_id")} />
350+ </Field>
351+ <Field label="Client secret" hint={keepsSecret ? "Saved, and never shown. Enter a new one to replace it." : "Kept encrypted, and never shown again."}>
352+ <Input name="client_secret" type="password" autoComplete="new-password" required={!keepsSecret} placeholder={keepsSecret ? "••••••••" : undefined} />
353+ </Field>
354+ </div>
355+ <Field label="Scopes" hint={defaultScopes ? `Space-separated. ${defaultScopes} when empty.` : "Space-separated, as the provider needs to answer userinfo."}>
356+ <Input name="scopes" defaultValue={value("scopes")} placeholder={defaultScopes} />
357+ </Field>
358+ </>
359+ );
360+}
361+
362+function RequireSection({ settings, slug, disabled, answer }: { settings: SsoSettings; slug: string; disabled: boolean; answer?: Answer }) {
363+ const connection = settings.connection!;
364+ const [chosen, setChosen] = useState(settings.required);
365+ const back = encodeURIComponent(`/${slug}/-/sso`);
366+ // Requiring it needs the owner to have signed in through it; never needed to stop.
367+ const locked = !settings.required && (disabled || !settings.you_linked);
368+ return (
369+ <section aria-labelledby="sso-require">
370+ <h2 id="sso-require" className="font-medium">
371+ Test and require
372+ </h2>
373+ <div className="mt-4 flex flex-wrap items-center gap-3">
374+ {connection.tested_at ? (
375+ <Badge tone="success">
376+ Tested by {connection.tested_by ?? "someone"} <TimeAgo at={connection.tested_at} />
377+ </Badge>
378+ ) : (
379+ <Badge tone="warn">Not tested yet</Badge>
380+ )}
381+ <ButtonLink to={`/auth/sso/${slug}?next=${back}`} variant="outline" size="sm">
382+ {settings.you_linked ? "Sign in through it again" : "Test sign-in"}
383+ </ButtonLink>
384+ </div>
385+ <Form method="post" className="mt-5 space-y-3">
386+ <input type="hidden" name="intent" value="required" />
387+ <SwitchCard name="required" title="Require single sign-on" checked={chosen} onCheckedChange={setChosen} disabled={locked}>
388+ {locked && !disabled
389+ ? "Sign in through your provider yourself first, with Test sign-in."
390+ : `Members use ${slug} only from a session that signed in through your provider in the last day, and their access tokens and SSH keys only once they have linked. Owners always can, so a provider that breaks never locks you out. Outside collaborators are not asked.`}
391+ </SwitchCard>
392+ <div className="flex flex-wrap items-center gap-3">
393+ <SubmitButton variant="outline" size="sm" match={{ intent: "required" }} pending="Saving…" disabled={chosen === settings.required}>
394+ Save
395+ </SubmitButton>
396+ <Saved answer={answer} />
397+ </div>
398+ </Form>
399+ </section>
400+ );
401+}
402+
403+function PeopleSection({ settings, answer }: { settings: SsoSettings; answer?: Answer }) {
404+ return (
405+ <section aria-labelledby="sso-people">
406+ <h2 id="sso-people" className="font-medium">
407+ Linked people
408+ </h2>
409+ <p className="mt-1.5 text-sm text-muted">
410+ Who has signed in through your provider, and who they are there. Unlink someone to let them link another account
411+ there; their sessions sign in through it again.
412+ </p>
413+ <div className="mt-4">
414+ {settings.identities.length === 0 ? (
415+ <p className="text-sm text-faint">No one yet.</p>
416+ ) : (
417+ <Table>
418+ <TableHeader>
419+ <TableRow>
420+ <TableHead>Person</TableHead>
421+ <TableHead>At your provider</TableHead>
422+ <TableHead>Last signed in</TableHead>
423+ <TableHead>
424+ <span className="sr-only">Unlink</span>
425+ </TableHead>
426+ </TableRow>
427+ </TableHeader>
428+ <TableBody>
429+ {settings.identities.map((identity) => (
430+ <TableRow key={identity.username}>
431+ <TableCell className="font-medium">{identity.display_username ?? identity.username}</TableCell>
432+ <TableCell className="max-w-56 truncate text-muted">{identity.email ?? identity.subject}</TableCell>
433+ <TableCell className="text-muted">
434+ <TimeAgo at={identity.last_used_at} />
435+ </TableCell>
436+ <TableCell className="text-right">
437+ <Form method="post">
438+ <input type="hidden" name="intent" value="unlink" />
439+ <input type="hidden" name="username" value={identity.username} />
440+ <SubmitButton variant="ghost" size="sm" match={{ intent: "unlink", username: identity.username }} pending="Unlinking…">
441+ Unlink
442+ </SubmitButton>
443+ </Form>
444+ </TableCell>
445+ </TableRow>
446+ ))}
447+ </TableBody>
448+ </Table>
449+ )}
450+ <div className="mt-2">
451+ <Saved answer={answer} />
452+ </div>
453+ </div>
454+ </section>
455+ );
456+}
+6−0
1616 import { usercontentPath } from "../app/lib/usercontent";
1717 import { TOKEN_CHALLENGE } from "../app/lib/website-token";
1818 import { serveUsercontent } from "./usercontent";
19+import { receiveSaml } from "./sso-saml";
20+import { samlPath } from "../app/lib/sso";
1921 import { serveArchivedAsset } from "../app/lib/asset-archive";
2022 import { needsBuildRefresh } from "../app/lib/build-version";
2123 import { developerResource, pageRepresentation } from "../app/lib/agent-pages";
119121 // session signed in (app/lib/front-door-limits.ts).
120122 const limited = await pageLimited(env, request, pathname);
121123 if (limited) return limited;
124+ // A workspace's identity provider posting a SAML response, from its own
125+ // site: React Router refuses cross-site posts (workers/sso-saml.ts).
126+ const saml = request.method === "POST" ? samlPath(pathname) : null;
127+ if (saml) return receiveSaml(env, request, saml);
122128 // Every page and data request says where its time went (Server-Timing)
123129 // and keeps the reader's D1 bookmarks (app/lib/perf.server.ts).
124130 const render = () => withRequestPerf(request, async () => finishResponse(request, await requestHandler(request)));
+34−0
1+import { ssoClient } from "@g1t/contracts";
2+
3+import { afterSso, errorCookie } from "../app/lib/sso";
4+
5+/**
6+ * A workspace's identity provider posting its SAML response
7+ * (`POST /auth/sso/<slug>/saml`). It comes from the provider's site, so it
8+ * is answered here, before React Router, which refuses cross-site form
9+ * posts; and it carries no session cookie (SameSite=Lax), so identity
10+ * finds the session from the request g1t sent, which the response must
11+ * answer. Either way the browser is sent on with a 303: on to where it was
12+ * going, or back to the page that says what went wrong.
13+ */
14+export async function receiveSaml(env: Env, request: Request, slug: string): Promise<Response> {
15+ const back = (message: string) =>
16+ new Response(null, {
17+ status: 303,
18+ headers: { location: `/auth/sso/${slug}/saml`, "set-cookie": errorCookie(message), "cache-control": "no-store" },
19+ });
20+ let samlResponse = "";
21+ try {
22+ const form = await request.formData();
23+ samlResponse = String(form.get("SAMLResponse") ?? "");
24+ } catch {
25+ return back("The identity provider's response could not be read.");
26+ }
27+ if (!samlResponse) return back("The identity provider sent no SAML response.");
28+ const finished = await ssoClient(env.IDENTITY)
29+ .saml(slug, samlResponse)
30+ .catch(() => null);
31+ if (!finished) return back("g1t could not check the identity provider's response just now. Try again in a minute.");
32+ if (!finished.ok) return back(finished.error.message);
33+ return new Response(null, { status: 303, headers: { location: afterSso(finished.value), "cache-control": "no-store" } });
34+}
+1−0
4747 pub mod security;
4848 pub mod teams;
4949 pub mod security_suite;
50+pub mod sso;
5051 pub mod subscribers;
5152 pub mod time;
5253 pub mod tokens;
+283−0
1+//! Single sign-on: a workspace's own identity provider, over OpenID
2+//! Connect, OAuth 2.0 or SAML 2.0. On every paid plan; a free workspace
3+//! sees it and cannot set it up. Mirrors `packages/contracts/src/sso.ts`.
4+//!
5+//! It never signs anyone in to g1t. A person signs in to g1t as
6+//! themselves, then proves themselves to the workspace's provider to use
7+//! the workspace, the first time linking who they are there to their g1t
8+//! account. A workspace can require it of its members: then a member uses
9+//! it only from a session that went through its provider in the last
10+//! day, and an access token of theirs only once they have linked. Owners
11+//! are never held out, so a provider that breaks never locks a workspace
12+//! out of itself.
13+//!
14+//! The identity service (`POST /rpc/<method>`):
15+//!
16+//! - `sso_settings` takes `SsoSettingsArgs`, returns `Outcome<SsoSettings>`.
17+//! - `save_sso` takes `SaveSsoArgs`, returns `Outcome<SsoSettings>`.
18+//! - `set_sso_required` takes `SetSsoRequiredArgs`, returns `Outcome<bool>`.
19+//! - `delete_sso` takes `SsoSettingsArgs`, returns `Outcome<bool>`.
20+//! - `unlink_sso_identity` takes `UnlinkSsoIdentityArgs`, returns `Outcome<bool>`.
21+//! - `sso_start` takes `SsoStartArgs`, returns `Outcome<SsoStart>`.
22+//! - `sso_finish` takes `SsoFinishArgs`, returns `Outcome<SsoFinished>`.
23+//! - `sso_saml` takes `SsoSamlArgs`, returns `Outcome<SsoFinished>`.
24+//! - `sso_summary` takes `SlugArgs`, returns `SsoSummary`.
25+//! - `sso_metadata` takes `SsoMetadataArgs`, returns the XML as a string.
26+
27+use serde::{Deserialize, Serialize};
28+
29+use crate::User;
30+use crate::audit::Surface;
31+
32+/// How a workspace's provider is spoken to.
33+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
34+#[serde(rename_all = "snake_case")]
35+pub enum SsoProtocol {
36+ /// OpenID Connect: an issuer whose discovery document says the rest.
37+ Oidc,
38+ /// Plain OAuth 2.0: authorize, token and userinfo endpoints, and which
39+ /// claims of the userinfo answer are the person's id and address.
40+ Oauth2,
41+ /// SAML 2.0: the provider's entity id, sign-in URL and certificates.
42+ Saml,
43+}
44+
45+impl SsoProtocol {
46+ pub fn as_str(self) -> &'static str {
47+ match self {
48+ SsoProtocol::Oidc => "oidc",
49+ SsoProtocol::Oauth2 => "oauth2",
50+ SsoProtocol::Saml => "saml",
51+ }
52+ }
53+
54+ pub fn parse(text: &str) -> Option<SsoProtocol> {
55+ match text {
56+ "oidc" => Some(SsoProtocol::Oidc),
57+ "oauth2" => Some(SsoProtocol::Oauth2),
58+ "saml" => Some(SsoProtocol::Saml),
59+ _ => None,
60+ }
61+ }
62+}
63+
64+/// What a connection needs, by protocol; the fields of the others are
65+/// absent. Stored as JSON, and shown to owners as it is: it holds no
66+/// secret.
67+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
68+pub struct SsoConfig {
69+ /// OpenID Connect: the issuer, such as `https://acme.okta.com`.
70+ #[serde(default, skip_serializing_if = "Option::is_none")]
71+ pub issuer: Option<String>,
72+ /// OpenID Connect and OAuth 2.0.
73+ #[serde(default, skip_serializing_if = "Option::is_none")]
74+ pub client_id: Option<String>,
75+ /// Space-separated. OpenID Connect always asks for `openid`.
76+ #[serde(default, skip_serializing_if = "Option::is_none")]
77+ pub scopes: Option<String>,
78+ /// OAuth 2.0.
79+ #[serde(default, skip_serializing_if = "Option::is_none")]
80+ pub authorization_url: Option<String>,
81+ #[serde(default, skip_serializing_if = "Option::is_none")]
82+ pub token_url: Option<String>,
83+ #[serde(default, skip_serializing_if = "Option::is_none")]
84+ pub userinfo_url: Option<String>,
85+ /// The userinfo claim that is the person's stable id; `sub` when
86+ /// absent, then `id`.
87+ #[serde(default, skip_serializing_if = "Option::is_none")]
88+ pub subject_claim: Option<String>,
89+ /// The userinfo claim that is their address; `email` when absent.
90+ #[serde(default, skip_serializing_if = "Option::is_none")]
91+ pub email_claim: Option<String>,
92+ /// SAML: the provider's entity id (its issuer).
93+ #[serde(default, skip_serializing_if = "Option::is_none")]
94+ pub idp_entity_id: Option<String>,
95+ /// SAML: where its single sign-on service takes requests.
96+ #[serde(default, skip_serializing_if = "Option::is_none")]
97+ pub idp_sso_url: Option<String>,
98+ /// SAML: its signing certificates, base64 DER; more than one while a
99+ /// certificate is rotated.
100+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
101+ pub certificates: Vec<String>,
102+}
103+
104+/// A SAML signing certificate, as shown.
105+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
106+pub struct SsoCertificate {
107+ /// SHA-256, `AB:CD:…`, as providers show it.
108+ pub fingerprint: String,
109+}
110+
111+/// A workspace's connection to its provider, as its owners see it.
112+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
113+pub struct SsoConnection {
114+ pub id: String,
115+ pub protocol: SsoProtocol,
116+ pub config: SsoConfig,
117+ /// Whether a client secret is kept; it is never shown.
118+ pub has_client_secret: bool,
119+ pub certificates: Vec<SsoCertificate>,
120+ /// Whether anyone the provider signs in may join as a member.
121+ pub allow_join: bool,
122+ /// When someone first signed in through it, and who (a username).
123+ pub tested_at: Option<String>,
124+ pub tested_by: Option<String>,
125+ pub created_at: String,
126+ pub updated_at: String,
127+}
128+
129+/// Someone linked to who they are at the workspace's provider.
130+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
131+pub struct SsoIdentity {
132+ pub username: String,
133+ #[serde(default, skip_serializing_if = "Option::is_none")]
134+ pub display_username: Option<String>,
135+ /// The provider's id for them.
136+ pub subject: String,
137+ /// The address the provider gave, for showing.
138+ pub email: Option<String>,
139+ pub linked_at: String,
140+ pub last_used_at: String,
141+}
142+
143+/// A workspace's single sign-on, for its settings page.
144+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
145+pub struct SsoSettings {
146+ pub slug: String,
147+ pub connection: Option<SsoConnection>,
148+ /// Whether members must sign in through it.
149+ pub required: bool,
150+ /// Whether the person asking has signed in through it: an owner must
151+ /// before requiring it.
152+ pub you_linked: bool,
153+ pub identities: Vec<SsoIdentity>,
154+}
155+
156+/// Whether a workspace has single sign-on, for anyone: the sign-in page
157+/// and the notice for someone it holds out.
158+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
159+pub struct SsoSummary {
160+ pub enabled: bool,
161+ pub protocol: Option<SsoProtocol>,
162+ pub required: bool,
163+ pub allow_join: bool,
164+}
165+
166+#[derive(Debug, Serialize, Deserialize)]
167+pub struct SsoSettingsArgs {
168+ pub actor: User,
169+ pub slug: String,
170+ #[serde(default)]
171+ pub surface: Option<Surface>,
172+}
173+
174+/// `save_sso`: sets up or changes the workspace's connection. Owners only,
175+/// on a paid plan. A secret or certificate left empty keeps the one saved.
176+/// Changing which provider it is (protocol, issuer, entity id) unlinks
177+/// everyone, stops requiring it, and needs a sign-in through it again.
178+#[derive(Debug, Serialize, Deserialize)]
179+pub struct SaveSsoArgs {
180+ pub actor: User,
181+ pub slug: String,
182+ pub protocol: SsoProtocol,
183+ #[serde(default)]
184+ pub config: SsoConfig,
185+ #[serde(default)]
186+ pub client_secret: Option<String>,
187+ /// SAML: certificates as pasted (PEM).
188+ #[serde(default)]
189+ pub certificate: Option<String>,
190+ #[serde(default = "yes")]
191+ pub allow_join: bool,
192+ #[serde(default)]
193+ pub surface: Option<Surface>,
194+}
195+
196+fn yes() -> bool {
197+ true
198+}
199+
200+/// `set_sso_required`: whether members must sign in through the provider.
201+/// Turning it on needs a paid plan and the owner to have signed in through
202+/// it themselves; turning it off never does.
203+#[derive(Debug, Serialize, Deserialize)]
204+pub struct SetSsoRequiredArgs {
205+ pub actor: User,
206+ pub slug: String,
207+ pub required: bool,
208+ #[serde(default)]
209+ pub surface: Option<Surface>,
210+}
211+
212+/// `unlink_sso_identity`: forgets who someone is at the provider, so they
213+/// link again (or another person can) the next time. Owners only.
214+#[derive(Debug, Serialize, Deserialize)]
215+pub struct UnlinkSsoIdentityArgs {
216+ pub actor: User,
217+ pub slug: String,
218+ pub username: String,
219+ #[serde(default)]
220+ pub surface: Option<Surface>,
221+}
222+
223+/// `sso_start`: the signed-in person sets off to the workspace's provider.
224+#[derive(Debug, Serialize, Deserialize)]
225+pub struct SsoStartArgs {
226+ pub user: User,
227+ /// The session it is for: what comes back counts for it alone.
228+ pub session_token: String,
229+ pub slug: String,
230+ /// The callback (OpenID Connect, OAuth 2.0) or assertion consumer
231+ /// service (SAML), exactly as registered with the provider.
232+ pub redirect_uri: String,
233+ /// SAML: g1t's entity id for the workspace.
234+ #[serde(default)]
235+ pub entity_id: Option<String>,
236+ /// A same-site path to return to.
237+ #[serde(default)]
238+ pub next: String,
239+}
240+
241+#[derive(Debug, Serialize, Deserialize)]
242+pub struct SsoStart {
243+ /// Where to send the browser.
244+ pub url: String,
245+ /// OpenID Connect and OAuth 2.0: the state, for a cookie that binds
246+ /// the trip to this browser. Absent for SAML, whose response is posted
247+ /// cross-site, without cookies; its request id binds it instead.
248+ pub state: Option<String>,
249+}
250+
251+/// `sso_finish`: the provider sent the browser back with a code.
252+#[derive(Debug, Serialize, Deserialize)]
253+pub struct SsoFinishArgs {
254+ pub slug: String,
255+ pub session_token: String,
256+ pub state: String,
257+ pub code: String,
258+}
259+
260+/// `sso_saml`: the provider posted its response.
261+#[derive(Debug, Serialize, Deserialize)]
262+pub struct SsoSamlArgs {
263+ pub slug: String,
264+ pub saml_response: String,
265+}
266+
267+#[derive(Debug, Serialize, Deserialize)]
268+pub struct SsoFinished {
269+ pub slug: String,
270+ pub next: String,
271+ /// Whether they joined the workspace just now.
272+ pub joined: bool,
273+ /// Whether this was the first sign-in through the connection, which
274+ /// marks it tested.
275+ pub first: bool,
276+}
277+
278+/// `sso_metadata`: g1t's SAML service provider metadata for a workspace.
279+#[derive(Debug, Serialize, Deserialize)]
280+pub struct SsoMetadataArgs {
281+ pub entity_id: String,
282+ pub acs_url: String,
283+}
+2−1
2121 "./coding-agents": "./src/coding-agents.ts",
2222 "./discovery": "./src/discovery.ts",
2323 "./telemetry": "./src/telemetry.ts",
24− "./agent-setup": "./src/agent-setup.ts"
24+ "./agent-setup": "./src/agent-setup.ts",
25+ "./sso": "./src/sso.ts"
2526 },
2627 "scripts": {
2728 "test": "node --test src/*.test.ts",
+1−0
5353 export * from "./search";
5454 export * from "./security";
5555 export * from "./security-suite";
56+export * from "./sso";
5657 export * from "./status";
5758 export * from "./teams";
5859 export * from "./people";
+1−1
3333 export type PolicyHold = {
3434 slug: string;
3535 reason: string;
36− gap: "two_factor" | "verified_email" | "email_domain";
36+ gap: "two_factor" | "verified_email" | "email_domain" | "sso";
3737 };
+121−0
1+/**
2+ * Single sign-on: a workspace's own identity provider, over OpenID
3+ * Connect, OAuth 2.0 or SAML 2.0, on every paid plan. Mirrors
4+ * `crates/contracts/src/sso.rs`, which says how it works: it never signs
5+ * anyone in to g1t; a person signed in to g1t proves themselves to the
6+ * workspace's provider to use the workspace.
7+ */
8+import type { ServiceBinding } from "./clients";
9+import type { User } from "./identity";
10+import type { Result } from "./result";
11+
12+export type SsoProtocol = "oidc" | "oauth2" | "saml";
13+
14+/** What a connection needs, by protocol; it holds no secret. */
15+export type SsoConfig = {
16+ issuer?: string | null;
17+ client_id?: string | null;
18+ scopes?: string | null;
19+ authorization_url?: string | null;
20+ token_url?: string | null;
21+ userinfo_url?: string | null;
22+ subject_claim?: string | null;
23+ email_claim?: string | null;
24+ idp_entity_id?: string | null;
25+ idp_sso_url?: string | null;
26+ certificates?: string[];
27+};
28+
29+export type SsoConnection = {
30+ id: string;
31+ protocol: SsoProtocol;
32+ config: SsoConfig;
33+ has_client_secret: boolean;
34+ /** SAML's signing certificates, by SHA-256 fingerprint. */
35+ certificates: { fingerprint: string }[];
36+ allow_join: boolean;
37+ tested_at: string | null;
38+ /** The username of whoever first signed in through it. */
39+ tested_by: string | null;
40+ created_at: string;
41+ updated_at: string;
42+};
43+
44+export type SsoIdentity = {
45+ username: string;
46+ display_username?: string | null;
47+ subject: string;
48+ email: string | null;
49+ linked_at: string;
50+ last_used_at: string;
51+};
52+
53+export type SsoSettings = {
54+ slug: string;
55+ connection: SsoConnection | null;
56+ required: boolean;
57+ /** Whether the owner asking has signed in through it, which requiring it needs. */
58+ you_linked: boolean;
59+ identities: SsoIdentity[];
60+};
61+
62+export type SsoSummary = {
63+ enabled: boolean;
64+ protocol: SsoProtocol | null;
65+ required: boolean;
66+ allow_join: boolean;
67+};
68+
69+export type SsoFinished = { slug: string; next: string; joined: boolean; first: boolean };
70+
71+export type SaveSso = {
72+ protocol: SsoProtocol;
73+ config: SsoConfig;
74+ /** Empty keeps the one saved. */
75+ client_secret?: string | null;
76+ /** SAML certificates as pasted (PEM); empty keeps the ones saved. */
77+ certificate?: string | null;
78+ allow_join: boolean;
79+};
80+
81+async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> {
82+ const response = await service.fetch(`https://service/rpc/${method}`, {
83+ method: "POST",
84+ headers: { "content-type": "application/json" },
85+ body: JSON.stringify(args),
86+ });
87+ if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
88+ return (await response.json()) as T;
89+}
90+
91+/** Single sign-on: methods of the identity service. */
92+export function ssoClient(identity: ServiceBinding) {
93+ const call = <T>(method: string, args: object) => rpc<T>(identity, method, args);
94+ return {
95+ settings: (actor: User, slug: string) => call<Result<SsoSettings>>("sso_settings", { actor, slug }),
96+ summary: (slug: string) => call<SsoSummary>("sso_summary", { slug }),
97+ save: (actor: User, slug: string, input: SaveSso) =>
98+ call<Result<SsoSettings>>("save_sso", { actor, slug, surface: "web", ...input }),
99+ setRequired: (actor: User, slug: string, required: boolean) =>
100+ call<Result<boolean>>("set_sso_required", { actor, slug, required, surface: "web" }),
101+ remove: (actor: User, slug: string) => call<Result<boolean>>("delete_sso", { actor, slug, surface: "web" }),
102+ unlink: (actor: User, slug: string, username: string) =>
103+ call<Result<boolean>>("unlink_sso_identity", { actor, slug, username, surface: "web" }),
104+ start: (input: { user: User; session_token: string; slug: string; redirect_uri: string; entity_id?: string | null; next: string }) =>
105+ call<Result<{ url: string; state: string | null }>>("sso_start", input),
106+ finish: (input: { slug: string; session_token: string; state: string; code: string }) =>
107+ call<Result<SsoFinished>>("sso_finish", input),
108+ saml: (slug: string, samlResponse: string) => call<Result<SsoFinished>>("sso_saml", { slug, saml_response: samlResponse }),
109+ metadata: (entityId: string, acsUrl: string) => call<string>("sso_metadata", { entity_id: entityId, acs_url: acsUrl }),
110+ };
111+}
112+
113+/**
114+ * Where a workspace's provider is pointed, from the site's origin: g1t's
115+ * entity id (its metadata's address), the assertion consumer service, and
116+ * the callback for OpenID Connect and OAuth 2.0.
117+ */
118+export function ssoAddresses(origin: string, slug: string) {
119+ const base = `${origin.replace(/\/+$/, "")}/auth/sso/${encodeURIComponent(slug.toLowerCase())}`;
120+ return { entityId: `${base}/metadata`, metadataUrl: `${base}/metadata`, acsUrl: `${base}/saml`, callbackUrl: `${base}/callback` };
121+}
+3−0
2222 sha1 = "0.10"
2323 pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] }
2424 sha2 = "0.10"
25+roxmltree = "0.20"
26+miniz_oxide = "0.9"
27+rsa = { version = "0.9", default-features = false, features = ["std", "sha2"] }
2528
2629 # wasm-opt at -O1: about the same gzipped size as -O in a tenth of the
2730 # time.
+78−0
Loading the diff…
+36−10
Loading the diff…
+8−2
Loading the diff…
+1010−0
A large diff (1,010 lines) is not shown until you ask for it.
+319−0
Loading the diff…
+688−0
Loading the diff…
+24−0
Loading the diff…
+196−0
Loading the diff…
+15−6
Loading the diff…

Comments

Sign in to comment.