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
| 364 | 364 | ||
| 365 | 365 | [[package]] | |
| 366 | 366 | 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" | |
| 367 | 373 | version = "1.0.0" | |
| 368 | 374 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 369 | 375 | checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" | |
| ⋯ | |||
| 754 | 760 | ||
| 755 | 761 | [[package]] | |
| 756 | 762 | 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" | |
| 757 | 775 | version = "0.7.5" | |
| 758 | 776 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 759 | 777 | checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" | |
| ⋯ | |||
| 797 | 815 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 798 | 816 | checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" | |
| 799 | 817 | dependencies = [ | |
| 800 | − | "crypto-bigint", | |
| 818 | + | "crypto-bigint 0.7.5", | |
| 801 | 819 | "rand_core 0.10.1", | |
| 802 | 820 | ] | |
| 803 | 821 | ||
| ⋯ | |||
| 990 | 1008 | ||
| 991 | 1009 | [[package]] | |
| 992 | 1010 | 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" | |
| 993 | 1024 | version = "0.17.0" | |
| 994 | 1025 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 995 | 1026 | checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" | |
| 996 | 1027 | dependencies = [ | |
| 997 | 1028 | "der 0.8.2", | |
| 998 | 1029 | "digest 0.11.3", | |
| 999 | − | "elliptic-curve", | |
| 1000 | − | "rfc6979", | |
| 1030 | + | "elliptic-curve 0.14.1", | |
| 1031 | + | "rfc6979 0.6.0", | |
| 1001 | 1032 | "signature 3.0.0", | |
| 1002 | 1033 | "spki 0.8.0", | |
| 1003 | 1034 | "zeroize", | |
| ⋯ | |||
| 1031 | 1062 | ||
| 1032 | 1063 | [[package]] | |
| 1033 | 1064 | 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" | |
| 1034 | 1083 | version = "0.14.1" | |
| 1035 | 1084 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1036 | 1085 | checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" | |
| 1037 | 1086 | dependencies = [ | |
| 1038 | − | "base16ct", | |
| 1039 | − | "crypto-bigint", | |
| 1087 | + | "base16ct 1.0.0", | |
| 1088 | + | "crypto-bigint 0.7.5", | |
| 1040 | 1089 | "crypto-common 0.2.2", | |
| 1041 | 1090 | "digest 0.11.3", | |
| 1042 | − | "ff", | |
| 1043 | − | "group", | |
| 1091 | + | "ff 0.14.0", | |
| 1092 | + | "group 0.14.0", | |
| 1044 | 1093 | "hkdf", | |
| 1045 | 1094 | "hybrid-array", | |
| 1046 | 1095 | "pem-rfc7468", | |
| 1047 | 1096 | "pkcs8 0.11.0", | |
| 1048 | 1097 | "rand_core 0.10.1", | |
| 1049 | − | "sec1", | |
| 1098 | + | "sec1 0.8.1", | |
| 1050 | 1099 | "subtle", | |
| 1051 | 1100 | "zeroize", | |
| 1052 | 1101 | ] | |
| ⋯ | |||
| 1160 | 1209 | ||
| 1161 | 1210 | [[package]] | |
| 1162 | 1211 | 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" | |
| 1163 | 1222 | version = "0.14.0" | |
| 1164 | 1223 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1165 | 1224 | checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" | |
| ⋯ | |||
| 1461 | 1520 | "hex", | |
| 1462 | 1521 | "hmac 0.12.1", | |
| 1463 | 1522 | "miniz_oxide", | |
| 1523 | + | "p256 0.13.2", | |
| 1464 | 1524 | "pbkdf2 0.12.2", | |
| 1465 | 1525 | "roxmltree", | |
| 1466 | 1526 | "rsa 0.9.10", | |
| ⋯ | |||
| 1684 | 1744 | dependencies = [ | |
| 1685 | 1745 | "typenum", | |
| 1686 | 1746 | "version_check", | |
| 1747 | + | "zeroize", | |
| 1687 | 1748 | ] | |
| 1688 | 1749 | ||
| 1689 | 1750 | [[package]] | |
| ⋯ | |||
| 1746 | 1807 | ||
| 1747 | 1808 | [[package]] | |
| 1748 | 1809 | 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" | |
| 1749 | 1821 | version = "0.14.0" | |
| 1750 | 1822 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1751 | 1823 | checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" | |
| 1752 | 1824 | dependencies = [ | |
| 1753 | − | "ff", | |
| 1825 | + | "ff 0.14.0", | |
| 1754 | 1826 | "rand_core 0.10.1", | |
| 1755 | 1827 | "subtle", | |
| 1756 | 1828 | ] | |
| ⋯ | |||
| 2678 | 2750 | ||
| 2679 | 2751 | [[package]] | |
| 2680 | 2752 | 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" | |
| 2681 | 2765 | version = "0.14.0" | |
| 2682 | 2766 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2683 | 2767 | checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" | |
| 2684 | 2768 | dependencies = [ | |
| 2685 | − | "ecdsa", | |
| 2686 | − | "elliptic-curve", | |
| 2769 | + | "ecdsa 0.17.0", | |
| 2770 | + | "elliptic-curve 0.14.1", | |
| 2687 | 2771 | "primefield", | |
| 2688 | − | "primeorder", | |
| 2772 | + | "primeorder 0.14.0", | |
| 2689 | 2773 | "sha2 0.11.0", | |
| 2690 | 2774 | ] | |
| 2691 | 2775 | ||
| ⋯ | |||
| 2695 | 2779 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2696 | 2780 | checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f" | |
| 2697 | 2781 | dependencies = [ | |
| 2698 | − | "ecdsa", | |
| 2699 | − | "elliptic-curve", | |
| 2782 | + | "ecdsa 0.17.0", | |
| 2783 | + | "elliptic-curve 0.14.1", | |
| 2700 | 2784 | "fiat-crypto", | |
| 2701 | 2785 | "primefield", | |
| 2702 | − | "primeorder", | |
| 2786 | + | "primeorder 0.14.0", | |
| 2703 | 2787 | "sha2 0.11.0", | |
| 2704 | 2788 | ] | |
| 2705 | 2789 | ||
| ⋯ | |||
| 2709 | 2793 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2710 | 2794 | checksum = "4ad64cc32c2dc466317c12ee5853e61f159f9eab1fe7efade0395dc2e7b43449" | |
| 2711 | 2795 | dependencies = [ | |
| 2712 | − | "base16ct", | |
| 2713 | − | "ecdsa", | |
| 2714 | − | "elliptic-curve", | |
| 2796 | + | "base16ct 1.0.0", | |
| 2797 | + | "ecdsa 0.17.0", | |
| 2798 | + | "elliptic-curve 0.14.1", | |
| 2715 | 2799 | "primefield", | |
| 2716 | − | "primeorder", | |
| 2800 | + | "primeorder 0.14.0", | |
| 2717 | 2801 | "sha2 0.11.0", | |
| 2718 | 2802 | ] | |
| 2719 | 2803 | ||
| ⋯ | |||
| 2723 | 2807 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2724 | 2808 | checksum = "6d8eab09a361a4afe0b1668be978cd97e4f052e927a92b0b608cf902965d49ce" | |
| 2725 | 2809 | dependencies = [ | |
| 2726 | − | "base16ct", | |
| 2810 | + | "base16ct 1.0.0", | |
| 2727 | 2811 | "byteorder", | |
| 2728 | 2812 | "bytes", | |
| 2729 | 2813 | "delegate", | |
| ⋯ | |||
| 3027 | 3111 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3028 | 3112 | checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" | |
| 3029 | 3113 | dependencies = [ | |
| 3030 | − | "crypto-bigint", | |
| 3114 | + | "crypto-bigint 0.7.5", | |
| 3031 | 3115 | "crypto-common 0.2.2", | |
| 3032 | − | "ff", | |
| 3116 | + | "ff 0.14.0", | |
| 3033 | 3117 | "rand_core 0.10.1", | |
| 3034 | 3118 | "subtle", | |
| 3035 | 3119 | "zeroize", | |
| ⋯ | |||
| 3037 | 3121 | ||
| 3038 | 3122 | [[package]] | |
| 3039 | 3123 | 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" | |
| 3040 | 3133 | version = "0.14.0" | |
| 3041 | 3134 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3042 | 3135 | checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" | |
| 3043 | 3136 | dependencies = [ | |
| 3044 | − | "elliptic-curve", | |
| 3137 | + | "elliptic-curve 0.14.1", | |
| 3045 | 3138 | "once_cell", | |
| 3046 | 3139 | "primefield", | |
| 3047 | 3140 | "serdect", | |
| ⋯ | |||
| 3295 | 3388 | ||
| 3296 | 3389 | [[package]] | |
| 3297 | 3390 | 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" | |
| 3298 | 3401 | version = "0.6.0" | |
| 3299 | 3402 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3300 | 3403 | checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" | |
| 3301 | 3404 | dependencies = [ | |
| 3302 | − | "crypto-bigint", | |
| 3405 | + | "crypto-bigint 0.7.5", | |
| 3303 | 3406 | "hmac 0.13.0", | |
| 3304 | 3407 | ] | |
| 3305 | 3408 | ||
| ⋯ | |||
| 3362 | 3465 | checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" | |
| 3363 | 3466 | dependencies = [ | |
| 3364 | 3467 | "const-oid 0.10.2", | |
| 3365 | − | "crypto-bigint", | |
| 3468 | + | "crypto-bigint 0.7.5", | |
| 3366 | 3469 | "crypto-primes", | |
| 3367 | 3470 | "digest 0.11.3", | |
| 3368 | 3471 | "pkcs1 0.8.0-rc.4", | |
| ⋯ | |||
| 3412 | 3515 | "bytes", | |
| 3413 | 3516 | "cbc", | |
| 3414 | 3517 | "cipher 0.5.2", | |
| 3415 | − | "crypto-bigint", | |
| 3518 | + | "crypto-bigint 0.7.5", | |
| 3416 | 3519 | "ctr 0.10.1", | |
| 3417 | 3520 | "curve25519-dalek", | |
| 3418 | 3521 | "data-encoding", | |
| 3419 | 3522 | "delegate", | |
| 3420 | 3523 | "der 0.8.2", | |
| 3421 | 3524 | "digest 0.11.3", | |
| 3422 | − | "ecdsa", | |
| 3525 | + | "ecdsa 0.17.0", | |
| 3423 | 3526 | "ed25519-dalek", | |
| 3424 | − | "elliptic-curve", | |
| 3527 | + | "elliptic-curve 0.14.1", | |
| 3425 | 3528 | "enum_dispatch", | |
| 3426 | 3529 | "flate2", | |
| 3427 | 3530 | "futures", | |
| ⋯ | |||
| 3437 | 3540 | "ml-kem", | |
| 3438 | 3541 | "module-lattice", | |
| 3439 | 3542 | "num-bigint 0.5.1", | |
| 3440 | − | "p256", | |
| 3543 | + | "p256 0.14.0", | |
| 3441 | 3544 | "p384", | |
| 3442 | 3545 | "p521", | |
| 3443 | 3546 | "pageant", | |
| ⋯ | |||
| 3453 | 3556 | "russh-util", | |
| 3454 | 3557 | "salsa20", | |
| 3455 | 3558 | "scrypt", | |
| 3456 | − | "sec1", | |
| 3559 | + | "sec1 0.8.1", | |
| 3457 | 3560 | "sha1 0.11.0", | |
| 3458 | 3561 | "sha2 0.11.0", | |
| 3459 | 3562 | "sha3 0.12.0", | |
| ⋯ | |||
| 3680 | 3783 | ||
| 3681 | 3784 | [[package]] | |
| 3682 | 3785 | 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" | |
| 3683 | 3799 | version = "0.8.1" | |
| 3684 | 3800 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3685 | 3801 | checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" | |
| 3686 | 3802 | dependencies = [ | |
| 3687 | − | "base16ct", | |
| 3803 | + | "base16ct 1.0.0", | |
| 3688 | 3804 | "ctutils", | |
| 3689 | 3805 | "der 0.8.2", | |
| 3690 | 3806 | "hybrid-array", | |
| ⋯ | |||
| 3865 | 3981 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3866 | 3982 | checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" | |
| 3867 | 3983 | dependencies = [ | |
| 3868 | − | "base16ct", | |
| 3984 | + | "base16ct 1.0.0", | |
| 3869 | 3985 | "serde", | |
| 3870 | 3986 | ] | |
| 3871 | 3987 | ||
| ⋯ | |||
| 4078 | 4194 | dependencies = [ | |
| 4079 | 4195 | "base64ct", | |
| 4080 | 4196 | "bytes", | |
| 4081 | − | "crypto-bigint", | |
| 4197 | + | "crypto-bigint 0.7.5", | |
| 4082 | 4198 | "ctutils", | |
| 4083 | 4199 | "digest 0.11.3", | |
| 4084 | 4200 | "pem-rfc7468", | |
| ⋯ | |||
| 4097 | 4213 | "ed25519-dalek", | |
| 4098 | 4214 | "hex", | |
| 4099 | 4215 | "hmac 0.13.0", | |
| 4100 | − | "p256", | |
| 4216 | + | "p256 0.14.0", | |
| 4101 | 4217 | "p384", | |
| 4102 | 4218 | "p521", | |
| 4103 | 4219 | "rand_core 0.10.1", | |
| 4104 | 4220 | "rsa 0.10.0-rc.18", | |
| 4105 | − | "sec1", | |
| 4221 | + | "sec1 0.8.1", | |
| 4106 | 4222 | "sha1 0.11.0", | |
| 4107 | 4223 | "sha2 0.11.0", | |
| 4108 | 4224 | "signature 3.0.0", | |
| ⋯ | |||
| 5076 | 5192 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5077 | 5193 | checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" | |
| 5078 | 5194 | dependencies = [ | |
| 5079 | − | "ff", | |
| 5080 | − | "group", | |
| 5195 | + | "ff 0.14.0", | |
| 5196 | + | "group 0.14.0", | |
| 5081 | 5197 | "hybrid-array", | |
| 5082 | 5198 | "primefield", | |
| 5083 | 5199 | ] | |
| 83 | 83 | pub fn description(self) -> &'static str { | |
| 84 | 84 | match self { | |
| 85 | 85 | 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.", | |
| 87 | 87 | 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.", | |
| 88 | 88 | 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.", | |
| 89 | 89 | 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.", | |
| ⋯ | |||
| 119 | 119 | "idp_entity_id": { "type": "string", "description": "saml: the provider's entity id (its issuer)." }, | |
| 120 | 120 | "idp_sso_url": { "type": "string", "description": "saml: where the provider takes requests (HTTP-Redirect, https)." }, | |
| 121 | 121 | "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." }, | |
| 123 | 124 | }), | |
| 124 | 125 | &["workspace", "protocol"], | |
| 125 | 126 | ), | |
| ⋯ | |||
| 169 | 170 | config[field] = json!(value); | |
| 170 | 171 | } | |
| 171 | 172 | } | |
| 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); | |
| 172 | 180 | serde_json::from_value(config).unwrap_or_default() | |
| 173 | 181 | } | |
| 174 | 182 | ||
| ⋯ | |||
| 227 | 235 | "config": config_of(input), | |
| 228 | 236 | "client_secret": text(input, "client_secret"), | |
| 229 | 237 | "certificate": text(input, "certificate"), | |
| 230 | − | "allow_join": flag(input, "allow_join").unwrap_or(true), | |
| 238 | + | "allow_join": flag(input, "allow_join").unwrap_or(false), | |
| 231 | 239 | "surface": surface, | |
| 232 | 240 | }), | |
| 233 | 241 | ) | |
| 61 | 61 | ||
| 62 | 62 | ## Let people join | |
| 63 | 63 | ||
| 64 | − | With **Let people from your provider join** on (the default), anyone your | |
| 65 | − | provider signs in becomes a member of the workspace when they first sign | |
| 66 | − | in through it, with no invitation: send them to | |
| 67 | − | `g1t.sh/auth/sso/<workspace>`. They need a g1t account with a confirmed | |
| 68 | − | email address first, and the workspace's other rules still apply, such as | |
| 69 | − | [requiring two-factor authentication](/guides/workspaces/#require-two-factor-authentication). | |
| 70 | − | Turn it off and only people who are already members can sign in through | |
| 71 | − | it. | |
| 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). | |
| 72 | 71 | ||
| 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 | + | ||
| 73 | 79 | ## Require it | |
| 74 | 80 | ||
| 75 | 81 | Turn on **Require single sign-on** under **Test and require**. From then | |
| ⋯ | |||
| 78 | 84 | - **On the site**, from a session that signed in through your provider in | |
| 79 | 85 | the last day. After a day they go through it again, which is usually | |
| 80 | 86 | 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. | |
| 83 | 90 | ||
| 84 | 91 | A member who has not keeps their place but cannot use the workspace: its | |
| 85 | 92 | private repositories, pages and API answer as if they were not a member, | |
| ⋯ | |||
| 107 | 114 | ||
| 108 | 115 | - **OpenID Connect and OAuth 2.0** use the authorization code flow with | |
| 109 | 116 | 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. | |
| 112 | 121 | - **SAML** responses must be signed, the assertion or the whole response, | |
| 113 | 122 | with RSA and SHA-256, SHA-384 or SHA-512, by one of the certificates you | |
| 114 | 123 | saved. The response must answer the request g1t sent (sign-ins started | |
| 115 | 124 | at your provider are refused), be addressed to the workspace's assertion | |
| 116 | 125 | 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. | |
| 119 | 130 | ||
| 120 | 131 | ## If the plan ends | |
| 121 | 132 | ||
| ⋯ | |||
| 192 | 203 | | Supported | Create, update (PUT and PATCH), deactivate and delete users; filtering by `userName`, `externalId`, `id` or `emails.value` | | |
| 193 | 204 | ||
| 194 | 205 | 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 | |
| 196 | 211 | token** replaces the old one at once; **Turn off SCIM** forgets who was | |
| 197 | 212 | assigned and removes nobody. | |
| 198 | 213 | ||
Binary or large file; its contents are not shown.
| 1 | − | import { Link, data, redirect } from "react-router"; | |
| 1 | + | import { Form, Link, data, redirect } from "react-router"; | |
| 2 | 2 | ||
| 3 | − | import { ssoAddresses } from "@g1t/contracts"; | |
| 3 | + | import { type User, ssoAddresses } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Route } from "./+types/auth-sso"; | |
| 6 | 6 | import { AuthCard } from "../components/auth-card"; | |
| 7 | + | import { SubmitButton } from "../components/ui"; | |
| 7 | 8 | import { cookie } from "../lib/github"; | |
| 8 | 9 | import { page } from "../lib/meta"; | |
| 10 | + | import { safeNext } from "../lib/next"; | |
| 9 | 11 | import { SSO_STATE_COOKIE } from "../lib/sso"; | |
| 10 | 12 | 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"; | |
| 12 | 14 | ||
| 13 | 15 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 14 | 16 | return page(args, { title: `Single sign-on · ${params.owner} · g1t` }); | |
| 15 | 17 | } | |
| 16 | 18 | ||
| 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 | + | ||
| 17 | 24 | /** | |
| 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. | |
| 22 | 28 | */ | |
| 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) { | |
| 26 | 30 | 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." }; | |
| 30 | 32 | 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 }); | |
| 32 | 34 | const addresses = ssoAddresses(new URL(request.url).origin, slug); | |
| 33 | 35 | const started = await sso.start({ | |
| 34 | 36 | user, | |
| ⋯ | |||
| 36 | 38 | slug, | |
| 37 | 39 | redirect_uri: summary.protocol === "saml" ? addresses.acsUrl : addresses.callbackUrl, | |
| 38 | 40 | entity_id: addresses.entityId, | |
| 39 | − | next: nextPath(request), | |
| 41 | + | next, | |
| 40 | 42 | }); | |
| 41 | 43 | if (!started.ok) return { slug, error: started.error.message }; | |
| 42 | 44 | const headers = started.value.state ? { "set-cookie": cookie(SSO_STATE_COOKIE, started.value.state, 600) } : undefined; | |
| 43 | 45 | throw redirect(started.value.url, { headers }); | |
| 44 | 46 | } | |
| 45 | 47 | ||
| 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}'s identity provider links your account there to this one and, if {loaderData.slug} lets its | |
| 86 | + | provider'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}'s sign-in | |
| 92 | + | </SubmitButton> | |
| 93 | + | </Form> | |
| 94 | + | </AuthCard> | |
| 95 | + | ); | |
| 48 | 96 | } | |
| 49 | 97 | ||
| 50 | 98 | /** What a single sign-on that did not work says, with the ways on. */ | |
| 78 | 78 | email_claim: text("email_claim"), | |
| 79 | 79 | idp_entity_id: text("idp_entity_id"), | |
| 80 | 80 | 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), | |
| 81 | 85 | }; | |
| 82 | 86 | const saved = await sso.save(user, slug, { | |
| 83 | 87 | protocol, | |
| ⋯ | |||
| 424 | 428 | </> | |
| 425 | 429 | )} | |
| 426 | 430 | ||
| 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}> | |
| 428 | 432 | 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. | |
| 430 | 434 | </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> | |
| 431 | 441 | </fieldset> | |
| 432 | 442 | <div className="flex flex-wrap items-center gap-3"> | |
| 433 | 443 | <SubmitButton match={{ intent: "save" }} pending="Checking…" disabled={disabled}> | |
| 108 | 108 | /// certificate is rotated. | |
| 109 | 109 | #[serde(default, skip_serializing_if = "Vec::is_empty")] | |
| 110 | 110 | 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>, | |
| 111 | 116 | } | |
| 112 | 117 | ||
| 113 | 118 | /// A SAML signing certificate, as shown. | |
| ⋯ | |||
| 257 | 262 | /// SAML: certificates as pasted (PEM). | |
| 258 | 263 | #[serde(default)] | |
| 259 | 264 | 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)] | |
| 261 | 268 | pub allow_join: bool, | |
| 262 | 269 | #[serde(default)] | |
| 263 | 270 | pub surface: Option<Surface>, | |
| 264 | − | } | |
| 265 | − | ||
| 266 | − | fn yes() -> bool { | |
| 267 | − | true | |
| 268 | 271 | } | |
| 269 | 272 | ||
| 270 | 273 | /// `set_sso_required`: whether members must sign in through the provider. | |
| 6417 | 6417 | Field { name: "idp_entity_id", kind: "string", required: false, values: &[] }, | |
| 6418 | 6418 | Field { name: "idp_sso_url", kind: "string", required: false, values: &[] }, | |
| 6419 | 6419 | Field { name: "issuer", kind: "string", required: false, values: &[] }, | |
| 6420 | + | Field { name: "join_domains", kind: "array", required: false, values: &[] }, | |
| 6420 | 6421 | Field { name: "protocol", kind: "string", required: true, values: &["oidc", "oauth2", "saml"] }, | |
| 6421 | 6422 | Field { name: "scopes", kind: "string", required: false, values: &[] }, | |
| 6422 | 6423 | Field { name: "subject_claim", kind: "string", required: false, values: &[] }, |
| 24 | 24 | idp_entity_id?: string | null; | |
| 25 | 25 | idp_sso_url?: string | null; | |
| 26 | 26 | certificates?: string[]; | |
| 27 | + | /** Who may join through it: only addresses the provider vouches for at these domains. Empty: anyone. */ | |
| 28 | + | join_domains?: string[]; | |
| 27 | 29 | }; | |
| 28 | 30 | ||
| 29 | 31 | export type SsoConnection = { |
| 25 | 25 | roxmltree = "0.20" | |
| 26 | 26 | miniz_oxide = "0.9" | |
| 27 | 27 | rsa = { version = "0.9", default-features = false, features = ["std", "sha2"] } | |
| 28 | + | p256 = { version = "0.13", default-features = false, features = ["ecdsa"] } | |
| 28 | 29 | ||
| 29 | 30 | # wasm-opt at -O1: about the same gzipped size as -O in a tenth of the | |
| 30 | 31 | # time. |
| 149 | 149 | /// Attaches the workspaces a user belongs to, so that any service can | |
| 150 | 150 | /// authorize them without asking again. | |
| 151 | 151 | 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 | |
| 153 | 153 | } | |
| 154 | 154 | ||
| 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> { | |
| 159 | 159 | let Some(mut user) = user else { | |
| 160 | 160 | return Ok(None); | |
| 161 | 161 | }; | |
| ⋯ | |||
| 176 | 176 | user.grants = within.grants; | |
| 177 | 177 | user.held = within.held; | |
| 178 | 178 | 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?; | |
| 180 | 180 | sso::apply_sso_holds(&mut user, &held); | |
| 181 | 181 | } | |
| 182 | 182 | Ok(Some(user)) | |
| ⋯ | |||
| 401 | 401 | { | |
| 402 | 402 | return Ok(None); | |
| 403 | 403 | } | |
| 404 | − | self.with_workspaces(user).await | |
| 404 | + | self.with_workspaces_via(user, sso::Via::Password).await | |
| 405 | 405 | } | |
| 406 | 406 | ||
| 407 | 407 | async fn register(&self, a: RegisterArgs) -> Result<Outcome<SignedIn>> { | |
| ⋯ | |||
| 563 | 563 | &session, | |
| 564 | 564 | ) | |
| 565 | 565 | .await?; | |
| 566 | − | self.with_workspaces_via(user, Some(&session)).await | |
| 566 | + | self.with_workspaces_via(user, sso::Via::Session(&session)).await | |
| 567 | 567 | } | |
| 568 | 568 | ||
| 569 | 569 | async fn user_for_git_credentials(&self, a: GitCredentialsArgs) -> Result<Viewer> { | |
| 33 | 33 | mod saml; | |
| 34 | 34 | #[path = "sso/scim.rs"] | |
| 35 | 35 | mod scim; | |
| 36 | + | #[path = "sso/jwt.rs"] | |
| 37 | + | mod jwt; | |
| 36 | 38 | #[path = "sso/provisioning.rs"] | |
| 37 | 39 | mod provisioning; | |
| 38 | 40 | ||
| ⋯ | |||
| 135 | 137 | next: String, | |
| 136 | 138 | } | |
| 137 | 139 | ||
| 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 | + | ||
| 138 | 216 | /// Which provider a connection is: changing it unlinks everyone. | |
| 139 | 217 | fn provider_of(protocol: SsoProtocol, config: &SsoConfig) -> String { | |
| 140 | 218 | let at = match protocol { | |
| ⋯ | |||
| 165 | 243 | /// own fields are kept. | |
| 166 | 244 | fn checked_config(protocol: SsoProtocol, given: &SsoConfig, certificates: Vec<String>) -> std::result::Result<SsoConfig, String> { | |
| 167 | 245 | 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 { | |
| 169 | 248 | SsoProtocol::Oidc => SsoConfig { | |
| 170 | 249 | issuer: Some(https_field(&given.issuer, "issuer")?), | |
| 171 | 250 | client_id: Some(client_id()?), | |
| ⋯ | |||
| 193 | 272 | ..SsoConfig::default() | |
| 194 | 273 | } | |
| 195 | 274 | } | |
| 196 | − | }) | |
| 275 | + | }; | |
| 276 | + | Ok(SsoConfig { join_domains, ..config }) | |
| 197 | 277 | } | |
| 198 | 278 | ||
| 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 | + | ||
| 199 | 290 | /// Which of a person's memberships a workspace's single sign-on holds out: | |
| 200 | 291 | /// those that require it and they have not met, never as an owner. | |
| 201 | 292 | pub fn held_by_sso(required: &[(String, Role)], met: &HashSet<String>) -> Vec<String> { | |
| ⋯ | |||
| 549 | 640 | return Ok(Outcome::Ok(false)); | |
| 550 | 641 | }; | |
| 551 | 642 | 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. | |
| 553 | 645 | 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 | + | ]) | |
| 557 | 654 | .await?; | |
| 558 | 655 | } | |
| 559 | 656 | self.audit_workspace( | |
| ⋯ | |||
| 679 | 776 | token: config.token_url.clone().unwrap_or_default(), | |
| 680 | 777 | userinfo: config.userinfo_url.clone(), | |
| 681 | 778 | issuer: None, | |
| 779 | + | jwks: None, | |
| 682 | 780 | basic: false, | |
| 683 | 781 | }), | |
| 684 | 782 | } | |
| ⋯ | |||
| 735 | 833 | .await?; | |
| 736 | 834 | if protocol == SsoProtocol::Oidc { | |
| 737 | 835 | 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?; | |
| 738 | 837 | let claims = oauth::id_token_claims( | |
| 739 | 838 | id_token, | |
| 839 | + | &keys, | |
| 740 | 840 | endpoints.issuer.as_deref().unwrap_or_default(), | |
| 741 | 841 | client_id, | |
| 742 | 842 | state.nonce.as_deref().unwrap_or_default(), | |
| 743 | 843 | now_ms() / 1000, | |
| 744 | 844 | )?; | |
| 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) | |
| 748 | 847 | } else { | |
| 749 | 848 | let access = tokens["access_token"].as_str().ok_or("The identity provider sent no access token.")?; | |
| 750 | 849 | let info = oauth::userinfo(endpoints.userinfo.as_deref().unwrap_or_default(), access).await?; | |
| ⋯ | |||
| 753 | 852 | None => oauth::claim(&info, "sub").or_else(|| oauth::claim(&info, "id")), | |
| 754 | 853 | } | |
| 755 | 854 | .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. | |
| 756 | 856 | let email = oauth::claim(&info, config.email_claim.as_deref().unwrap_or("email")); | |
| 757 | − | Ok((subject, email)) | |
| 857 | + | Ok(Said::new(subject, email, false)) | |
| 758 | 858 | } | |
| 759 | 859 | }; | |
| 760 | 860 | 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, | |
| 762 | 862 | Err(why) => Ok(Outcome::fail(FailureCode::Invalid, why)), | |
| 763 | 863 | } | |
| 764 | 864 | } | |
| ⋯ | |||
| 785 | 885 | now_ms: now_ms(), | |
| 786 | 886 | }; | |
| 787 | 887 | 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, | |
| 789 | 890 | Err(why) => Ok(Outcome::fail(FailureCode::Invalid, why)), | |
| 790 | 891 | } | |
| 791 | 892 | } | |
| 792 | 893 | ||
| 793 | 894 | /// 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>> { | |
| 796 | 898 | let slug = slug.trim().to_lowercase(); | |
| 797 | 899 | let user_id = state.user_id.as_str(); | |
| 900 | + | let subject = said.subject.as_str(); | |
| 798 | 901 | let Some(person) = self.sso_person(user_id).await? else { | |
| 799 | 902 | return Ok(Outcome::fail(FailureCode::Unauthenticated, "Sign in to g1t again.")); | |
| 800 | 903 | }; | |
| ⋯ | |||
| 810 | 913 | .all() | |
| 811 | 914 | .await? | |
| 812 | 915 | .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). | |
| 817 | 930 | let provisioned = match self.scim_on(&row.workspace_id).await? { | |
| 818 | 931 | 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 => { | |
| 821 | 934 | return Ok(Outcome::fail( | |
| 822 | 935 | FailureCode::Conflict, | |
| 823 | 936 | format!("That account at {slug}'s identity provider is linked to another g1t account. An owner of {slug} can unlink it."), | |
| 824 | 937 | )); | |
| 825 | 938 | } | |
| 826 | − | Some((id, _)) => Some(id), | |
| 827 | − | None => { | |
| 939 | + | provisioning::Match::One(id, _) => Some(id), | |
| 940 | + | provisioning::Match::Many => { | |
| 828 | 941 | 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( | |
| 829 | 948 | FailureCode::Forbidden, | |
| 830 | 949 | format!("Your account at {slug}'s identity provider is not assigned to g1t. Ask whoever runs it for {slug} to assign you."), | |
| 831 | 950 | )); | |
| 832 | 951 | } | |
| 833 | 952 | }, | |
| 834 | 953 | }; | |
| 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 | + | } | |
| 846 | 973 | } | |
| 974 | + | // Refusals done: write. | |
| 975 | + | let now = rfc3339(now_ms()); | |
| 976 | + | let email_value = said.email.as_deref().map_or(JsValue::NULL, Into::into); | |
| 847 | 977 | if linked.is_empty() { | |
| 848 | 978 | self.db | |
| 849 | 979 | .prepare( | |
| ⋯ | |||
| 864 | 994 | } | |
| 865 | 995 | if let Some(id) = &provisioned { | |
| 866 | 996 | 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()])? | |
| 869 | 999 | .run() | |
| 870 | 1000 | .await?; | |
| 871 | 1001 | } | |
| 872 | − | // Not a member yet: the provider vouches for them, if the workspace | |
| 873 | − | // lets it, within its policy (security.rs). | |
| 874 | 1002 | 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 { | |
| 885 | 1004 | joined = self | |
| 886 | 1005 | .db | |
| 887 | 1006 | .prepare( | |
| ⋯ | |||
| 936 | 1055 | } | |
| 937 | 1056 | ||
| 938 | 1057 | /// 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>> { | |
| 942 | 1064 | // g1t's own agent is never held: it is no one's to sign in. | |
| 943 | 1065 | if required.iter().all(|(_, role)| *role == Role::Owner) || crate::paid::is_g1t(&user.username) { | |
| 944 | 1066 | return Ok(Vec::new()); | |
| ⋯ | |||
| 947 | 1069 | struct Slug { | |
| 948 | 1070 | slug: String, | |
| 949 | 1071 | } | |
| 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) => { | |
| 952 | 1078 | self.db | |
| 953 | 1079 | .prepare(format!( | |
| 954 | 1080 | "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}" | |
| 956 | 1082 | )) | |
| 957 | − | .bind(&[session.into()])? | |
| 1083 | + | .bind(&[session.into(), user.id.as_str().into()])? | |
| 958 | 1084 | .all() | |
| 959 | 1085 | .await? | |
| 960 | 1086 | } | |
| 961 | − | None => { | |
| 1087 | + | Via::Credential => { | |
| 962 | 1088 | self.db | |
| 963 | − | .prepare( | |
| 1089 | + | .prepare(format!( | |
| 964 | 1090 | "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()])? | |
| 968 | 1094 | .all() | |
| 969 | 1095 | .await? | |
| 970 | 1096 | } | |
| ⋯ | |||
| 1046 | 1172 | } | |
| 1047 | 1173 | ||
| 1048 | 1174 | #[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] | |
| 1049 | 1210 | fn another_provider_is_told_apart_from_a_changed_setting() { | |
| 1050 | 1211 | let a = SsoConfig { issuer: Some("https://acme.okta.com/".into()), client_id: Some("one".into()), ..SsoConfig::default() }; | |
| 1051 | 1212 | let b = SsoConfig { issuer: Some("https://acme.okta.com".into()), client_id: Some("two".into()), ..SsoConfig::default() }; | |
| 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 | + | } |
| 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 | + | } |
| 1 | 1 | //! OpenID Connect and OAuth 2.0 for single sign-on: the authorization code | |
| 2 | 2 | //! flow, with PKCE (S256) and, for OpenID Connect, a nonce. | |
| 3 | 3 | //! | |
| 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 | |
| 10 | 9 | //! has no ID token: the person's id and address are claims of the userinfo | |
| 11 | 10 | //! endpoint's answer, named in the connection. | |
| 12 | 11 | ||
| 13 | 12 | use base64::Engine; | |
| 14 | − | use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; | |
| 13 | + | use base64::engine::general_purpose::STANDARD; | |
| 15 | 14 | use serde_json::Value; | |
| 16 | 15 | use worker::{Fetch, Headers, Method, Request, RequestInit, Url}; | |
| 17 | 16 | ||
| ⋯ | |||
| 27 | 26 | pub userinfo: Option<String>, | |
| 28 | 27 | /// OpenID Connect: the issuer, as its discovery document says it. | |
| 29 | 28 | pub issuer: Option<String>, | |
| 29 | + | /// OpenID Connect: where the issuer publishes its signing keys. | |
| 30 | + | pub jwks: Option<String>, | |
| 30 | 31 | /// Whether the client authenticates with HTTP Basic (else in the form). | |
| 31 | 32 | pub basic: bool, | |
| 32 | 33 | } | |
| ⋯ | |||
| 46 | 47 | let (Some(authorize), Some(token)) = (endpoint("authorization_endpoint"), endpoint("token_endpoint")) else { | |
| 47 | 48 | return Err("The provider's discovery document has no https authorization and token endpoints.".to_owned()); | |
| 48 | 49 | }; | |
| 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 | + | }; | |
| 49 | 53 | let methods: Vec<&str> = document["token_endpoint_auth_methods_supported"] | |
| 50 | 54 | .as_array() | |
| 51 | 55 | .map(|methods| methods.iter().filter_map(Value::as_str).collect()) | |
| ⋯ | |||
| 55 | 59 | token, | |
| 56 | 60 | userinfo: endpoint("userinfo_endpoint"), | |
| 57 | 61 | issuer: Some(said.to_owned()), | |
| 62 | + | jwks: Some(jwks), | |
| 58 | 63 | // Basic is the default when the provider lists none. | |
| 59 | 64 | basic: methods.is_empty() || methods.contains(&"client_secret_basic"), | |
| 60 | 65 | }) | |
| ⋯ | |||
| 73 | 78 | endpoints_from(&document, issuer) | |
| 74 | 79 | } | |
| 75 | 80 | ||
| 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 | + | ||
| 76 | 90 | /// The scopes to ask for: as set, always with `openid` for OpenID Connect. | |
| 77 | 91 | pub fn scopes(set: Option<&str>, openid: bool) -> String { | |
| 78 | 92 | let set = set.map(str::trim).filter(|s| !s.is_empty()); | |
| ⋯ | |||
| 190 | 204 | Ok(answer) | |
| 191 | 205 | } | |
| 192 | 206 | ||
| 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 { | |
| 203 | 220 | return Err(format!("The ID token was issued by {:?}, not {issuer:?}.", claims["iss"].as_str().unwrap_or_default())); | |
| 204 | 221 | } | |
| 205 | 222 | let audiences: Vec<&str> = match &claims["aud"] { | |
| ⋯ | |||
| 213 | 230 | if claims["exp"].as_u64().is_none_or(|exp| exp + SKEW_SECONDS <= now_seconds) { | |
| 214 | 231 | return Err("The ID token has expired. Start again.".to_owned()); | |
| 215 | 232 | } | |
| 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 | + | } | |
| 216 | 236 | if claims["nonce"].as_str() != Some(nonce) { | |
| 217 | 237 | return Err("The ID token answers another sign-in. Start again.".to_owned()); | |
| 218 | 238 | } | |
| ⋯ | |||
| 238 | 258 | use super::*; | |
| 239 | 259 | use serde_json::json; | |
| 240 | 260 | ||
| 241 | − | fn token(claims: Value) -> String { | |
| 242 | − | format!("e30.{}.c2ln", URL_SAFE_NO_PAD.encode(claims.to_string())) | |
| 243 | − | } | |
| 244 | − | ||
| 245 | 261 | #[test] | |
| 246 | 262 | fn discovery_must_name_the_issuer_and_https_endpoints() { | |
| 247 | 263 | let document = json!({ | |
| ⋯ | |||
| 249 | 265 | "authorization_endpoint": "https://acme.okta.com/oauth2/v1/authorize", | |
| 250 | 266 | "token_endpoint": "https://acme.okta.com/oauth2/v1/token", | |
| 251 | 267 | "userinfo_endpoint": "https://acme.okta.com/oauth2/v1/userinfo", | |
| 268 | + | "jwks_uri": "https://acme.okta.com/oauth2/v1/keys", | |
| 252 | 269 | "token_endpoint_auth_methods_supported": ["client_secret_post"], | |
| 253 | 270 | }); | |
| 254 | 271 | let found = endpoints_from(&document, "https://acme.okta.com/").unwrap(); | |
| ⋯ | |||
| 257 | 274 | assert!(endpoints_from(&document, "https://globex.okta.com").unwrap_err().contains("names the issuer")); | |
| 258 | 275 | let plain = json!({ "issuer": "https://a.test", "authorization_endpoint": "http://a.test/auth", "token_endpoint": "https://a.test/token" }); | |
| 259 | 276 | 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" }); | |
| 261 | 278 | 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")); | |
| 262 | 281 | } | |
| 263 | 282 | ||
| 264 | 283 | #[test] | |
| ⋯ | |||
| 288 | 307 | fn an_id_token_must_be_for_this_sign_in() { | |
| 289 | 308 | let now = 1_800_000_000; | |
| 290 | 309 | 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(); | |
| 292 | 311 | assert_eq!(claim(&claims, "sub").as_deref(), Some("u1")); | |
| 293 | 312 | let with = |field: &str, value: Value| { | |
| 294 | 313 | let mut claims = good.clone(); | |
| 295 | 314 | 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) | |
| 297 | 316 | }; | |
| 298 | 317 | assert!(with("iss", json!("https://evil.test")).unwrap_err().contains("issued by")); | |
| 299 | 318 | assert!(with("aud", json!("other")).unwrap_err().contains("client id")); | |
| 300 | 319 | assert!(with("aud", json!(["other", "client"])).is_ok()); | |
| 301 | 320 | assert!(with("exp", json!(now - 600)).unwrap_err().contains("expired")); | |
| 302 | 321 | 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()); | |
| 304 | 339 | } | |
| 305 | 340 | ||
| 306 | 341 | #[test] | |
| 41 | 41 | slug: &'a str, | |
| 42 | 42 | connection_id: &'a str, | |
| 43 | 43 | 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 | + | } | |
| 44 | 54 | } | |
| 45 | 55 | ||
| 46 | 56 | const SELECT_ROW: &str = "SELECT id, resource, user_id, created_at, updated_at FROM scim_users"; | |
| 47 | 57 | ||
| 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 | + | ||
| 48 | 67 | impl Identity { | |
| 49 | 68 | pub(super) async fn scim_on(&self, workspace_id: &str) -> Result<bool> { | |
| 50 | 69 | Ok(self | |
| ⋯ | |||
| 114 | 133 | })) | |
| 115 | 134 | } | |
| 116 | 135 | ||
| 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> { | |
| 120 | 139 | let rows = self | |
| 121 | 140 | .db | |
| 122 | 141 | .prepare(format!("{SELECT_ROW} WHERE workspace_id = ? AND active = 1")) | |
| ⋯ | |||
| 124 | 143 | .all() | |
| 125 | 144 | .await? | |
| 126 | 145 | .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 | + | }) | |
| 128 | 152 | } | |
| 129 | 153 | ||
| 130 | 154 | /// `enable_scim`: a new token, replacing any other. Owners, on a paid | |
| ⋯ | |||
| 218 | 242 | let Some(connection) = self.connection_where("workspace_id", &workspace_id).await? else { | |
| 219 | 243 | return Ok(scim_error(scim::ScimError::new(403, None, "Set up single sign-on for the workspace first."))); | |
| 220 | 244 | }; | |
| 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 | + | }); | |
| 224 | 249 | self.db | |
| 225 | 250 | .prepare("UPDATE scim_tokens SET last_used_at = ? WHERE workspace_id = ?") | |
| 226 | 251 | .bind(&[rfc3339(now_ms()).into(), workspace_id.as_str().into()])? | |
| ⋯ | |||
| 238 | 263 | let query: HashMap<String, String> = worker::Url::parse(&format!("https://q.invalid/?{}", a.query.as_deref().unwrap_or(""))) | |
| 239 | 264 | .map(|url| url.query_pairs().into_owned().collect()) | |
| 240 | 265 | .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() }; | |
| 242 | 267 | Ok(match (method.as_str(), segments.as_slice()) { | |
| 243 | 268 | ("GET", ["ServiceProviderConfig"]) => answer(200, Some(scim::service_provider_config("https://docs.g1t.sh/guides/single-sign-on/#scim"))), | |
| 244 | 269 | ("GET", ["ResourceTypes"]) => answer(200, Some(scim::resource_types(&a.base))), | |
| ⋯ | |||
| 323 | 348 | Ok(user) => user, | |
| 324 | 349 | Err(error) => return Ok(scim_error(error)), | |
| 325 | 350 | }; | |
| 351 | + | if user.active | |
| 352 | + | && let Some(refused) = at.plan_refusal() | |
| 353 | + | { | |
| 354 | + | return Ok(refused); | |
| 355 | + | } | |
| 326 | 356 | let key = scim::user_name_key(&user.user_name); | |
| 327 | 357 | if self.user_name_taken(at.workspace_id, &key, "").await? { | |
| 328 | 358 | return Ok(scim_error(scim::ScimError::new(409, Some("uniqueness"), format!("{} is provisioned already.", user.user_name)))); | |
| ⋯ | |||
| 373 | 403 | Ok(user) => user, | |
| 374 | 404 | Err(error) => return Ok(scim_error(error)), | |
| 375 | 405 | }; | |
| 406 | + | if user.active | |
| 407 | + | && let Some(refused) = at.plan_refusal() | |
| 408 | + | { | |
| 409 | + | return Ok(refused); | |
| 410 | + | } | |
| 376 | 411 | let key = scim::user_name_key(&user.user_name); | |
| 377 | 412 | if self.user_name_taken(at.workspace_id, &key, id).await? { | |
| 378 | 413 | return Ok(scim_error(scim::ScimError::new(409, Some("uniqueness"), format!("{} is provisioned already.", user.user_name)))); | |
| ⋯ | |||
| 417 | 452 | return Ok(scim_error(scim::ScimError::new(404, None, "No such user."))); | |
| 418 | 453 | }; | |
| 419 | 454 | 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 { | |
| 421 | 460 | self.deprovision(at, user_id, &user.user_name).await?; | |
| 422 | 461 | } | |
| 423 | 462 | self.db | |
| ⋯ | |||
| 430 | 469 | Ok(answer(204, None)) | |
| 431 | 470 | } | |
| 432 | 471 | ||
| 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. | |
| 434 | 475 | async fn sso_account_of(&self, connection_id: &str, user: &scim::ScimUser) -> Result<Option<String>> { | |
| 435 | 476 | #[derive(Deserialize)] | |
| 436 | 477 | struct Linked { | |
| 437 | 478 | user_id: String, | |
| 438 | 479 | subject: String, | |
| 439 | − | email: Option<String>, | |
| 440 | 480 | } | |
| 441 | 481 | let linked = self | |
| 442 | 482 | .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 = ?") | |
| 444 | 484 | .bind(&[connection_id.into()])? | |
| 445 | 485 | .all() | |
| 446 | 486 | .await? | |
| 447 | 487 | .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 | + | }) | |
| 449 | 493 | } | |
| 450 | 494 | ||
| 451 | 495 | /// Someone the provider unassigned leaves the workspace: their | |
| ⋯ | |||
| 482 | 526 | self.db | |
| 483 | 527 | .prepare("DELETE FROM sso_sessions WHERE workspace_id = ? AND session_id IN (SELECT id FROM sessions WHERE user_id = ?)") | |
| 484 | 528 | .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()])?, | |
| 485 | 537 | ]) | |
| 486 | 538 | .await?; | |
| 487 | 539 | if member.is_some() { | |
| 116 | 116 | return Err("The identity provider did not send a SAML response.".to_owned()); | |
| 117 | 117 | } | |
| 118 | 118 | unique_ids(&doc)?; | |
| 119 | + | plain(&doc)?; | |
| 119 | 120 | // Signed first: nothing else is read until it verifies. | |
| 120 | 121 | let assertion = only_child(response, ASSERTION, "Assertion")?; | |
| 121 | 122 | let Some(assertion) = assertion else { | |
| ⋯ | |||
| 124 | 125 | } | |
| 125 | 126 | return Err(status_message(response).unwrap_or_else(|| "The identity provider's response has no assertion.".to_owned())); | |
| 126 | 127 | }; | |
| 127 | − | let mut signed = false; | |
| 128 | + | let mut assertion_signed = false; | |
| 129 | + | let mut response_signed = false; | |
| 128 | 130 | if let Some(signature) = only_child(assertion, DSIG, "Signature")? { | |
| 129 | 131 | verify_signature(&xml, signature, expect.certificates)?; | |
| 130 | − | signed = true; | |
| 132 | + | assertion_signed = true; | |
| 131 | 133 | } | |
| 132 | 134 | if let Some(signature) = only_child(response, DSIG, "Signature")? { | |
| 133 | 135 | verify_signature(&xml, signature, expect.certificates)?; | |
| 134 | − | signed = true; | |
| 136 | + | response_signed = true; | |
| 135 | 137 | } | |
| 136 | − | if !signed { | |
| 138 | + | if !assertion_signed && !response_signed { | |
| 137 | 139 | return Err(NOT_SIGNED.to_owned()); | |
| 138 | 140 | } | |
| 139 | 141 | if let Some(message) = status_message(response) { | |
| 140 | 142 | return Err(message); | |
| 141 | 143 | } | |
| 142 | − | check_response(response, assertion, expect) | |
| 144 | + | check_response(response, assertion, expect, response_signed) | |
| 143 | 145 | } | |
| 144 | 146 | ||
| 145 | 147 | /// Why the provider said no, when its status is not success. | |
| ⋯ | |||
| 156 | 158 | }) | |
| 157 | 159 | } | |
| 158 | 160 | ||
| 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> { | |
| 160 | 165 | match response.attribute("InResponseTo") { | |
| 161 | 166 | Some(id) if id == expect.request_id => {} | |
| 162 | 167 | Some(_) => return Err("This response answers another sign-in. Start again.".to_owned()), | |
| ⋯ | |||
| 196 | 201 | .filter_map(|c| child(c, ASSERTION, "SubjectConfirmationData")) | |
| 197 | 202 | .any(|data| { | |
| 198 | 203 | 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 | + | } | |
| 200 | 208 | && data.attribute("NotBefore").is_none() | |
| 201 | 209 | && data.attribute("NotOnOrAfter").and_then(parse_time).is_some_and(|at| now < at + SKEW_MS) | |
| 202 | 210 | }); | |
| ⋯ | |||
| 253 | 261 | ||
| 254 | 262 | // --- The document ----------------------------------------------------------- | |
| 255 | 263 | ||
| 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. | |
| 256 | 266 | 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(()) | |
| 258 | 294 | } | |
| 259 | 295 | ||
| 260 | 296 | fn children<'a, 'i>(node: Node<'a, 'i>, ns: &'a str, name: &'a str) -> impl Iterator<Item = Node<'a, 'i>> + 'a { | |
| 194 | 194 | assert_eq!(doc.root_element().attribute("entityID"), Some(AUDIENCE)); | |
| 195 | 195 | assert!(xml.contains(&format!("Location=\"{ACS}\""))); | |
| 196 | 196 | } | |
| 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 | + | } |
| 15 | 15 | ||
| 16 | 16 | pub const USER_SCHEMA: &str = "urn:ietf:params:scim:schemas:core:2.0:User"; | |
| 17 | 17 | pub const LIST_SCHEMA: &str = "urn:ietf:params:scim:api:messages:2.0:ListResponse"; | |
| 18 | + | #[cfg(test)] | |
| 18 | 19 | pub const PATCH_SCHEMA: &str = "urn:ietf:params:scim:api:messages:2.0:PatchOp"; | |
| 19 | 20 | pub const ERROR_SCHEMA: &str = "urn:ietf:params:scim:api:messages:2.0:Error"; | |
| 20 | 21 | /// The most users one page lists. | |
| ⋯ | |||
| 246 | 247 | Ok(()) | |
| 247 | 248 | } | |
| 248 | 249 | ||
| 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())); | |
| 254 | 257 | same(&user.user_name) | |
| 255 | 258 | || user.external_id.as_deref().is_some_and(same) | |
| 256 | − | || email.is_some_and(|email| { | |
| 259 | + | || trusted_email.is_some_and(|email| { | |
| 257 | 260 | 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) | |
| 259 | 262 | }) | |
| 260 | 263 | } | |
| 261 | 264 | ||
| ⋯ | |||
| 385 | 388 | } | |
| 386 | 389 | ||
| 387 | 390 | #[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() { | |
| 389 | 392 | 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"))); | |
| 395 | 406 | } | |
| 396 | 407 | ||
| 397 | 408 | #[test] | |
| 22 | 22 | ||
| 23 | 23 | /// Enough for a person and their teams; stops one account claiming names in | |
| 24 | 24 | /// bulk. | |
| 25 | − | const MAX_WORKSPACES_PER_USER: usize = 10; | |
| 25 | + | pub(crate) const MAX_WORKSPACES_PER_USER: usize = 10; | |
| 26 | 26 | ||
| 27 | 27 | const MAX_NAME_LENGTH: usize = 80; | |
| 28 | 28 | const MAX_DESCRIPTION_LENGTH: usize = 160; |
Comments
Sign in to comment.