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

Harden single sign-on and SCIM after review

OpenID Connect ID tokens are now verified against the issuer's published keys (jwks_uri): RS256/384/512, PS256/384/512 and ES256, checked against tokens signed by an independent signer. none, HMAC and keys named in the token are refused; iat in the future is refused; Google's schemeless issuer is accepted for Google only. Fixes from an adversarial review: - SAML: comments and processing instructions are refused (a comment could shorten a signed NameID or address, as CVE-2017-11427), text is read whole, nesting is capped before canonicalization, and when only the assertion is signed its SubjectConfirmationData must name the request. - A non-member confirms with a same-site POST before setting off, so a link from another site can never make someone a member. - Joining is off by default, and can be limited to addresses the provider vouches for at named domains. The per-person workspace limit applies. - git over HTTPS with a password never satisfies a requirement for SSO; tokens and keys need a link, and where SCIM is on, an active assignment (sessions too). - SCIM: matching uses stable ids (NameID, sub, Entra oid and member UPN) or a vouched-for primary address, and refuses ambiguity; deprovisioning works on an ended plan (only assigning or reactivating needs one), finds the link when the assignment had none, and revokes pending invitations; unlinking frees the assignment. - complete() decides every refusal before writing anything; address-shaped subjects are lowercased. Refs #56

G1t-Session:
ses_01m54fhewne5tbgqqh4n8f5fvz
syntaqxcommitted Parent65cc908Browse files
20 files+1009−2060/20 viewed
+153−37
364364
365365 [[package]]
366366 name = "base16ct"
367+version = "0.2.0"
368+source = "registry+https://github.com/rust-lang/crates.io-index"
369+checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
370+
371+[[package]]
372+name = "base16ct"
367373 version = "1.0.0"
368374 source = "registry+https://github.com/rust-lang/crates.io-index"
369375 checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
754760
755761 [[package]]
756762 name = "crypto-bigint"
763+version = "0.5.5"
764+source = "registry+https://github.com/rust-lang/crates.io-index"
765+checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
766+dependencies = [
767+ "generic-array 0.14.7",
768+ "rand_core 0.6.4",
769+ "subtle",
770+ "zeroize",
771+]
772+
773+[[package]]
774+name = "crypto-bigint"
757775 version = "0.7.5"
758776 source = "registry+https://github.com/rust-lang/crates.io-index"
759777 checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271"
797815 source = "registry+https://github.com/rust-lang/crates.io-index"
798816 checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f"
799817 dependencies = [
800− "crypto-bigint",
818+ "crypto-bigint 0.7.5",
801819 "rand_core 0.10.1",
802820 ]
803821
9901008
9911009 [[package]]
9921010 name = "ecdsa"
1011+version = "0.16.9"
1012+source = "registry+https://github.com/rust-lang/crates.io-index"
1013+checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
1014+dependencies = [
1015+ "der 0.7.10",
1016+ "digest 0.10.7",
1017+ "elliptic-curve 0.13.8",
1018+ "rfc6979 0.4.0",
1019+ "signature 2.2.0",
1020+]
1021+
1022+[[package]]
1023+name = "ecdsa"
9931024 version = "0.17.0"
9941025 source = "registry+https://github.com/rust-lang/crates.io-index"
9951026 checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0"
9961027 dependencies = [
9971028 "der 0.8.2",
9981029 "digest 0.11.3",
999− "elliptic-curve",
1000− "rfc6979",
1030+ "elliptic-curve 0.14.1",
1031+ "rfc6979 0.6.0",
10011032 "signature 3.0.0",
10021033 "spki 0.8.0",
10031034 "zeroize",
10311062
10321063 [[package]]
10331064 name = "elliptic-curve"
1065+version = "0.13.8"
1066+source = "registry+https://github.com/rust-lang/crates.io-index"
1067+checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
1068+dependencies = [
1069+ "base16ct 0.2.0",
1070+ "crypto-bigint 0.5.5",
1071+ "digest 0.10.7",
1072+ "ff 0.13.1",
1073+ "generic-array 0.14.7",
1074+ "group 0.13.0",
1075+ "rand_core 0.6.4",
1076+ "sec1 0.7.3",
1077+ "subtle",
1078+ "zeroize",
1079+]
1080+
1081+[[package]]
1082+name = "elliptic-curve"
10341083 version = "0.14.1"
10351084 source = "registry+https://github.com/rust-lang/crates.io-index"
10361085 checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65"
10371086 dependencies = [
1038− "base16ct",
1039− "crypto-bigint",
1087+ "base16ct 1.0.0",
1088+ "crypto-bigint 0.7.5",
10401089 "crypto-common 0.2.2",
10411090 "digest 0.11.3",
1042− "ff",
1043− "group",
1091+ "ff 0.14.0",
1092+ "group 0.14.0",
10441093 "hkdf",
10451094 "hybrid-array",
10461095 "pem-rfc7468",
10471096 "pkcs8 0.11.0",
10481097 "rand_core 0.10.1",
1049− "sec1",
1098+ "sec1 0.8.1",
10501099 "subtle",
10511100 "zeroize",
10521101 ]
11601209
11611210 [[package]]
11621211 name = "ff"
1212+version = "0.13.1"
1213+source = "registry+https://github.com/rust-lang/crates.io-index"
1214+checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
1215+dependencies = [
1216+ "rand_core 0.6.4",
1217+ "subtle",
1218+]
1219+
1220+[[package]]
1221+name = "ff"
11631222 version = "0.14.0"
11641223 source = "registry+https://github.com/rust-lang/crates.io-index"
11651224 checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f"
14611520 "hex",
14621521 "hmac 0.12.1",
14631522 "miniz_oxide",
1523+ "p256 0.13.2",
14641524 "pbkdf2 0.12.2",
14651525 "roxmltree",
14661526 "rsa 0.9.10",
16841744 dependencies = [
16851745 "typenum",
16861746 "version_check",
1747+ "zeroize",
16871748 ]
16881749
16891750 [[package]]
17461807
17471808 [[package]]
17481809 name = "group"
1810+version = "0.13.0"
1811+source = "registry+https://github.com/rust-lang/crates.io-index"
1812+checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
1813+dependencies = [
1814+ "ff 0.13.1",
1815+ "rand_core 0.6.4",
1816+ "subtle",
1817+]
1818+
1819+[[package]]
1820+name = "group"
17491821 version = "0.14.0"
17501822 source = "registry+https://github.com/rust-lang/crates.io-index"
17511823 checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4"
17521824 dependencies = [
1753− "ff",
1825+ "ff 0.14.0",
17541826 "rand_core 0.10.1",
17551827 "subtle",
17561828 ]
26782750
26792751 [[package]]
26802752 name = "p256"
2753+version = "0.13.2"
2754+source = "registry+https://github.com/rust-lang/crates.io-index"
2755+checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
2756+dependencies = [
2757+ "ecdsa 0.16.9",
2758+ "elliptic-curve 0.13.8",
2759+ "primeorder 0.13.6",
2760+ "sha2 0.10.9",
2761+]
2762+
2763+[[package]]
2764+name = "p256"
26812765 version = "0.14.0"
26822766 source = "registry+https://github.com/rust-lang/crates.io-index"
26832767 checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a"
26842768 dependencies = [
2685− "ecdsa",
2686− "elliptic-curve",
2769+ "ecdsa 0.17.0",
2770+ "elliptic-curve 0.14.1",
26872771 "primefield",
2688− "primeorder",
2772+ "primeorder 0.14.0",
26892773 "sha2 0.11.0",
26902774 ]
26912775
26952779 source = "registry+https://github.com/rust-lang/crates.io-index"
26962780 checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f"
26972781 dependencies = [
2698− "ecdsa",
2699− "elliptic-curve",
2782+ "ecdsa 0.17.0",
2783+ "elliptic-curve 0.14.1",
27002784 "fiat-crypto",
27012785 "primefield",
2702− "primeorder",
2786+ "primeorder 0.14.0",
27032787 "sha2 0.11.0",
27042788 ]
27052789
27092793 source = "registry+https://github.com/rust-lang/crates.io-index"
27102794 checksum = "4ad64cc32c2dc466317c12ee5853e61f159f9eab1fe7efade0395dc2e7b43449"
27112795 dependencies = [
2712− "base16ct",
2713− "ecdsa",
2714− "elliptic-curve",
2796+ "base16ct 1.0.0",
2797+ "ecdsa 0.17.0",
2798+ "elliptic-curve 0.14.1",
27152799 "primefield",
2716− "primeorder",
2800+ "primeorder 0.14.0",
27172801 "sha2 0.11.0",
27182802 ]
27192803
27232807 source = "registry+https://github.com/rust-lang/crates.io-index"
27242808 checksum = "6d8eab09a361a4afe0b1668be978cd97e4f052e927a92b0b608cf902965d49ce"
27252809 dependencies = [
2726− "base16ct",
2810+ "base16ct 1.0.0",
27272811 "byteorder",
27282812 "bytes",
27292813 "delegate",
30273111 source = "registry+https://github.com/rust-lang/crates.io-index"
30283112 checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4"
30293113 dependencies = [
3030− "crypto-bigint",
3114+ "crypto-bigint 0.7.5",
30313115 "crypto-common 0.2.2",
3032− "ff",
3116+ "ff 0.14.0",
30333117 "rand_core 0.10.1",
30343118 "subtle",
30353119 "zeroize",
30373121
30383122 [[package]]
30393123 name = "primeorder"
3124+version = "0.13.6"
3125+source = "registry+https://github.com/rust-lang/crates.io-index"
3126+checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
3127+dependencies = [
3128+ "elliptic-curve 0.13.8",
3129+]
3130+
3131+[[package]]
3132+name = "primeorder"
30403133 version = "0.14.0"
30413134 source = "registry+https://github.com/rust-lang/crates.io-index"
30423135 checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06"
30433136 dependencies = [
3044− "elliptic-curve",
3137+ "elliptic-curve 0.14.1",
30453138 "once_cell",
30463139 "primefield",
30473140 "serdect",
32953388
32963389 [[package]]
32973390 name = "rfc6979"
3391+version = "0.4.0"
3392+source = "registry+https://github.com/rust-lang/crates.io-index"
3393+checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
3394+dependencies = [
3395+ "hmac 0.12.1",
3396+ "subtle",
3397+]
3398+
3399+[[package]]
3400+name = "rfc6979"
32983401 version = "0.6.0"
32993402 source = "registry+https://github.com/rust-lang/crates.io-index"
33003403 checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b"
33013404 dependencies = [
3302− "crypto-bigint",
3405+ "crypto-bigint 0.7.5",
33033406 "hmac 0.13.0",
33043407 ]
33053408
33623465 checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf"
33633466 dependencies = [
33643467 "const-oid 0.10.2",
3365− "crypto-bigint",
3468+ "crypto-bigint 0.7.5",
33663469 "crypto-primes",
33673470 "digest 0.11.3",
33683471 "pkcs1 0.8.0-rc.4",
34123515 "bytes",
34133516 "cbc",
34143517 "cipher 0.5.2",
3415− "crypto-bigint",
3518+ "crypto-bigint 0.7.5",
34163519 "ctr 0.10.1",
34173520 "curve25519-dalek",
34183521 "data-encoding",
34193522 "delegate",
34203523 "der 0.8.2",
34213524 "digest 0.11.3",
3422− "ecdsa",
3525+ "ecdsa 0.17.0",
34233526 "ed25519-dalek",
3424− "elliptic-curve",
3527+ "elliptic-curve 0.14.1",
34253528 "enum_dispatch",
34263529 "flate2",
34273530 "futures",
34373540 "ml-kem",
34383541 "module-lattice",
34393542 "num-bigint 0.5.1",
3440− "p256",
3543+ "p256 0.14.0",
34413544 "p384",
34423545 "p521",
34433546 "pageant",
34533556 "russh-util",
34543557 "salsa20",
34553558 "scrypt",
3456− "sec1",
3559+ "sec1 0.8.1",
34573560 "sha1 0.11.0",
34583561 "sha2 0.11.0",
34593562 "sha3 0.12.0",
36803783
36813784 [[package]]
36823785 name = "sec1"
3786+version = "0.7.3"
3787+source = "registry+https://github.com/rust-lang/crates.io-index"
3788+checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
3789+dependencies = [
3790+ "base16ct 0.2.0",
3791+ "der 0.7.10",
3792+ "generic-array 0.14.7",
3793+ "subtle",
3794+ "zeroize",
3795+]
3796+
3797+[[package]]
3798+name = "sec1"
36833799 version = "0.8.1"
36843800 source = "registry+https://github.com/rust-lang/crates.io-index"
36853801 checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d"
36863802 dependencies = [
3687− "base16ct",
3803+ "base16ct 1.0.0",
36883804 "ctutils",
36893805 "der 0.8.2",
36903806 "hybrid-array",
38653981 source = "registry+https://github.com/rust-lang/crates.io-index"
38663982 checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e"
38673983 dependencies = [
3868− "base16ct",
3984+ "base16ct 1.0.0",
38693985 "serde",
38703986 ]
38713987
40784194 dependencies = [
40794195 "base64ct",
40804196 "bytes",
4081− "crypto-bigint",
4197+ "crypto-bigint 0.7.5",
40824198 "ctutils",
40834199 "digest 0.11.3",
40844200 "pem-rfc7468",
40974213 "ed25519-dalek",
40984214 "hex",
40994215 "hmac 0.13.0",
4100− "p256",
4216+ "p256 0.14.0",
41014217 "p384",
41024218 "p521",
41034219 "rand_core 0.10.1",
41044220 "rsa 0.10.0-rc.18",
4105− "sec1",
4221+ "sec1 0.8.1",
41064222 "sha1 0.11.0",
41074223 "sha2 0.11.0",
41084224 "signature 3.0.0",
50765192 source = "registry+https://github.com/rust-lang/crates.io-index"
50775193 checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa"
50785194 dependencies = [
5079− "ff",
5080− "group",
5195+ "ff 0.14.0",
5196+ "group 0.14.0",
50815197 "hybrid-array",
50825198 "primefield",
50835199 ]
+11−3
8383 pub fn description(self) -> &'static str {
8484 match self {
8585 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.",
86− 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.",
86+ 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 (false by default) lets anyone the provider signs in join as a member; join_domains limits that to addresses the provider vouches for at those domains. 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.",
8787 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.",
8888 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.",
8989 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.",
119119 "idp_entity_id": { "type": "string", "description": "saml: the provider's entity id (its issuer)." },
120120 "idp_sso_url": { "type": "string", "description": "saml: where the provider takes requests (HTTP-Redirect, https)." },
121121 "certificate": { "type": "string", "description": "saml: the provider's signing certificates, PEM. Left out keeps the ones saved." },
122− "allow_join": { "type": "boolean", "description": "Anyone the provider signs in joins as a member. True by default." },
122+ "allow_join": { "type": "boolean", "description": "Anyone the provider signs in joins as a member when they first sign in through it. False by default; leave it off for a provider anyone can sign in to." },
123+ "join_domains": { "type": "array", "items": { "type": "string" }, "description": "When joining is on, only people whose address the provider vouches for (OpenID's email_verified, or a signed SAML assertion) is at one of these domains, such as [\"acme.com\"]. Empty: anyone it signs in." },
123124 }),
124125 &["workspace", "protocol"],
125126 ),
169170 config[field] = json!(value);
170171 }
171172 }
173+ // A list, or one comma-separated string.
174+ let domains: Vec<String> = match &input["join_domains"] {
175+ Value::Array(items) => items.iter().filter_map(Value::as_str).map(str::to_owned).collect(),
176+ Value::String(text) => text.split(',').map(str::to_owned).collect(),
177+ _ => Vec::new(),
178+ };
179+ config["join_domains"] = json!(domains);
172180 serde_json::from_value(config).unwrap_or_default()
173181 }
174182
227235 "config": config_of(input),
228236 "client_secret": text(input, "client_secret"),
229237 "certificate": text(input, "certificate"),
230− "allow_join": flag(input, "allow_join").unwrap_or(true),
238+ "allow_join": flag(input, "allow_join").unwrap_or(false),
231239 "surface": surface,
232240 }),
233241 )
+30−15
6161
6262 ## Let people join
6363
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.
64+Turn on **Let people from your provider join** (it starts off) and anyone
65+your provider signs in becomes a member of the workspace when they first
66+sign in through it, with no invitation: send them to
67+`g1t.sh/auth/sso/<workspace>`. g1t asks them to confirm first, so a link
68+alone never makes anyone a member. They need a g1t account with a
69+confirmed email address, and the workspace's other rules still apply, such
70+as [requiring two-factor authentication](/guides/workspaces/#require-two-factor-authentication).
7271
72+**Only addresses at** limits who may join to people whose address your
73+provider vouches for (OpenID Connect's `email_verified`, or a signed SAML
74+assertion) at your domains, such as `acme.com`. Set it, or leave joining
75+off, whenever your provider lets anyone sign in: a personal Google or
76+GitHub account would otherwise let in anyone. With joining off, only
77+people who are already members can sign in through it.
78+
7379 ## Require it
7480
7581 Turn on **Require single sign-on** under **Test and require**. From then
7884 - **On the site**, from a session that signed in through your provider in
7985 the last day. After a day they go through it again, which is usually
8086 instant: your provider remembers them.
81−- **With an access token, SSH key or git credentials**, once they have
82− linked their account.
87+- **With an access token or SSH key**, once they have linked their account
88+ (and, with [SCIM](#scim), while your provider still has them assigned).
89+- **Never with their g1t password** for git over HTTPS: use a token.
8390
8491 A member who has not keeps their place but cannot use the workspace: its
8592 private repositories, pages and API answer as if they were not a member,
107114
108115 - **OpenID Connect and OAuth 2.0** use the authorization code flow with
109116 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.
117+ your client id, unexpired, and answer this sign-in's nonce, and its
118+ signature must verify with a key your provider publishes (its
119+ `jwks_uri`): RS256, RS384, RS512, PS256, PS384, PS512 or ES256. Tokens
120+ signed with a shared secret (HS256) or not at all are refused.
112121 - **SAML** responses must be signed, the assertion or the whole response,
113122 with RSA and SHA-256, SHA-384 or SHA-512, by one of the certificates you
114123 saved. The response must answer the request g1t sent (sign-ins started
115124 at your provider are refused), be addressed to the workspace's assertion
116125 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.
126+ provider's entity id, and be in its validity window. When only the
127+ assertion is signed, its own subject confirmation must name g1t's
128+ request. Responses with comments or processing instructions are refused.
129+ Leave the assertion unencrypted: it reaches g1t over HTTPS.
119130
120131 ## If the plan ends
121132
192203 | Supported | Create, update (PUT and PATCH), deactivate and delete users; filtering by `userName`, `externalId`, `id` or `emails.value` |
193204
194205 Whoever signs in through your provider is matched to the person it
195−assigned by their id there, their userName, or their address. **Make a new
206+assigned by a stable id it gave (the SAML NameID, OpenID's `sub`, and for
207+Microsoft Entra ID the object id and a member's user principal name) as
208+their `userName` or `externalId`, or by an address your provider vouches
209+for as their `userName` or primary address. If more than one assigned
210+person matches, nobody is chosen and they are told so. **Make a new
196211 token** replaces the old one at once; **Turn off SCIM** forgets who was
197212 assigned and removes nobody.
198213
+0−0

Binary or large file; its contents are not shown.

+65−17
1−import { Link, data, redirect } from "react-router";
1+import { Form, Link, data, redirect } from "react-router";
22
3−import { ssoAddresses } from "@g1t/contracts";
3+import { type User, ssoAddresses } from "@g1t/contracts";
44
55 import type { Route } from "./+types/auth-sso";
66 import { AuthCard } from "../components/auth-card";
7+import { SubmitButton } from "../components/ui";
78 import { cookie } from "../lib/github";
89 import { page } from "../lib/meta";
10+import { safeNext } from "../lib/next";
911 import { SSO_STATE_COOKIE } from "../lib/sso";
1012 import { sso } from "../lib/sso.server";
11−import { nextPath, requireUser, sessionTokenOf } from "../lib/session.server";
13+import { assertSameOrigin, nextPath, requireUser, sessionTokenOf } from "../lib/session.server";
1214
1315 export function meta({ params, ...args }: Route.MetaArgs) {
1416 return page(args, { title: `Single sign-on · ${params.owner} · g1t` });
1517 }
1618
19+/** Whether `viewer` belongs to `slug`, held out of it or not. */
20+function belongs(viewer: User, slug: string): boolean {
21+ return (viewer.workspaces ?? []).some((m) => m.slug === slug) || (viewer.held ?? []).some((hold) => hold.slug === slug);
22+}
23+
1724 /**
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.
25+ * Sets off to the workspace's identity provider: the state the provider
26+ * sends back is kept in a short-lived cookie (OpenID Connect and OAuth 2.0);
27+ * a SAML request's id binds its answer instead.
2228 */
23−export async function loader({ request, params, context }: Route.LoaderArgs) {
24− const user = requireUser(context, request);
25− const slug = params.owner.toLowerCase();
29+async function start(request: Request, user: User, slug: string, next: string) {
2630 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− }
31+ if (!token) return { slug, error: "Single sign-on is for people signed in on this site, not for access tokens." };
3032 const summary = await sso.summary(slug);
31− if (!summary.enabled) return data({ slug, error: `${slug} has not set up single sign-on.` }, { status: 404 });
33+ if (!summary.enabled) throw data({ slug, error: `${slug} has not set up single sign-on.` }, { status: 404 });
3234 const addresses = ssoAddresses(new URL(request.url).origin, slug);
3335 const started = await sso.start({
3436 user,
3638 slug,
3739 redirect_uri: summary.protocol === "saml" ? addresses.acsUrl : addresses.callbackUrl,
3840 entity_id: addresses.entityId,
39− next: nextPath(request),
41+ next,
4042 });
4143 if (!started.ok) return { slug, error: started.error.message };
4244 const headers = started.value.state ? { "set-cookie": cookie(SSO_STATE_COOKIE, started.value.state, 600) } : undefined;
4345 throw redirect(started.value.url, { headers });
4446 }
4547
46−export default function SsoStart({ loaderData }: Route.ComponentProps) {
47− return <SsoProblem slug={loaderData.slug} error={loaderData.error} />;
48+/**
49+ * A member (held out or not) sets off at once. Anyone else is asked first,
50+ * on this page, and sets off only by saying so: a link from another site
51+ * must never make someone a member of a workspace without their knowing.
52+ */
53+export async function loader({ request, params, context }: Route.LoaderArgs) {
54+ const user = requireUser(context, request);
55+ const slug = params.owner.toLowerCase();
56+ if (belongs(user, slug)) return start(request, user, slug, nextPath(request));
57+ const summary = await sso.summary(slug);
58+ if (!summary.enabled) return data({ slug, error: `${slug} has not set up single sign-on.` }, { status: 404 });
59+ return { slug, confirm: true as const, username: user.display_username ?? user.username, next: nextPath(request) };
60+}
61+
62+export async function action({ request, params, context }: Route.ActionArgs) {
63+ assertSameOrigin(request);
64+ const user = requireUser(context, request);
65+ const form = await request.formData();
66+ return start(request, user, params.owner.toLowerCase(), safeNext(String(form.get("next") ?? "")));
67+}
68+
69+export default function SsoStart({ loaderData, actionData }: Route.ComponentProps) {
70+ const shown = actionData ?? loaderData;
71+ if ("error" in shown && shown.error) return <SsoProblem slug={shown.slug} error={shown.error} />;
72+ if (!("confirm" in loaderData)) return null;
73+ return (
74+ <AuthCard
75+ title={`Join ${loaderData.slug}`}
76+ subtitle="Through its identity provider"
77+ footer={
78+ <Link to="/" className="text-fg underline underline-offset-4">
79+ Not now
80+ </Link>
81+ }
82+ >
83+ <p className="text-sm text-muted">
84+ You are signed in as <span className="font-medium text-fg">{loaderData.username}</span>. Signing in through{" "}
85+ {loaderData.slug}&apos;s identity provider links your account there to this one and, if {loaderData.slug} lets its
86+ provider&apos;s people join, makes you a member.
87+ </p>
88+ <Form method="post" className="mt-6">
89+ <input type="hidden" name="next" value={loaderData.next} />
90+ <SubmitButton className="w-full" pending="Setting off…">
91+ Continue to {loaderData.slug}&apos;s sign-in
92+ </SubmitButton>
93+ </Form>
94+ </AuthCard>
95+ );
4896 }
4997
5098 /** What a single sign-on that did not work says, with the ways on. */
+12−2
7878 email_claim: text("email_claim"),
7979 idp_entity_id: text("idp_entity_id"),
8080 idp_sso_url: text("idp_sso_url"),
81+ join_domains: String(form.get("join_domains") ?? "")
82+ .split(/[\s,]+/)
83+ .map((domain) => domain.trim())
84+ .filter(Boolean),
8185 };
8286 const saved = await sso.save(user, slug, {
8387 protocol,
424428 </>
425429 )}
426430
427− <SwitchCard name="allow_join" title="Let people from your provider join" defaultChecked={connection?.allow_join ?? true}>
431+ <SwitchCard name="allow_join" title="Let people from your provider join" defaultChecked={connection?.allow_join ?? false}>
428432 Anyone your provider signs in becomes a member of the workspace, with no invitation. Off: only members can sign in
429− through it.
433+ through it. Leave it off for a provider anyone can sign in to, such as a personal Google or GitHub account.
430434 </SwitchCard>
435+ <Field
436+ label="Only addresses at"
437+ hint="Who may join, when joining is on: people whose address your provider vouches for is at one of these domains, such as acme.com. Empty: anyone your provider signs in."
438+ >
439+ <Input name="join_domains" defaultValue={(connection?.config.join_domains ?? []).join(", ")} placeholder="acme.com" />
440+ </Field>
431441 </fieldset>
432442 <div className="flex flex-wrap items-center gap-3">
433443 <SubmitButton match={{ intent: "save" }} pending="Checking…" disabled={disabled}>
+8−5
108108 /// certificate is rotated.
109109 #[serde(default, skip_serializing_if = "Vec::is_empty")]
110110 pub certificates: Vec<String>,
111+ /// Who may join through it, when it lets people join: only those whose
112+ /// address the provider vouches for is at one of these domains. Empty:
113+ /// anyone it signs in.
114+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
115+ pub join_domains: Vec<String>,
111116 }
112117
113118 /// A SAML signing certificate, as shown.
257262 /// SAML: certificates as pasted (PEM).
258263 #[serde(default)]
259264 pub certificate: Option<String>,
260− #[serde(default = "yes")]
265+ /// Off unless asked for: a provider anyone can sign in to (a consumer
266+ /// one) would let anyone in.
267+ #[serde(default)]
261268 pub allow_join: bool,
262269 #[serde(default)]
263270 pub surface: Option<Surface>,
264−}
265−
266−fn yes() -> bool {
267− true
268271 }
269272
270273 /// `set_sso_required`: whether members must sign in through the provider.
+1−0
64176417 Field { name: "idp_entity_id", kind: "string", required: false, values: &[] },
64186418 Field { name: "idp_sso_url", kind: "string", required: false, values: &[] },
64196419 Field { name: "issuer", kind: "string", required: false, values: &[] },
6420+ Field { name: "join_domains", kind: "array", required: false, values: &[] },
64206421 Field { name: "protocol", kind: "string", required: true, values: &["oidc", "oauth2", "saml"] },
64216422 Field { name: "scopes", kind: "string", required: false, values: &[] },
64226423 Field { name: "subject_claim", kind: "string", required: false, values: &[] },
+2−0
2424 idp_entity_id?: string | null;
2525 idp_sso_url?: string | null;
2626 certificates?: string[];
27+ /** Who may join through it: only addresses the provider vouches for at these domains. Empty: anyone. */
28+ join_domains?: string[];
2729 };
2830
2931 export type SsoConnection = {
+1−0
2525 roxmltree = "0.20"
2626 miniz_oxide = "0.9"
2727 rsa = { version = "0.9", default-features = false, features = ["std", "sha2"] }
28+p256 = { version = "0.13", default-features = false, features = ["ecdsa"] }
2829
2930 # wasm-opt at -O1: about the same gzipped size as -O in a tenth of the
3031 # time.
+8−8
149149 /// Attaches the workspaces a user belongs to, so that any service can
150150 /// authorize them without asking again.
151151 async fn with_workspaces(&self, user: Viewer) -> Result<Viewer> {
152− self.with_workspaces_via(user, None).await
152+ self.with_workspaces_via(user, sso::Via::Credential).await
153153 }
154154
155− /// The same, for a user resolved from the session whose hash is
156− /// `session`: a workspace that requires single sign-on asks it of the
157− /// session (sso.rs), and of anything else that the person has linked.
158− async fn with_workspaces_via(&self, user: Viewer, session: Option<&str>) -> Result<Viewer> {
155+ /// The same, saying how the person came in: a workspace that requires
156+ /// single sign-on asks it of a session, a link of a token or key, and
157+ /// never lets a password through (sso.rs).
158+ async fn with_workspaces_via(&self, user: Viewer, via: sso::Via<'_>) -> Result<Viewer> {
159159 let Some(mut user) = user else {
160160 return Ok(None);
161161 };
176176 user.grants = within.grants;
177177 user.held = within.held;
178178 if !sso_required.is_empty() {
179− let held = self.sso_holds(&user, sso_required, session).await?;
179+ let held = self.sso_holds(&user, sso_required, via).await?;
180180 sso::apply_sso_holds(&mut user, &held);
181181 }
182182 Ok(Some(user))
401401 {
402402 return Ok(None);
403403 }
404− self.with_workspaces(user).await
404+ self.with_workspaces_via(user, sso::Via::Password).await
405405 }
406406
407407 async fn register(&self, a: RegisterArgs) -> Result<Outcome<SignedIn>> {
563563 &session,
564564 )
565565 .await?;
566− self.with_workspaces_via(user, Some(&session)).await
566+ self.with_workspaces_via(user, sso::Via::Session(&session)).await
567567 }
568568
569569 async fn user_for_git_credentials(&self, a: GitCredentialsArgs) -> Result<Viewer> {
+220−59
3333 mod saml;
3434 #[path = "sso/scim.rs"]
3535 mod scim;
36+#[path = "sso/jwt.rs"]
37+mod jwt;
3638 #[path = "sso/provisioning.rs"]
3739 mod provisioning;
3840
135137 next: String,
136138 }
137139
140+/// Who the provider says signed in.
141+pub(crate) struct Said {
142+ /// Its stable id for them: OpenID's `sub`, the userinfo subject claim,
143+ /// or SAML's NameID. An address-shaped one is lowercased, as providers
144+ /// change its case.
145+ pub subject: String,
146+ /// Every stable id it gave, the subject first: also Entra's `oid` and,
147+ /// for an Entra tenant's own members, their user principal name.
148+ pub ids: Vec<String>,
149+ /// The address it gave, for showing.
150+ pub email: Option<String>,
151+ /// Whether that address can be trusted to be theirs: OpenID's
152+ /// `email_verified`, or a SAML assertion the provider signed. Only a
153+ /// trusted address matches anyone or lets anyone join by domain.
154+ pub email_verified: bool,
155+}
156+
157+impl Said {
158+ fn new(subject: String, email: Option<String>, email_verified: bool) -> Said {
159+ let subject = if subject.contains('@') { subject.trim().to_lowercase() } else { subject.trim().to_owned() };
160+ Said { ids: vec![subject.clone()], subject, email: email.map(|e| e.trim().to_lowercase()).filter(|e| e.contains('@')), email_verified }
161+ }
162+
163+ fn trusted_email(&self) -> Option<&str> {
164+ self.email.as_deref().filter(|_| self.email_verified)
165+ }
166+}
167+
168+/// What an OpenID Connect ID token says: Entra's `oid` and, for its tenant's
169+/// own members (never a guest, `#EXT#`), the user principal name are stable
170+/// ids SCIM may know them by.
171+fn said_by_oidc(claims: &Value, issuer: &str) -> Option<Said> {
172+ let subject = oauth::claim(claims, "sub")?;
173+ let mut said = Said::new(subject, oauth::claim(claims, "email"), claims["email_verified"].as_bool() == Some(true));
174+ if issuer.starts_with("https://login.microsoftonline.com/") {
175+ said.ids.extend(oauth::claim(claims, "oid"));
176+ said.ids.extend(oauth::claim(claims, "preferred_username").filter(|upn| !upn.contains("#EXT#")).map(|upn| upn.to_lowercase()));
177+ }
178+ Some(said)
179+}
180+
181+/// Why `said` may not join by its address, when the connection names the
182+/// domains that may: an address the provider vouches for, at one of them.
183+fn join_domain_refusal(domains: &[String], said: &Said, slug: &str) -> Option<String> {
184+ if domains.is_empty() {
185+ return None;
186+ }
187+ let at = said.trusted_email().and_then(|email| email.rsplit_once('@')).map(|(_, domain)| domain.to_owned());
188+ match at {
189+ Some(domain) if domains.iter().any(|d| d.eq_ignore_ascii_case(&domain)) => None,
190+ _ => Some(format!(
191+ "Only people with an address at {} join {slug} through its identity provider. Ask an owner to invite you.",
192+ domains.join(" or ")
193+ )),
194+ }
195+}
196+
197+/// The domains a connection lets join, as an owner entered them.
198+fn domains_from(given: &[String]) -> std::result::Result<Vec<String>, String> {
199+ let mut out: Vec<String> = Vec::new();
200+ for domain in given.iter().flat_map(|d| d.split([',', ' ', '\n'])) {
201+ let domain = domain.trim().trim_start_matches('@').to_lowercase();
202+ if domain.is_empty() {
203+ continue;
204+ }
205+ if !domain.contains('.') || domain.len() > 253 || !domain.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') {
206+ return Err(format!("{domain} is not a domain, such as acme.com."));
207+ }
208+ if !out.contains(&domain) {
209+ out.push(domain);
210+ }
211+ }
212+ out.truncate(20);
213+ Ok(out)
214+}
215+
138216 /// Which provider a connection is: changing it unlinks everyone.
139217 fn provider_of(protocol: SsoProtocol, config: &SsoConfig) -> String {
140218 let at = match protocol {
165243 /// own fields are kept.
166244 fn checked_config(protocol: SsoProtocol, given: &SsoConfig, certificates: Vec<String>) -> std::result::Result<SsoConfig, String> {
167245 let client_id = || field(&given.client_id, "client id")?.ok_or_else(|| "Enter the client id.".to_owned());
168− Ok(match protocol {
246+ let join_domains = domains_from(&given.join_domains)?;
247+ let config = match protocol {
169248 SsoProtocol::Oidc => SsoConfig {
170249 issuer: Some(https_field(&given.issuer, "issuer")?),
171250 client_id: Some(client_id()?),
193272 ..SsoConfig::default()
194273 }
195274 }
196− })
275+ };
276+ Ok(SsoConfig { join_domains, ..config })
197277 }
198278
279+/// How someone came in, for what single sign-on asks of them.
280+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
281+pub enum Via<'a> {
282+ /// A session on the site: its hash.
283+ Session(&'a str),
284+ /// An access token, an SSH key, a deploy key.
285+ Credential,
286+ /// Their g1t password (git over HTTPS without a token).
287+ Password,
288+}
289+
199290 /// Which of a person's memberships a workspace's single sign-on holds out:
200291 /// those that require it and they have not met, never as an owner.
201292 pub fn held_by_sso(required: &[(String, Role)], met: &HashSet<String>) -> Vec<String> {
549640 return Ok(Outcome::Ok(false));
550641 };
551642 if let Some(user_id) = removed["user_id"].as_str() {
552− // Their sessions go through the provider again.
643+ // Their sessions go through the provider again, and their SCIM
644+ // assignment links to whoever signs in as it next.
553645 self.db
554− .prepare("DELETE FROM sso_sessions WHERE workspace_id = ? AND session_id IN (SELECT id FROM sessions WHERE user_id = ?)")
555− .bind(&[workspace_id.as_str().into(), user_id.into()])?
556− .run()
646+ .batch(vec![
647+ self.db
648+ .prepare("DELETE FROM sso_sessions WHERE workspace_id = ? AND session_id IN (SELECT id FROM sessions WHERE user_id = ?)")
649+ .bind(&[workspace_id.as_str().into(), user_id.into()])?,
650+ self.db
651+ .prepare("UPDATE scim_users SET user_id = NULL WHERE workspace_id = ? AND user_id = ?")
652+ .bind(&[workspace_id.as_str().into(), user_id.into()])?,
653+ ])
557654 .await?;
558655 }
559656 self.audit_workspace(
679776 token: config.token_url.clone().unwrap_or_default(),
680777 userinfo: config.userinfo_url.clone(),
681778 issuer: None,
779+ jwks: None,
682780 basic: false,
683781 }),
684782 }
735833 .await?;
736834 if protocol == SsoProtocol::Oidc {
737835 let id_token = tokens["id_token"].as_str().ok_or("The identity provider sent no ID token.")?;
836+ let keys = oauth::keys(endpoints.jwks.as_deref().unwrap_or_default()).await?;
738837 let claims = oauth::id_token_claims(
739838 id_token,
839+ &keys,
740840 endpoints.issuer.as_deref().unwrap_or_default(),
741841 client_id,
742842 state.nonce.as_deref().unwrap_or_default(),
743843 now_ms() / 1000,
744844 )?;
745− let subject = oauth::claim(&claims, "sub").ok_or("The ID token names no one.")?;
746− let email = oauth::claim(&claims, "email").filter(|_| claims["email_verified"].as_bool() != Some(false));
747− Ok::<_, String>((subject, email))
845+ let said = said_by_oidc(&claims, endpoints.issuer.as_deref().unwrap_or_default()).ok_or("The ID token names no one.")?;
846+ Ok::<_, String>(said)
748847 } else {
749848 let access = tokens["access_token"].as_str().ok_or("The identity provider sent no access token.")?;
750849 let info = oauth::userinfo(endpoints.userinfo.as_deref().unwrap_or_default(), access).await?;
753852 None => oauth::claim(&info, "sub").or_else(|| oauth::claim(&info, "id")),
754853 }
755854 .ok_or("The userinfo answer has no id for the person: set the subject claim.")?;
855+ // Plain OAuth 2.0 says nothing of whether an address is theirs.
756856 let email = oauth::claim(&info, config.email_claim.as_deref().unwrap_or("email"));
757− Ok((subject, email))
857+ Ok(Said::new(subject, email, false))
758858 }
759859 };
760860 match said.await {
761− Ok((subject, email)) => self.complete(&row, &state, &a.slug, &subject, email.as_deref()).await,
861+ Ok(said) => self.complete(&row, &state, &a.slug, &said).await,
762862 Err(why) => Ok(Outcome::fail(FailureCode::Invalid, why)),
763863 }
764864 }
785885 now_ms: now_ms(),
786886 };
787887 match saml::verify_response(&a.saml_response, &expect) {
788− Ok(asserted) => self.complete(&row, &state, &a.slug, &asserted.subject, asserted.email.as_deref()).await,
888+ // The provider signed the assertion, address and all.
889+ Ok(asserted) => self.complete(&row, &state, &a.slug, &Said::new(asserted.subject, asserted.email, true)).await,
789890 Err(why) => Ok(Outcome::fail(FailureCode::Invalid, why)),
790891 }
791892 }
792893
793894 /// The provider has said who signed in: link them, let them in, and
794− /// mark their session.
795− async fn complete(&self, row: &ConnectionRow, state: &StateRow, slug: &str, subject: &str, email: Option<&str>) -> Result<Outcome<SsoFinished>> {
895+ /// mark their session. Everything that can refuse is decided before
896+ /// anything is written, so a refused sign-in leaves no link behind.
897+ async fn complete(&self, row: &ConnectionRow, state: &StateRow, slug: &str, said: &Said) -> Result<Outcome<SsoFinished>> {
796898 let slug = slug.trim().to_lowercase();
797899 let user_id = state.user_id.as_str();
900+ let subject = said.subject.as_str();
798901 let Some(person) = self.sso_person(user_id).await? else {
799902 return Ok(Outcome::fail(FailureCode::Unauthenticated, "Sign in to g1t again."));
800903 };
810913 .all()
811914 .await?
812915 .results::<Linked>()?;
813− let now = rfc3339(now_ms());
814− let email_value = email.map_or(JsValue::NULL, Into::into);
815− // With SCIM on, only someone the provider has assigned, and not
816− // as another g1t account (provisioning.rs).
916+ if linked.iter().any(|l| l.subject == subject && l.user_id != user_id) {
917+ return Ok(Outcome::fail(
918+ FailureCode::Conflict,
919+ format!("That account at {slug}'s identity provider is linked to another g1t account. An owner of {slug} can unlink it."),
920+ ));
921+ }
922+ if linked.iter().any(|l| l.user_id == user_id && l.subject != subject) {
923+ return Ok(Outcome::fail(
924+ FailureCode::Conflict,
925+ format!("Your g1t account is linked to another account at {slug}'s identity provider. Sign in with that one, or ask an owner of {slug} to unlink it."),
926+ ));
927+ }
928+ // With SCIM on, only someone the provider has assigned, matched
929+ // without doubt, and not as another g1t account (provisioning.rs).
817930 let provisioned = match self.scim_on(&row.workspace_id).await? {
818931 false => None,
819− true => match self.provisioned_as(&row.workspace_id, subject, email).await? {
820− Some((_, Some(other))) if other != user_id => {
932+ true => match self.provisioned_as(&row.workspace_id, &said.ids, said.trusted_email()).await? {
933+ provisioning::Match::One(_, Some(other)) if other != user_id => {
821934 return Ok(Outcome::fail(
822935 FailureCode::Conflict,
823936 format!("That account at {slug}'s identity provider is linked to another g1t account. An owner of {slug} can unlink it."),
824937 ));
825938 }
826− Some((id, _)) => Some(id),
827− None => {
939+ provisioning::Match::One(id, _) => Some(id),
940+ provisioning::Match::Many => {
828941 return Ok(Outcome::fail(
942+ FailureCode::Conflict,
943+ format!("More than one person assigned to g1t in {slug}'s identity provider matches your account there. Ask whoever runs it to fix the assignments."),
944+ ));
945+ }
946+ provisioning::Match::None => {
947+ return Ok(Outcome::fail(
829948 FailureCode::Forbidden,
830949 format!("Your account at {slug}'s identity provider is not assigned to g1t. Ask whoever runs it for {slug} to assign you."),
831950 ));
832951 }
833952 },
834953 };
835− if linked.iter().any(|l| l.subject == subject && l.user_id != user_id) {
836− return Ok(Outcome::fail(
837− FailureCode::Conflict,
838− format!("That account at {slug}'s identity provider is linked to another g1t account. An owner of {slug} can unlink it."),
839− ));
840− }
841− if linked.iter().any(|l| l.user_id == user_id && l.subject != subject) {
842− return Ok(Outcome::fail(
843− FailureCode::Conflict,
844− format!("Your g1t account is linked to another account at {slug}'s identity provider. Sign in with that one, or ask an owner of {slug} to unlink it."),
845− ));
954+ // Not a member yet: the provider vouches for them, if the workspace
955+ // lets it and they meet its rules (security.rs), and they have room.
956+ let member = self.is_member_of(&row.workspace_id, user_id).await?;
957+ if !member {
958+ if row.allow_join == 0 {
959+ return Ok(Outcome::fail(FailureCode::Forbidden, format!("Only members of {slug} sign in through its identity provider. Ask an owner to invite you.")));
960+ }
961+ if let Some(why) = join_domain_refusal(&row.config().join_domains, said, &slug) {
962+ return Ok(Outcome::fail(FailureCode::Forbidden, why));
963+ }
964+ if let Some(why) = self.policy_refusal(user_id, &slug).await? {
965+ return Ok(Outcome::fail(FailureCode::Forbidden, why));
966+ }
967+ if let Some(refused) = self.plan_refusal(&slug, plan_needed(&slug)).await? {
968+ return Ok(refused);
969+ }
970+ if self.memberships(user_id).await?.len() >= crate::workspaces::MAX_WORKSPACES_PER_USER {
971+ return Ok(Outcome::fail(FailureCode::Conflict, "You belong to the maximum number of workspaces."));
972+ }
846973 }
974+ // Refusals done: write.
975+ let now = rfc3339(now_ms());
976+ let email_value = said.email.as_deref().map_or(JsValue::NULL, Into::into);
847977 if linked.is_empty() {
848978 self.db
849979 .prepare(
864994 }
865995 if let Some(id) = &provisioned {
866996 self.db
867− .prepare("UPDATE scim_users SET user_id = ? WHERE id = ?")
868− .bind(&[user_id.into(), id.as_str().into()])?
997+ .prepare("UPDATE scim_users SET user_id = ? WHERE id = ? AND workspace_id = ?")
998+ .bind(&[user_id.into(), id.as_str().into(), row.workspace_id.as_str().into()])?
869999 .run()
8701000 .await?;
8711001 }
872− // Not a member yet: the provider vouches for them, if the workspace
873− // lets it, within its policy (security.rs).
8741002 let mut joined = false;
875− if !self.is_member_of(&row.workspace_id, user_id).await? {
876− if row.allow_join == 0 {
877− return Ok(Outcome::fail(FailureCode::Forbidden, format!("Only members of {slug} sign in through its identity provider. Ask an owner to invite you.")));
878− }
879− if let Some(why) = self.policy_refusal(user_id, &slug).await? {
880− return Ok(Outcome::fail(FailureCode::Forbidden, why));
881− }
882− if let Some(refused) = self.plan_refusal(&slug, plan_needed(&slug)).await? {
883− return Ok(refused);
884− }
1003+ if !member {
8851004 joined = self
8861005 .db
8871006 .prepare(
9361055 }
9371056
9381057 /// The workspaces of `required` (slugs, with the person's role) whose
939− /// single sign-on holds the person out: met by a session marked for it
940− /// (`session`, its hash) or, for a token or key, by having linked.
941− pub async fn sso_holds(&self, user: &User, required: Vec<(String, Role)>, session: Option<&str>) -> Result<Vec<String>> {
1058+ /// single sign-on holds the person out, for how they came in (`via`):
1059+ /// a session counts once it went through the provider in the last day,
1060+ /// a token or key once they have linked, and a password never (it is
1061+ /// what single sign-on stands in for). Where SCIM is on, they must also
1062+ /// still be assigned.
1063+ pub async fn sso_holds(&self, user: &User, required: Vec<(String, Role)>, via: Via<'_>) -> Result<Vec<String>> {
9421064 // g1t's own agent is never held: it is no one's to sign in.
9431065 if required.iter().all(|(_, role)| *role == Role::Owner) || crate::paid::is_g1t(&user.username) {
9441066 return Ok(Vec::new());
9471069 struct Slug {
9481070 slug: String,
9491071 }
950− let met = match session {
951− Some(session) => {
1072+ // Where SCIM is on, only while the provider still has them assigned.
1073+ const ASSIGNED: &str = "(NOT EXISTS (SELECT 1 FROM scim_tokens t WHERE t.workspace_id = w.id)
1074+ OR EXISTS (SELECT 1 FROM scim_users u WHERE u.workspace_id = w.id AND u.user_id = ?2 AND u.active = 1))";
1075+ let met = match via {
1076+ Via::Password => return Ok(held_by_sso(&required, &HashSet::new())),
1077+ Via::Session(session) => {
9521078 self.db
9531079 .prepare(format!(
9541080 "SELECT w.slug FROM sso_sessions s JOIN workspaces w ON w.id = s.workspace_id
955− WHERE s.session_id = ? AND s.expires_at > {SQL_NOW}"
1081+ WHERE s.session_id = ?1 AND s.expires_at > {SQL_NOW} AND {ASSIGNED}"
9561082 ))
957− .bind(&[session.into()])?
1083+ .bind(&[session.into(), user.id.as_str().into()])?
9581084 .all()
9591085 .await?
9601086 }
961− None => {
1087+ Via::Credential => {
9621088 self.db
963− .prepare(
1089+ .prepare(format!(
9641090 "SELECT w.slug FROM sso_identities i JOIN sso_connections c ON c.id = i.connection_id
965− JOIN workspaces w ON w.id = c.workspace_id WHERE i.user_id = ?",
966− )
967− .bind(&[user.id.as_str().into()])?
1091+ JOIN workspaces w ON w.id = c.workspace_id WHERE i.user_id = ?1 AND {ASSIGNED}"
1092+ ))
1093+ .bind(&[user.id.as_str().into(), user.id.as_str().into()])?
9681094 .all()
9691095 .await?
9701096 }
10461172 }
10471173
10481174 #[test]
1175+ fn only_a_trusted_address_at_a_named_domain_joins() {
1176+ let domains = vec!["acme.com".to_owned()];
1177+ let trusted = Said::new("u1".into(), Some("Ada@Acme.com".into()), true);
1178+ assert_eq!(join_domain_refusal(&domains, &trusted, "acme"), None);
1179+ let untrusted = Said::new("u1".into(), Some("ada@acme.com".into()), false);
1180+ assert!(join_domain_refusal(&domains, &untrusted, "acme").is_some());
1181+ let elsewhere = Said::new("u1".into(), Some("ada@gmail.com".into()), true);
1182+ assert!(join_domain_refusal(&domains, &elsewhere, "acme").unwrap().contains("acme.com"));
1183+ assert_eq!(join_domain_refusal(&[], &elsewhere, "acme"), None);
1184+ assert_eq!(domains_from(&["@Acme.com, globex.io".to_owned()]).unwrap(), vec!["acme.com".to_owned(), "globex.io".to_owned()]);
1185+ assert!(domains_from(&["not a domain!".to_owned()]).is_err());
1186+ }
1187+
1188+ #[test]
1189+ fn an_address_shaped_subject_is_lowercased() {
1190+ assert_eq!(Said::new(" Ada@Acme.COM ".into(), None, true).subject, "ada@acme.com");
1191+ assert_eq!(Said::new("00uAbC".into(), None, true).subject, "00uAbC");
1192+ }
1193+
1194+ #[test]
1195+ fn entra_gives_its_object_id_and_a_members_principal_name() {
1196+ let claims = serde_json::json!({ "sub": "pairwise", "oid": "11111111-2222", "preferred_username": "Ada@Contoso.com", "email": "ada@contoso.com" });
1197+ let said = said_by_oidc(&claims, "https://login.microsoftonline.com/tid/v2.0").unwrap();
1198+ assert_eq!(said.ids, vec!["pairwise".to_owned(), "11111111-2222".to_owned(), "ada@contoso.com".to_owned()]);
1199+ // Entra sends no email_verified: its address is never trusted.
1200+ assert_eq!(said.trusted_email(), None);
1201+ let guest = serde_json::json!({ "sub": "p", "preferred_username": "eve_gmail.com#EXT#@contoso.onmicrosoft.com" });
1202+ assert_eq!(said_by_oidc(&guest, "https://login.microsoftonline.com/tid/v2.0").unwrap().ids, vec!["p".to_owned()]);
1203+ let okta = serde_json::json!({ "sub": "00u1", "oid": "x", "email": "ada@acme.com", "email_verified": true });
1204+ let said = said_by_oidc(&okta, "https://acme.okta.com").unwrap();
1205+ assert_eq!(said.ids, vec!["00u1".to_owned()]);
1206+ assert_eq!(said.trusted_email(), Some("ada@acme.com"));
1207+ }
1208+
1209+ #[test]
10491210 fn another_provider_is_told_apart_from_a_changed_setting() {
10501211 let a = SsoConfig { issuer: Some("https://acme.okta.com/".into()), client_id: Some("one".into()), ..SsoConfig::default() };
10511212 let b = SsoConfig { issuer: Some("https://acme.okta.com".into()), client_id: Some("two".into()), ..SsoConfig::default() };
+212−0
1+//! Verifying an OpenID Connect ID token's signature with the issuer's
2+//! published keys (its JWKS, from `jwks_uri`): RSA (RS256/384/512 and
3+//! PS256/384/512) and P-256 (ES256). Anything else, `none` and the HMAC
4+//! algorithms above all, is refused: g1t never holds a key that would make
5+//! a shared secret mean anything here.
6+//!
7+//! The key is the one the token's header names by `kid`, or the only
8+//! signing key of the right type when it names none. A key marked for
9+//! encryption, or for another algorithm, is never used.
10+
11+use base64::Engine;
12+use base64::engine::general_purpose::URL_SAFE_NO_PAD;
13+use rsa::pss::Pss;
14+use rsa::{BigUint, Pkcs1v15Sign, RsaPublicKey};
15+use serde_json::Value;
16+use sha2::{Digest, Sha256, Sha384, Sha512};
17+
18+const UNREADABLE: &str = "The identity provider's ID token could not be read.";
19+const BAD_SIGNATURE: &str = "The ID token's signature did not verify with the identity provider's published keys.";
20+
21+fn decode(part: &str) -> Result<Vec<u8>, String> {
22+ URL_SAFE_NO_PAD.decode(part.trim_end_matches('=')).map_err(|_| UNREADABLE.to_owned())
23+}
24+
25+/// The algorithms g1t accepts, and what each needs.
26+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27+enum Alg {
28+ Rs(Bits),
29+ Ps(Bits),
30+ Es256,
31+}
32+
33+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34+enum Bits {
35+ B256,
36+ B384,
37+ B512,
38+}
39+
40+impl Bits {
41+ fn digest(self, data: &[u8]) -> Vec<u8> {
42+ match self {
43+ Bits::B256 => Sha256::digest(data).to_vec(),
44+ Bits::B384 => Sha384::digest(data).to_vec(),
45+ Bits::B512 => Sha512::digest(data).to_vec(),
46+ }
47+ }
48+}
49+
50+impl Alg {
51+ fn parse(name: &str) -> Result<Alg, String> {
52+ Ok(match name {
53+ "RS256" => Alg::Rs(Bits::B256),
54+ "RS384" => Alg::Rs(Bits::B384),
55+ "RS512" => Alg::Rs(Bits::B512),
56+ "PS256" => Alg::Ps(Bits::B256),
57+ "PS384" => Alg::Ps(Bits::B384),
58+ "PS512" => Alg::Ps(Bits::B512),
59+ "ES256" => Alg::Es256,
60+ other => {
61+ return Err(format!(
62+ "The identity provider signs ID tokens with {other:?}, which g1t does not accept. Use RS256 (or PS256 or ES256)."
63+ ));
64+ }
65+ })
66+ }
67+
68+ fn kty(self) -> &'static str {
69+ match self {
70+ Alg::Rs(_) | Alg::Ps(_) => "RSA",
71+ Alg::Es256 => "EC",
72+ }
73+ }
74+}
75+
76+/// The key `kid` names, or the only one that fits.
77+fn key_for<'a>(jwks: &'a Value, kid: Option<&str>, alg: Alg, name: &str) -> Result<&'a Value, String> {
78+ let usable: Vec<&Value> = jwks["keys"]
79+ .as_array()
80+ .into_iter()
81+ .flatten()
82+ .filter(|key| key["kty"].as_str() == Some(alg.kty()))
83+ .filter(|key| key["use"].as_str().is_none_or(|u| u == "sig"))
84+ .filter(|key| key["alg"].as_str().is_none_or(|a| a == name))
85+ .collect();
86+ let found = match kid {
87+ Some(kid) => usable.into_iter().find(|key| key["kid"].as_str() == Some(kid)),
88+ None if usable.len() == 1 => usable.into_iter().next(),
89+ None => None,
90+ };
91+ found.ok_or_else(|| "The identity provider's published keys have none for this ID token. If it just rotated its keys, try again.".to_owned())
92+}
93+
94+/// Verifies `token`'s signature with a key of `jwks` and returns its claims.
95+/// Only the signature: what the claims must say is checked after.
96+pub fn verify(token: &str, jwks: &Value) -> Result<Value, String> {
97+ let mut parts = token.trim().split('.');
98+ let (Some(header), Some(payload), Some(signature), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else {
99+ return Err(UNREADABLE.to_owned());
100+ };
101+ let head: Value = serde_json::from_slice(&decode(header)?).map_err(|_| UNREADABLE.to_owned())?;
102+ let name = head["alg"].as_str().unwrap_or("none");
103+ let alg = Alg::parse(name)?;
104+ if head.get("crit").is_some() || head.get("jku").is_some() || head.get("x5u").is_some() || head.get("jwk").is_some() {
105+ // Keys named in the token itself are never trusted.
106+ return Err("The ID token names its own key, which g1t does not accept.".to_owned());
107+ }
108+ let key = key_for(jwks, head["kid"].as_str(), alg, name)?;
109+ let signed = format!("{header}.{payload}");
110+ let signature = decode(signature)?;
111+ let verified = match alg {
112+ Alg::Rs(bits) | Alg::Ps(bits) => {
113+ let (Some(n), Some(e)) = (key["n"].as_str(), key["e"].as_str()) else {
114+ return Err(BAD_SIGNATURE.to_owned());
115+ };
116+ let public = RsaPublicKey::new(BigUint::from_bytes_be(&decode(n)?), BigUint::from_bytes_be(&decode(e)?)).map_err(|_| BAD_SIGNATURE.to_owned())?;
117+ let hashed = bits.digest(signed.as_bytes());
118+ match (alg, bits) {
119+ (Alg::Rs(_), Bits::B256) => public.verify(Pkcs1v15Sign::new::<Sha256>(), &hashed, &signature),
120+ (Alg::Rs(_), Bits::B384) => public.verify(Pkcs1v15Sign::new::<Sha384>(), &hashed, &signature),
121+ (Alg::Rs(_), Bits::B512) => public.verify(Pkcs1v15Sign::new::<Sha512>(), &hashed, &signature),
122+ (_, Bits::B256) => public.verify(Pss::new::<Sha256>(), &hashed, &signature),
123+ (_, Bits::B384) => public.verify(Pss::new::<Sha384>(), &hashed, &signature),
124+ (_, Bits::B512) => public.verify(Pss::new::<Sha512>(), &hashed, &signature),
125+ }
126+ .is_ok()
127+ }
128+ Alg::Es256 => {
129+ use p256::ecdsa::signature::Verifier;
130+ use p256::ecdsa::{Signature, VerifyingKey};
131+ if key["crv"].as_str() != Some("P-256") {
132+ return Err(BAD_SIGNATURE.to_owned());
133+ }
134+ let (Some(x), Some(y)) = (key["x"].as_str(), key["y"].as_str()) else {
135+ return Err(BAD_SIGNATURE.to_owned());
136+ };
137+ let mut point = vec![0x04];
138+ point.extend(decode(x)?);
139+ point.extend(decode(y)?);
140+ match (VerifyingKey::from_sec1_bytes(&point), Signature::from_slice(&signature)) {
141+ (Ok(public), Ok(signature)) => public.verify(signed.as_bytes(), &signature).is_ok(),
142+ _ => false,
143+ }
144+ }
145+ };
146+ if !verified {
147+ return Err(BAD_SIGNATURE.to_owned());
148+ }
149+ serde_json::from_slice(&decode(payload)?).map_err(|_| UNREADABLE.to_owned())
150+}
151+
152+#[cfg(test)]
153+mod tests {
154+ use super::*;
155+ use serde_json::json;
156+
157+ fn fixtures() -> Value {
158+ serde_json::from_str(include_str!("jwt_fixtures.json")).unwrap()
159+ }
160+
161+ fn token(name: &str) -> String {
162+ fixtures()["tokens"][name].as_str().unwrap().to_owned()
163+ }
164+
165+ #[test]
166+ fn tokens_signed_by_the_issuer_verify() {
167+ let jwks = fixtures()["jwks"].clone();
168+ for name in ["rs256", "ps256", "es256", "rs256_other_key"] {
169+ let claims = verify(&token(name), &jwks).unwrap_or_else(|e| panic!("{name}: {e}"));
170+ assert_eq!(claims["sub"], "u1", "{name}");
171+ }
172+ }
173+
174+ #[test]
175+ fn a_changed_token_does_not() {
176+ let jwks = fixtures()["jwks"].clone();
177+ for name in ["rs256", "ps256", "es256"] {
178+ let token = token(name);
179+ let mut parts: Vec<String> = token.split('.').map(str::to_owned).collect();
180+ let mut claims: Value = serde_json::from_slice(&decode(&parts[1]).unwrap()).unwrap();
181+ claims["sub"] = json!("eve");
182+ parts[1] = URL_SAFE_NO_PAD.encode(claims.to_string());
183+ assert_eq!(verify(&parts.join("."), &jwks).unwrap_err(), BAD_SIGNATURE, "{name}");
184+ }
185+ }
186+
187+ #[test]
188+ fn a_key_the_issuer_did_not_publish_is_not_found() {
189+ let without = fixtures()["jwks_without_rsa2"].clone();
190+ assert!(verify(&token("rs256_other_key"), &without).unwrap_err().contains("none for this ID token"));
191+ }
192+
193+ #[test]
194+ fn unsigned_and_shared_secret_tokens_are_refused() {
195+ let jwks = fixtures()["jwks"].clone();
196+ let unsigned = format!("{}.{}.", URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#), URL_SAFE_NO_PAD.encode(r#"{"sub":"eve"}"#));
197+ assert!(verify(&unsigned, &jwks).unwrap_err().contains("\"none\""));
198+ let hmac = format!("{}.{}.c2ln", URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256","kid":"rsa1"}"#), URL_SAFE_NO_PAD.encode(r#"{"sub":"eve"}"#));
199+ assert!(verify(&hmac, &jwks).unwrap_err().contains("HS256"));
200+ let own_key = format!("{}.{}.c2ln", URL_SAFE_NO_PAD.encode(r#"{"alg":"RS256","jku":"https://evil.test/keys"}"#), URL_SAFE_NO_PAD.encode(r#"{"sub":"eve"}"#));
201+ assert!(verify(&own_key, &jwks).unwrap_err().contains("names its own key"));
202+ }
203+
204+ #[test]
205+ fn an_es256_token_is_not_checked_with_an_rsa_key() {
206+ // The EC token's header renamed to an RSA key: the types differ.
207+ let token = token("es256");
208+ let mut parts: Vec<String> = token.split('.').map(str::to_owned).collect();
209+ parts[0] = URL_SAFE_NO_PAD.encode(r#"{"alg":"ES256","kid":"rsa1"}"#);
210+ assert!(verify(&parts.join("."), &fixtures()["jwks"]).is_err());
211+ }
212+}
+63−0
1+{
2+ "claims": {
3+ "iss": "https://idp.example.test",
4+ "aud": "client",
5+ "exp": 1800000600,
6+ "iat": 1800000000,
7+ "nonce": "n",
8+ "sub": "u1",
9+ "email": "ada@acme.test"
10+ },
11+ "jwks": {
12+ "keys": [
13+ {
14+ "kty": "RSA",
15+ "kid": "rsa1",
16+ "use": "sig",
17+ "n": "wrtE1vPs-4sQR9amgH3_TWrrY9TKdHLgRYKbHuQlIlW5Mcs0UdVHYQeehc45xXY0-8TO-jifS8mAtqVwNF7mbtqmCzhzaKaFi3g-kTGyEkNiVHNovJvfCIrPAjkUixZc2yaP8V5wCePkxwnO2uVQgXrHpH_qUIOd_p9BEqk89w42lsT9P2AT-DayQdXo8N2eCmskTokvLNXTcvjreMJ6ut1ecXoQ_XIGQEg5jXbIxG4S507uKvMWjYm6U1X7YOxhlN407VS4g0KgbCstosUxhAkwHgkJtL287CVv4VLs1PQajK3mShlAVt3EFCe_yZPWFt2yhw-P3lRpktiizFAF4w",
18+ "e": "AQAB"
19+ },
20+ {
21+ "kty": "EC",
22+ "kid": "ec1",
23+ "use": "sig",
24+ "crv": "P-256",
25+ "x": "cE6lw8bOHDe30Hkz8GlJPTo2-xgGuGx39ITmnvcFdaI",
26+ "y": "Uei4ubkFj1rtZhp4oiX6-eJrDM48_-4NHwAjAdb9ze8"
27+ },
28+ {
29+ "kty": "RSA",
30+ "kid": "rsa2",
31+ "use": "sig",
32+ "alg": "RS256",
33+ "n": "r7V__oqhwxFHAEyYICowrmLd96uL6P0wb_VfJFx5QmdLcz6DOC3tkn_kj28z-Y8Rshqd07bETfNzEaqK4-vqeVwxOSeUEAm-_d6Z44aN2xhMeqXwUN5BeQiK85zbw0Fa27ZMJswGKh8mnb5aWXbMjy2O6nF9pkCpU9olmC-uIJNiWnWDC4W6A4O9d4rjsqhVKGhC8KnsJDocffrL8x6AgqtP8MsUO6y_8b-j0GSTAJWNjyiAIUbwuX-YMXM3IhG0yqYX_HbBej4Rhj-bJ8i56NvGZVoMezweoe1rxNLljaIEm-uaOQ_CHM32n9PfsRW8OnCShxqgG0KwYlNlSNN0CQ",
34+ "e": "AQAB"
35+ }
36+ ]
37+ },
38+ "tokens": {
39+ "rs256": "eyJhbGciOiAiUlMyNTYiLCAia2lkIjogInJzYTEiLCAidHlwIjogIkpXVCJ9.eyJpc3MiOiAiaHR0cHM6Ly9pZHAuZXhhbXBsZS50ZXN0IiwgImF1ZCI6ICJjbGllbnQiLCAiZXhwIjogMTgwMDAwMDYwMCwgImlhdCI6IDE4MDAwMDAwMDAsICJub25jZSI6ICJuIiwgInN1YiI6ICJ1MSIsICJlbWFpbCI6ICJhZGFAYWNtZS50ZXN0In0.Q0QVI4lc2qnjm4H3Z564-aycXcJ0DxFfNPYCAzBnD3GqawSdC0YKGeEMwim8qGcicWa1Q0-LCm_8-6awaBJkHK__Ae_amxLH86WazWNEdNl66YUybxo0k7PBsgDhASXtf3vb6vMNLsKQ-AThiE7fmcAr1e0LSwFbMRNjd5Gs_60uujlLUfDHZtRQh3NFLzpYqSIT3ucRS1AnGY2znMQz02k_dTPS6g-kQTRT08rW726p5uiv3l11ONDuWPEzbfIynvivPzNoaoN0K7A7vqWim8EZTjxZD_WfR-p-Zg-aFdteee4s3yLOKen7iQ76LhyfTUtsg6hqwtmrUKqhopsfbg",
40+ "ps256": "eyJhbGciOiAiUFMyNTYiLCAia2lkIjogInJzYTEiLCAidHlwIjogIkpXVCJ9.eyJpc3MiOiAiaHR0cHM6Ly9pZHAuZXhhbXBsZS50ZXN0IiwgImF1ZCI6ICJjbGllbnQiLCAiZXhwIjogMTgwMDAwMDYwMCwgImlhdCI6IDE4MDAwMDAwMDAsICJub25jZSI6ICJuIiwgInN1YiI6ICJ1MSIsICJlbWFpbCI6ICJhZGFAYWNtZS50ZXN0In0.WJSWYVkgCKcUvwaTpDxo-YHrda5lJRO0-vlTWCRlJH6pS-l6VcQ3Ps9GBW53Z_4QfM_WJU7EoLsoZVQDuZgldf5JogN3owWVUl0RgrkTtH_jUO_8ZGEfv8rgljck4ZoAk_6csAnL9CN9sFSrZQcvSKqiWS2z7_SA2div3Btit5G1AlOXN4x6jjbnTwlU4cejQT788KNd8lIxX4kELPiUJLJYZ6GwOaP1KLRUIVuvwvl66oytrhmPgJTOw9qAc45tF5Dwzi-if-UO8xwn9ajRUwR0l9N0SVYcw_1_f85_eD5qzuw_IkPY2wHEtxn4OBfTm-UPypFIqwyjGmY09BAMEA",
41+ "es256": "eyJhbGciOiAiRVMyNTYiLCAia2lkIjogImVjMSIsICJ0eXAiOiAiSldUIn0.eyJpc3MiOiAiaHR0cHM6Ly9pZHAuZXhhbXBsZS50ZXN0IiwgImF1ZCI6ICJjbGllbnQiLCAiZXhwIjogMTgwMDAwMDYwMCwgImlhdCI6IDE4MDAwMDAwMDAsICJub25jZSI6ICJuIiwgInN1YiI6ICJ1MSIsICJlbWFpbCI6ICJhZGFAYWNtZS50ZXN0In0.46rAsOYZfUoU35hVGJMTyl4cD4VKh94-MsMXTnSfAAOiSlaWH21Jb4iZ9kidzvwhajUGyQ6KQgZtzAsYv03-SA",
42+ "rs256_other_key": "eyJhbGciOiAiUlMyNTYiLCAia2lkIjogInJzYTIiLCAidHlwIjogIkpXVCJ9.eyJpc3MiOiAiaHR0cHM6Ly9pZHAuZXhhbXBsZS50ZXN0IiwgImF1ZCI6ICJjbGllbnQiLCAiZXhwIjogMTgwMDAwMDYwMCwgImlhdCI6IDE4MDAwMDAwMDAsICJub25jZSI6ICJuIiwgInN1YiI6ICJ1MSIsICJlbWFpbCI6ICJhZGFAYWNtZS50ZXN0In0.UcDwQX2YtpyPLFwMSUGFfnGEV9XxI54mTXh38DC2AQ4VpQ7mqFxFR6edRDpvDMv-NPwPRya3D0skPXl8yIBWkK69xUuhj-fycAu0MpuJZGyP_90GT_PoDNPYiNX96Wlqm94iibrhGeNwtb8YGtm9JSfC3RnHeUkxwsK_25yEOtHfTxpoyKSW4bx5uaPEwg328r73d6kMUcZZCe61Z0mVTN-h4NsfMGQ3HXCv12oo-Ti5guB5xNFGVup0y0THC87_04OZOgmCv7jA4eAU07bTwQj2G8ccPMROdb9cQP5sf8OfwdA5n8vUJtWbZoUkExsJx5pE_HiezOL17vh4ColQ4g"
43+ },
44+ "jwks_without_rsa2": {
45+ "keys": [
46+ {
47+ "kty": "RSA",
48+ "kid": "rsa1",
49+ "use": "sig",
50+ "n": "wrtE1vPs-4sQR9amgH3_TWrrY9TKdHLgRYKbHuQlIlW5Mcs0UdVHYQeehc45xXY0-8TO-jifS8mAtqVwNF7mbtqmCzhzaKaFi3g-kTGyEkNiVHNovJvfCIrPAjkUixZc2yaP8V5wCePkxwnO2uVQgXrHpH_qUIOd_p9BEqk89w42lsT9P2AT-DayQdXo8N2eCmskTokvLNXTcvjreMJ6ut1ecXoQ_XIGQEg5jXbIxG4S507uKvMWjYm6U1X7YOxhlN407VS4g0KgbCstosUxhAkwHgkJtL287CVv4VLs1PQajK3mShlAVt3EFCe_yZPWFt2yhw-P3lRpktiizFAF4w",
51+ "e": "AQAB"
52+ },
53+ {
54+ "kty": "EC",
55+ "kid": "ec1",
56+ "use": "sig",
57+ "crv": "P-256",
58+ "x": "cE6lw8bOHDe30Hkz8GlJPTo2-xgGuGx39ITmnvcFdaI",
59+ "y": "Uei4ubkFj1rtZhp4oiX6-eJrDM48_-4NHwAjAdb9ze8"
60+ }
61+ ]
62+ }
63+}
+60−25
11 //! OpenID Connect and OAuth 2.0 for single sign-on: the authorization code
22 //! flow, with PKCE (S256) and, for OpenID Connect, a nonce.
33 //!
4−//! OpenID Connect reads the issuer's discovery document for its endpoints,
5−//! and the ID token from the token endpoint. g1t is a confidential client
6−//! and receives the ID token straight from the token endpoint over TLS, so
7−//! it checks its claims (issuer, audience, expiry, nonce) and, as OpenID
8−//! Connect Core 3.1.3.7 allows for exactly this case, relies on TLS to the
9−//! issuer's own endpoint rather than the token's signature. Plain OAuth 2.0
4+//! OpenID Connect reads the issuer's discovery document for its endpoints
5+//! and keys, and the ID token from the token endpoint. The token's
6+//! signature is verified with the issuer's published keys (jwt.rs) before
7+//! anything in it is read, and then its claims are checked: issuer,
8+//! audience (and azp), expiry, issued-at and nonce. Plain OAuth 2.0
109 //! has no ID token: the person's id and address are claims of the userinfo
1110 //! endpoint's answer, named in the connection.
1211
1312 use base64::Engine;
14−use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
13+use base64::engine::general_purpose::STANDARD;
1514 use serde_json::Value;
1615 use worker::{Fetch, Headers, Method, Request, RequestInit, Url};
1716
2726 pub userinfo: Option<String>,
2827 /// OpenID Connect: the issuer, as its discovery document says it.
2928 pub issuer: Option<String>,
29+ /// OpenID Connect: where the issuer publishes its signing keys.
30+ pub jwks: Option<String>,
3031 /// Whether the client authenticates with HTTP Basic (else in the form).
3132 pub basic: bool,
3233 }
4647 let (Some(authorize), Some(token)) = (endpoint("authorization_endpoint"), endpoint("token_endpoint")) else {
4748 return Err("The provider's discovery document has no https authorization and token endpoints.".to_owned());
4849 };
50+ let Some(jwks) = endpoint("jwks_uri") else {
51+ return Err("The provider's discovery document publishes no signing keys (jwks_uri), so its ID tokens cannot be verified.".to_owned());
52+ };
4953 let methods: Vec<&str> = document["token_endpoint_auth_methods_supported"]
5054 .as_array()
5155 .map(|methods| methods.iter().filter_map(Value::as_str).collect())
5559 token,
5660 userinfo: endpoint("userinfo_endpoint"),
5761 issuer: Some(said.to_owned()),
62+ jwks: Some(jwks),
5863 // Basic is the default when the provider lists none.
5964 basic: methods.is_empty() || methods.contains(&"client_secret_basic"),
6065 })
7378 endpoints_from(&document, issuer)
7479 }
7580
81+/// Reads the issuer's signing keys.
82+pub async fn keys(url: &str) -> Result<Value, String> {
83+ let (status, keys) = send(Method::Get, url, Headers::new(), None).await.map_err(|_| UNREACHABLE.to_owned())?;
84+ if status != 200 || !keys["keys"].is_array() {
85+ return Err(format!("The provider's signing keys ({url}) answered {status}."));
86+ }
87+ Ok(keys)
88+}
89+
7690 /// The scopes to ask for: as set, always with `openid` for OpenID Connect.
7791 pub fn scopes(set: Option<&str>, openid: bool) -> String {
7892 let set = set.map(str::trim).filter(|s| !s.is_empty());
190204 Ok(answer)
191205 }
192206
193−/// The claims of an ID token, once they say it is for this sign-in.
194−pub fn id_token_claims(id_token: &str, issuer: &str, client_id: &str, nonce: &str, now_seconds: u64) -> Result<Value, String> {
195− const UNREADABLE: &str = "The identity provider's ID token could not be read.";
196− let mut parts = id_token.split('.');
197− let (Some(_), Some(payload), Some(_), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else {
198− return Err(UNREADABLE.to_owned());
199− };
200− let bytes = URL_SAFE_NO_PAD.decode(payload.trim_end_matches('=')).map_err(|_| UNREADABLE.to_owned())?;
201− let claims: Value = serde_json::from_slice(&bytes).map_err(|_| UNREADABLE.to_owned())?;
202− if claims["iss"].as_str() != Some(issuer) {
207+/// The claims of an ID token: its signature verified with the issuer's
208+/// keys (jwt.rs), then what it says checked against this sign-in.
209+pub fn id_token_claims(id_token: &str, keys: &Value, issuer: &str, client_id: &str, nonce: &str, now_seconds: u64) -> Result<Value, String> {
210+ let claims = super::jwt::verify(id_token, keys)?;
211+ check_claims(claims, issuer, client_id, nonce, now_seconds)
212+}
213+
214+/// Whether a verified ID token's claims say it is for this sign-in.
215+pub fn check_claims(claims: Value, issuer: &str, client_id: &str, nonce: &str, now_seconds: u64) -> Result<Value, String> {
216+ // Google issues ID tokens as either form of its issuer (its OpenID
217+ // Connect docs say so); no other issuer is taken without its scheme.
218+ let google = issuer == "https://accounts.google.com" && claims["iss"].as_str() == Some("accounts.google.com");
219+ if claims["iss"].as_str() != Some(issuer) && !google {
203220 return Err(format!("The ID token was issued by {:?}, not {issuer:?}.", claims["iss"].as_str().unwrap_or_default()));
204221 }
205222 let audiences: Vec<&str> = match &claims["aud"] {
213230 if claims["exp"].as_u64().is_none_or(|exp| exp + SKEW_SECONDS <= now_seconds) {
214231 return Err("The ID token has expired. Start again.".to_owned());
215232 }
233+ if claims["iat"].as_u64().is_some_and(|iat| iat > now_seconds + SKEW_SECONDS) {
234+ return Err("The ID token was issued in the future: check the identity provider's clock.".to_owned());
235+ }
216236 if claims["nonce"].as_str() != Some(nonce) {
217237 return Err("The ID token answers another sign-in. Start again.".to_owned());
218238 }
238258 use super::*;
239259 use serde_json::json;
240260
241− fn token(claims: Value) -> String {
242− format!("e30.{}.c2ln", URL_SAFE_NO_PAD.encode(claims.to_string()))
243− }
244−
245261 #[test]
246262 fn discovery_must_name_the_issuer_and_https_endpoints() {
247263 let document = json!({
249265 "authorization_endpoint": "https://acme.okta.com/oauth2/v1/authorize",
250266 "token_endpoint": "https://acme.okta.com/oauth2/v1/token",
251267 "userinfo_endpoint": "https://acme.okta.com/oauth2/v1/userinfo",
268+ "jwks_uri": "https://acme.okta.com/oauth2/v1/keys",
252269 "token_endpoint_auth_methods_supported": ["client_secret_post"],
253270 });
254271 let found = endpoints_from(&document, "https://acme.okta.com/").unwrap();
257274 assert!(endpoints_from(&document, "https://globex.okta.com").unwrap_err().contains("names the issuer"));
258275 let plain = json!({ "issuer": "https://a.test", "authorization_endpoint": "http://a.test/auth", "token_endpoint": "https://a.test/token" });
259276 assert!(endpoints_from(&plain, "https://a.test").is_err());
260− let defaults = json!({ "issuer": "https://a.test", "authorization_endpoint": "https://a.test/auth", "token_endpoint": "https://a.test/token" });
277+ let defaults = json!({ "issuer": "https://a.test", "authorization_endpoint": "https://a.test/auth", "token_endpoint": "https://a.test/token", "jwks_uri": "https://a.test/keys" });
261278 assert!(endpoints_from(&defaults, "https://a.test").unwrap().basic);
279+ let keyless = json!({ "issuer": "https://a.test", "authorization_endpoint": "https://a.test/auth", "token_endpoint": "https://a.test/token" });
280+ assert!(endpoints_from(&keyless, "https://a.test").unwrap_err().contains("jwks_uri"));
262281 }
263282
264283 #[test]
288307 fn an_id_token_must_be_for_this_sign_in() {
289308 let now = 1_800_000_000;
290309 let good = json!({ "iss": "https://idp.test", "aud": "client", "exp": now + 60, "nonce": "n", "sub": "u1", "email": "ada@acme.test" });
291− let claims = id_token_claims(&token(good.clone()), "https://idp.test", "client", "n", now).unwrap();
310+ let claims = check_claims(good.clone(), "https://idp.test", "client", "n", now).unwrap();
292311 assert_eq!(claim(&claims, "sub").as_deref(), Some("u1"));
293312 let with = |field: &str, value: Value| {
294313 let mut claims = good.clone();
295314 claims[field] = value;
296− id_token_claims(&token(claims), "https://idp.test", "client", "n", now)
315+ check_claims(claims, "https://idp.test", "client", "n", now)
297316 };
298317 assert!(with("iss", json!("https://evil.test")).unwrap_err().contains("issued by"));
299318 assert!(with("aud", json!("other")).unwrap_err().contains("client id"));
300319 assert!(with("aud", json!(["other", "client"])).is_ok());
301320 assert!(with("exp", json!(now - 600)).unwrap_err().contains("expired"));
302321 assert!(with("nonce", json!("m")).unwrap_err().contains("another sign-in"));
303− assert!(id_token_claims("not.a-token", "https://idp.test", "client", "n", now).is_err());
322+ assert!(with("iat", json!(now + 3600)).unwrap_err().contains("future"));
323+ let google = json!({ "iss": "accounts.google.com", "aud": "client", "exp": now + 60, "nonce": "n", "sub": "1" });
324+ assert!(check_claims(google.clone(), "https://accounts.google.com", "client", "n", now).is_ok());
325+ assert!(check_claims(google, "https://idp.test", "client", "n", now).is_err());
326+ assert!(id_token_claims("not.a-token", &json!({ "keys": [] }), "https://idp.test", "client", "n", now).is_err());
327+ }
328+
329+ #[test]
330+ fn a_signed_id_token_is_verified_then_checked() {
331+ let fixtures: Value = serde_json::from_str(include_str!("jwt_fixtures.json")).unwrap();
332+ let token = fixtures["tokens"]["rs256"].as_str().unwrap();
333+ let claims = id_token_claims(token, &fixtures["jwks"], "https://idp.example.test", "client", "n", 1_800_000_100).unwrap();
334+ assert_eq!(claim(&claims, "email").as_deref(), Some("ada@acme.test"));
335+ // Signed, but for another sign-in.
336+ assert!(id_token_claims(token, &fixtures["jwks"], "https://idp.example.test", "client", "other", 1_800_000_100).is_err());
337+ // Not signed with the issuer's keys.
338+ assert!(id_token_claims(token, &json!({ "keys": [fixtures["jwks"]["keys"][1].clone()] }), "https://idp.example.test", "client", "n", 1_800_000_100).is_err());
304339 }
305340
306341 #[test]
+65−13
4141 slug: &'a str,
4242 connection_id: &'a str,
4343 base: &'a str,
44+ /// Why the workspace may not let anyone in now, when its plan has
45+ /// ended: assigning or reactivating anyone is refused; reading and
46+ /// taking people out never are, so leavers still leave.
47+ plan: Option<&'a str>,
48+}
49+
50+impl Scim<'_> {
51+ fn plan_refusal(&self) -> Option<ScimAnswer> {
52+ self.plan.map(|why| scim_error(scim::ScimError::new(403, None, why)))
53+ }
4454 }
4555
4656 const SELECT_ROW: &str = "SELECT id, resource, user_id, created_at, updated_at FROM scim_users";
4757
58+/// Which provisioned user someone signing in is.
59+pub(crate) enum Match {
60+ None,
61+ /// The one: its id, and the g1t account already linked to it.
62+ One(String, Option<String>),
63+ /// More than one: nobody is chosen.
64+ Many,
65+}
66+
4867 impl Identity {
4968 pub(super) async fn scim_on(&self, workspace_id: &str) -> Result<bool> {
5069 Ok(self
114133 }))
115134 }
116135
117− /// The active provisioned user someone signing in as `subject` is: its
118− /// id, and the g1t account already linked to it.
119− pub(super) async fn provisioned_as(&self, workspace_id: &str, subject: &str, email: Option<&str>) -> Result<Option<(String, Option<String>)>> {
136+ /// The active provisioned user someone signing in is (scim::is_them):
137+ /// exactly one, or nobody.
138+ pub(super) async fn provisioned_as(&self, workspace_id: &str, ids: &[String], trusted_email: Option<&str>) -> Result<Match> {
120139 let rows = self
121140 .db
122141 .prepare(format!("{SELECT_ROW} WHERE workspace_id = ? AND active = 1"))
124143 .all()
125144 .await?
126145 .results::<ScimRow>()?;
127− Ok(rows.into_iter().find(|row| scim::is_them(&row.user(), subject, email)).map(|row| (row.id, row.user_id)))
146+ let mut found = rows.into_iter().filter(|row| scim::is_them(&row.user(), ids, trusted_email));
147+ Ok(match (found.next(), found.next()) {
148+ (None, _) => Match::None,
149+ (Some(row), None) => Match::One(row.id, row.user_id),
150+ (Some(_), Some(_)) => Match::Many,
151+ })
128152 }
129153
130154 /// `enable_scim`: a new token, replacing any other. Owners, on a paid
218242 let Some(connection) = self.connection_where("workspace_id", &workspace_id).await? else {
219243 return Ok(scim_error(scim::ScimError::new(403, None, "Set up single sign-on for the workspace first.")));
220244 };
221− if let Some(Outcome::Fail(failure)) = self.plan_refusal::<()>(&slug, plan_needed(&slug)).await? {
222− return Ok(scim_error(scim::ScimError::new(403, None, failure.message)));
223− }
245+ let plan = self.plan_refusal::<()>(&slug, plan_needed(&slug)).await?.and_then(|refused| match refused {
246+ Outcome::Fail(failure) => Some(failure.message),
247+ Outcome::Ok(()) => None,
248+ });
224249 self.db
225250 .prepare("UPDATE scim_tokens SET last_used_at = ? WHERE workspace_id = ?")
226251 .bind(&[rfc3339(now_ms()).into(), workspace_id.as_str().into()])?
238263 let query: HashMap<String, String> = worker::Url::parse(&format!("https://q.invalid/?{}", a.query.as_deref().unwrap_or("")))
239264 .map(|url| url.query_pairs().into_owned().collect())
240265 .unwrap_or_default();
241− let at = Scim { workspace_id: &workspace_id, slug: &slug, connection_id: &connection.id, base: &a.base };
266+ let at = Scim { workspace_id: &workspace_id, slug: &slug, connection_id: &connection.id, base: &a.base, plan: plan.as_deref() };
242267 Ok(match (method.as_str(), segments.as_slice()) {
243268 ("GET", ["ServiceProviderConfig"]) => answer(200, Some(scim::service_provider_config("https://docs.g1t.sh/guides/single-sign-on/#scim"))),
244269 ("GET", ["ResourceTypes"]) => answer(200, Some(scim::resource_types(&a.base))),
323348 Ok(user) => user,
324349 Err(error) => return Ok(scim_error(error)),
325350 };
351+ if user.active
352+ && let Some(refused) = at.plan_refusal()
353+ {
354+ return Ok(refused);
355+ }
326356 let key = scim::user_name_key(&user.user_name);
327357 if self.user_name_taken(at.workspace_id, &key, "").await? {
328358 return Ok(scim_error(scim::ScimError::new(409, Some("uniqueness"), format!("{} is provisioned already.", user.user_name))));
373403 Ok(user) => user,
374404 Err(error) => return Ok(scim_error(error)),
375405 };
406+ if user.active
407+ && let Some(refused) = at.plan_refusal()
408+ {
409+ return Ok(refused);
410+ }
376411 let key = scim::user_name_key(&user.user_name);
377412 if self.user_name_taken(at.workspace_id, &key, id).await? {
378413 return Ok(scim_error(scim::ScimError::new(409, Some("uniqueness"), format!("{} is provisioned already.", user.user_name))));
417452 return Ok(scim_error(scim::ScimError::new(404, None, "No such user.")));
418453 };
419454 let user = row.user();
420− if let Some(user_id) = &row.user_id {
455+ let linked = match row.user_id.clone() {
456+ Some(linked) => Some(linked),
457+ None => self.sso_account_of(at.connection_id, &user).await?,
458+ };
459+ if let Some(user_id) = &linked {
421460 self.deprovision(at, user_id, &user.user_name).await?;
422461 }
423462 self.db
430469 Ok(answer(204, None))
431470 }
432471
433− /// The g1t account already linked through single sign-on as `user`.
472+ /// The g1t account already linked through single sign-on as `user`, by
473+ /// the subject it signed in as (the address it gave is not trusted for
474+ /// this). Only when exactly one is.
434475 async fn sso_account_of(&self, connection_id: &str, user: &scim::ScimUser) -> Result<Option<String>> {
435476 #[derive(Deserialize)]
436477 struct Linked {
437478 user_id: String,
438479 subject: String,
439− email: Option<String>,
440480 }
441481 let linked = self
442482 .db
443− .prepare("SELECT user_id, subject, email FROM sso_identities WHERE connection_id = ?")
483+ .prepare("SELECT user_id, subject FROM sso_identities WHERE connection_id = ?")
444484 .bind(&[connection_id.into()])?
445485 .all()
446486 .await?
447487 .results::<Linked>()?;
448− Ok(linked.into_iter().find(|l| scim::is_them(user, &l.subject, l.email.as_deref())).map(|l| l.user_id))
488+ let mut found = linked.into_iter().filter(|l| scim::is_them(user, std::slice::from_ref(&l.subject), None));
489+ Ok(match (found.next(), found.next()) {
490+ (Some(one), None) => Some(one.user_id),
491+ _ => None,
492+ })
449493 }
450494
451495 /// Someone the provider unassigned leaves the workspace: their
482526 self.db
483527 .prepare("DELETE FROM sso_sessions WHERE workspace_id = ? AND session_id IN (SELECT id FROM sessions WHERE user_id = ?)")
484528 .bind(&[at.workspace_id.into(), user_id.into()])?,
529+ // An invitation waiting for them would only let them back in.
530+ self.db
531+ .prepare(format!(
532+ "UPDATE invites SET revoked_at = {SQL_NOW}, sealed_code = NULL
533+ WHERE workspace_id = ? AND invitee_id = ? AND revoked_at IS NULL AND accepted_at IS NULL
534+ AND declined_at IS NULL AND redeemed_at IS NULL"
535+ ))
536+ .bind(&[at.workspace_id.into(), user_id.into()])?,
485537 ])
486538 .await?;
487539 if member.is_some() {
+44−8
116116 return Err("The identity provider did not send a SAML response.".to_owned());
117117 }
118118 unique_ids(&doc)?;
119+ plain(&doc)?;
119120 // Signed first: nothing else is read until it verifies.
120121 let assertion = only_child(response, ASSERTION, "Assertion")?;
121122 let Some(assertion) = assertion else {
124125 }
125126 return Err(status_message(response).unwrap_or_else(|| "The identity provider's response has no assertion.".to_owned()));
126127 };
127− let mut signed = false;
128+ let mut assertion_signed = false;
129+ let mut response_signed = false;
128130 if let Some(signature) = only_child(assertion, DSIG, "Signature")? {
129131 verify_signature(&xml, signature, expect.certificates)?;
130− signed = true;
132+ assertion_signed = true;
131133 }
132134 if let Some(signature) = only_child(response, DSIG, "Signature")? {
133135 verify_signature(&xml, signature, expect.certificates)?;
134− signed = true;
136+ response_signed = true;
135137 }
136− if !signed {
138+ if !assertion_signed && !response_signed {
137139 return Err(NOT_SIGNED.to_owned());
138140 }
139141 if let Some(message) = status_message(response) {
140142 return Err(message);
141143 }
142− check_response(response, assertion, expect)
144+ check_response(response, assertion, expect, response_signed)
143145 }
144146
145147 /// Why the provider said no, when its status is not success.
156158 })
157159 }
158160
159−fn check_response(response: Node, assertion: Node, expect: &Expect) -> Result<Asserted, String> {
161+/// `response_signed`: whether the response itself was signed. When only the
162+/// assertion is, the response's own `InResponseTo` is anyone's to write,
163+/// so the assertion's signed subject confirmation must name the request.
164+fn check_response(response: Node, assertion: Node, expect: &Expect, response_signed: bool) -> Result<Asserted, String> {
160165 match response.attribute("InResponseTo") {
161166 Some(id) if id == expect.request_id => {}
162167 Some(_) => return Err("This response answers another sign-in. Start again.".to_owned()),
196201 .filter_map(|c| child(c, ASSERTION, "SubjectConfirmationData"))
197202 .any(|data| {
198203 data.attribute("Recipient") == Some(expect.acs_url)
199− && data.attribute("InResponseTo").is_none_or(|id| id == expect.request_id)
204+ && match data.attribute("InResponseTo") {
205+ Some(id) => id == expect.request_id,
206+ None => response_signed,
207+ }
200208 && data.attribute("NotBefore").is_none()
201209 && data.attribute("NotOnOrAfter").and_then(parse_time).is_some_and(|at| now < at + SKEW_MS)
202210 });
253261
254262 // --- The document -----------------------------------------------------------
255263
264+/// All of an element's text, as canonicalization reads it: every text node
265+/// inside it, not only the first, so what is read is what was signed.
256266 fn text(node: Node) -> String {
257− node.text().unwrap_or_default().trim().to_owned()
267+ let all: String = node.descendants().filter(Node::is_text).filter_map(|t| t.text()).collect();
268+ all.trim().to_owned()
269+}
270+
271+/// The deepest a response may nest. Real ones are under ten.
272+const MAX_DEPTH: usize = 64;
273+
274+/// Refuses comments and processing instructions anywhere in the response,
275+/// and nesting deeper than any provider sends. Canonicalization leaves
276+/// comments out, so `ada@acme.test<!---->.evil.io` signs as one address and
277+/// would read as another; no provider puts them in a response. Depth is
278+/// checked before canonicalization, which recurses.
279+fn plain(doc: &Document) -> Result<(), String> {
280+ // Document order visits a parent before its children, so each depth is
281+ // its parent's plus one, found without recursion.
282+ let mut depths: std::collections::HashMap<NodeId, usize> = std::collections::HashMap::new();
283+ for node in doc.descendants() {
284+ if node.is_comment() || node.is_pi() {
285+ return Err("The identity provider's response holds comments or processing instructions, which g1t does not accept.".to_owned());
286+ }
287+ let depth = node.parent().and_then(|parent| depths.get(&parent.id()).copied()).unwrap_or(0) + 1;
288+ if depth > MAX_DEPTH {
289+ return Err("The identity provider's response nests too deeply.".to_owned());
290+ }
291+ depths.insert(node.id(), depth);
292+ }
293+ Ok(())
258294 }
259295
260296 fn children<'a, 'i>(node: Node<'a, 'i>, ns: &'a str, name: &'a str) -> impl Iterator<Item = Node<'a, 'i>> + 'a {
+29−0
194194 assert_eq!(doc.root_element().attribute("entityID"), Some(AUDIENCE));
195195 assert!(xml.contains(&format!("Location=\"{ACS}\"")));
196196 }
197+
198+#[test]
199+fn a_comment_cannot_shorten_what_was_signed() {
200+ let certificates = certs("cert");
201+ // The CVE-2017-11427 trick: the digest skips the comment, the reader
202+ // would stop at it.
203+ let injected = edited("assertion_signed", |xml| xml.replacen(">ada@acme.test<", ">ada@acme.test<!---->.evil.io<", 1));
204+ assert!(verify_response(&injected, &expect(&certificates)).unwrap_err().contains("comments"));
205+ let pi = edited("assertion_signed", |xml| xml.replacen("<saml2:Subject>", "<?x y?><saml2:Subject>", 1));
206+ assert!(verify_response(&pi, &expect(&certificates)).unwrap_err().contains("processing instructions"));
207+}
208+
209+#[test]
210+fn text_is_read_whole() {
211+ let xml = "<a>one<b>two</b> three</a>";
212+ let doc = Document::parse(xml).unwrap();
213+ assert_eq!(text(doc.root_element()), "onetwo three");
214+}
215+
216+#[test]
217+fn deep_nesting_is_refused_before_canonicalization() {
218+ let certificates = certs("cert");
219+ let deep = format!(
220+ "<samlp:Response xmlns:samlp=\"{PROTOCOL}\">{}{}</samlp:Response>",
221+ "<x>".repeat(100),
222+ "</x>".repeat(100)
223+ );
224+ assert!(verify_response(&STANDARD.encode(deep), &expect(&certificates)).unwrap_err().contains("nests too deeply"));
225+}
+24−13
1515
1616 pub const USER_SCHEMA: &str = "urn:ietf:params:scim:schemas:core:2.0:User";
1717 pub const LIST_SCHEMA: &str = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
18+#[cfg(test)]
1819 pub const PATCH_SCHEMA: &str = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
1920 pub const ERROR_SCHEMA: &str = "urn:ietf:params:scim:api:messages:2.0:Error";
2021 /// The most users one page lists.
246247 Ok(())
247248 }
248249
249−/// Whether someone who signed in through the provider as `subject`, with
250−/// `email`, is this provisioned user.
251−pub fn is_them(user: &ScimUser, subject: &str, email: Option<&str>) -> bool {
252− let subject = subject.trim().to_lowercase();
253− let same = |value: &str| value.trim().to_lowercase() == subject;
250+/// Whether someone who signed in through the provider is this provisioned
251+/// user: one of the stable ids it gave (`ids`) is their userName or
252+/// externalId, or an address it vouches for (`trusted_email`) is their
253+/// userName or primary address. A secondary address, or one the provider
254+/// does not vouch for, never matches.
255+pub fn is_them(user: &ScimUser, ids: &[String], trusted_email: Option<&str>) -> bool {
256+ let same = |value: &str| ids.iter().any(|id| id.trim().eq_ignore_ascii_case(value.trim()));
254257 same(&user.user_name)
255258 || user.external_id.as_deref().is_some_and(same)
256− || email.is_some_and(|email| {
259+ || trusted_email.is_some_and(|email| {
257260 let email = email.trim().to_lowercase();
258− user_name_key(&user.user_name) == email || user.emails.contains(&email)
261+ user_name_key(&user.user_name) == email || user.emails.first() == Some(&email)
259262 })
260263 }
261264
385388 }
386389
387390 #[test]
388− fn a_sign_in_is_matched_by_id_name_or_address() {
391+ fn a_sign_in_is_matched_by_id_name_or_vouched_address() {
389392 let user = from_resource(&okta_user()).unwrap();
390− assert!(is_them(&user, "00u1abcd", None));
391− assert!(is_them(&user, "ADA@acme.test", None));
392− assert!(is_them(&user, "some-oidc-sub", Some("ada@acme.test")));
393− assert!(!is_them(&user, "some-oidc-sub", Some("eve@acme.test")));
394− assert!(!is_them(&user, "00u9", None));
393+ let ids = |list: &[&str]| list.iter().map(|id| id.to_string()).collect::<Vec<_>>();
394+ assert!(is_them(&user, &ids(&["00u1abcd"]), None));
395+ assert!(is_them(&user, &ids(&["ADA@acme.test"]), None));
396+ assert!(is_them(&user, &ids(&["pairwise", "00u1abcd"]), None));
397+ assert!(is_them(&user, &ids(&["some-oidc-sub"]), Some("ada@acme.test")));
398+ assert!(!is_them(&user, &ids(&["some-oidc-sub"]), Some("eve@acme.test")));
399+ assert!(!is_them(&user, &ids(&["00u9"]), None));
400+ // A secondary address is not enough.
401+ let mut two = user.clone();
402+ two.user_name = "ada".into();
403+ two.emails = vec!["ada@acme.test".into(), "shared@acme.test".into()];
404+ assert!(!is_them(&two, &ids(&["x"]), Some("shared@acme.test")));
405+ assert!(is_them(&two, &ids(&["x"]), Some("ada@acme.test")));
395406 }
396407
397408 #[test]
+1−1
2222
2323 /// Enough for a person and their teams; stops one account claiming names in
2424 /// bulk.
25−const MAX_WORKSPACES_PER_USER: usize = 10;
25+pub(crate) const MAX_WORKSPACES_PER_USER: usize = 10;
2626
2727 const MAX_NAME_LENGTH: usize = 80;
2828 const MAX_DESCRIPTION_LENGTH: usize = 160;

Comments

Sign in to comment.