Add workspace single sign-on on every paid plan
A workspace connects its own identity provider over OpenID Connect, OAuth 2.0 or SAML 2.0 at Workspace -> Access -> Single sign-on. Any paid plan has it at no extra price; a free workspace sees the page disabled with a way to start the plan. It never signs anyone in to g1t: people sign in as themselves, then through the workspace's provider, which links who they are there to their account (one each way). Owners test it by signing in through it, can let the provider's people join without an invitation, and can require it: members then use the workspace only from a session that went through the provider in the last day, or with tokens and keys once linked. Owners are never held out, so a broken provider cannot lock a workspace out. SAML responses are verified in identity (exclusive c14n, RSA-SHA2, one reference to the signed parent, unique IDs, no DTD, SP-initiated only) and checked against signxml- and libxml2-signed fixtures. The provider's cross-site POST is answered in the Worker ahead of React Router. SCIM is not part of this. Closes #56
- G1t-Session:
- ses_01m54fhewne5tbgqqh4n8f5fvz
| 671 | 671 | ||
| 672 | 672 | [[package]] | |
| 673 | 673 | name = "const-oid" | |
| 674 | + | version = "0.9.6" | |
| 675 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 676 | + | checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" | |
| 677 | + | ||
| 678 | + | [[package]] | |
| 679 | + | name = "const-oid" | |
| 674 | 680 | version = "0.10.2" | |
| 675 | 681 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 676 | 682 | checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" | |
| ⋯ | |||
| 901 | 907 | ||
| 902 | 908 | [[package]] | |
| 903 | 909 | name = "der" | |
| 910 | + | version = "0.7.10" | |
| 911 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 912 | + | checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" | |
| 913 | + | dependencies = [ | |
| 914 | + | "const-oid 0.9.6", | |
| 915 | + | "zeroize", | |
| 916 | + | ] | |
| 917 | + | ||
| 918 | + | [[package]] | |
| 919 | + | name = "der" | |
| 904 | 920 | version = "0.8.2" | |
| 905 | 921 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 906 | 922 | checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" | |
| 907 | 923 | dependencies = [ | |
| 908 | − | "const-oid", | |
| 924 | + | "const-oid 0.10.2", | |
| 909 | 925 | "pem-rfc7468", | |
| 910 | 926 | "zeroize", | |
| 911 | 927 | ] | |
| ⋯ | |||
| 932 | 948 | checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" | |
| 933 | 949 | dependencies = [ | |
| 934 | 950 | "block-buffer 0.10.4", | |
| 951 | + | "const-oid 0.9.6", | |
| 935 | 952 | "crypto-common 0.1.7", | |
| 936 | 953 | "subtle", | |
| 937 | 954 | ] | |
| ⋯ | |||
| 943 | 960 | checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" | |
| 944 | 961 | dependencies = [ | |
| 945 | 962 | "block-buffer 0.12.1", | |
| 946 | − | "const-oid", | |
| 963 | + | "const-oid 0.10.2", | |
| 947 | 964 | "crypto-common 0.2.2", | |
| 948 | 965 | "ctutils", | |
| 949 | 966 | ] | |
| ⋯ | |||
| 977 | 994 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 978 | 995 | checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" | |
| 979 | 996 | dependencies = [ | |
| 980 | − | "der", | |
| 997 | + | "der 0.8.2", | |
| 981 | 998 | "digest 0.11.3", | |
| 982 | 999 | "elliptic-curve", | |
| 983 | 1000 | "rfc6979", | |
| 984 | − | "signature", | |
| 985 | − | "spki", | |
| 1001 | + | "signature 3.0.0", | |
| 1002 | + | "spki 0.8.0", | |
| 986 | 1003 | "zeroize", | |
| 987 | 1004 | ] | |
| 988 | 1005 | ||
| ⋯ | |||
| 992 | 1009 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 993 | 1010 | checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" | |
| 994 | 1011 | dependencies = [ | |
| 995 | − | "pkcs8", | |
| 996 | − | "signature", | |
| 1012 | + | "pkcs8 0.11.0", | |
| 1013 | + | "signature 3.0.0", | |
| 997 | 1014 | ] | |
| 998 | 1015 | ||
| 999 | 1016 | [[package]] | |
| ⋯ | |||
| 1007 | 1024 | "rand_core 0.10.1", | |
| 1008 | 1025 | "serde", | |
| 1009 | 1026 | "sha2 0.11.0", | |
| 1010 | − | "signature", | |
| 1027 | + | "signature 3.0.0", | |
| 1011 | 1028 | "subtle", | |
| 1012 | 1029 | "zeroize", | |
| 1013 | 1030 | ] | |
| ⋯ | |||
| 1027 | 1044 | "hkdf", | |
| 1028 | 1045 | "hybrid-array", | |
| 1029 | 1046 | "pem-rfc7468", | |
| 1030 | − | "pkcs8", | |
| 1047 | + | "pkcs8 0.11.0", | |
| 1031 | 1048 | "rand_core 0.10.1", | |
| 1032 | 1049 | "sec1", | |
| 1033 | 1050 | "subtle", | |
| ⋯ | |||
| 1443 | 1460 | "getrandom 0.2.17", | |
| 1444 | 1461 | "hex", | |
| 1445 | 1462 | "hmac 0.12.1", | |
| 1463 | + | "miniz_oxide", | |
| 1446 | 1464 | "pbkdf2 0.12.2", | |
| 1465 | + | "roxmltree", | |
| 1466 | + | "rsa 0.9.10", | |
| 1447 | 1467 | "serde", | |
| 1448 | 1468 | "serde_json", | |
| 1449 | 1469 | "sha1 0.10.7", | |
| ⋯ | |||
| 2348 | 2368 | ] | |
| 2349 | 2369 | ||
| 2350 | 2370 | [[package]] | |
| 2371 | + | name = "lazy_static" | |
| 2372 | + | version = "1.5.1" | |
| 2373 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2374 | + | checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" | |
| 2375 | + | dependencies = [ | |
| 2376 | + | "spin", | |
| 2377 | + | ] | |
| 2378 | + | ||
| 2379 | + | [[package]] | |
| 2351 | 2380 | name = "libc" | |
| 2352 | 2381 | version = "0.2.189" | |
| 2353 | 2382 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 2466 | 2495 | "hybrid-array", | |
| 2467 | 2496 | "kem", | |
| 2468 | 2497 | "module-lattice", | |
| 2469 | − | "pkcs8", | |
| 2498 | + | "pkcs8 0.11.0", | |
| 2470 | 2499 | "rand_core 0.10.1", | |
| 2471 | 2500 | "sha3 0.11.0", | |
| 2472 | 2501 | ] | |
| ⋯ | |||
| 2532 | 2561 | dependencies = [ | |
| 2533 | 2562 | "num-integer", | |
| 2534 | 2563 | "num-traits", | |
| 2535 | − | "rand", | |
| 2564 | + | "rand 0.10.3", | |
| 2536 | 2565 | "rand_core 0.10.1", | |
| 2537 | 2566 | ] | |
| 2538 | 2567 | ||
| 2539 | 2568 | [[package]] | |
| 2569 | + | name = "num-bigint-dig" | |
| 2570 | + | version = "0.8.6" | |
| 2571 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2572 | + | checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" | |
| 2573 | + | dependencies = [ | |
| 2574 | + | "lazy_static", | |
| 2575 | + | "libm", | |
| 2576 | + | "num-integer", | |
| 2577 | + | "num-iter", | |
| 2578 | + | "num-traits", | |
| 2579 | + | "rand 0.8.8", | |
| 2580 | + | "smallvec", | |
| 2581 | + | "zeroize", | |
| 2582 | + | ] | |
| 2583 | + | ||
| 2584 | + | [[package]] | |
| 2540 | 2585 | name = "num-complex" | |
| 2541 | 2586 | version = "0.4.6" | |
| 2542 | 2587 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 2588 | 2633 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" | |
| 2589 | 2634 | dependencies = [ | |
| 2590 | 2635 | "autocfg", | |
| 2636 | + | "libm", | |
| 2591 | 2637 | ] | |
| 2592 | 2638 | ||
| 2593 | 2639 | [[package]] | |
| ⋯ | |||
| 2683 | 2729 | "delegate", | |
| 2684 | 2730 | "futures", | |
| 2685 | 2731 | "log", | |
| 2686 | − | "rand", | |
| 2732 | + | "rand 0.10.3", | |
| 2687 | 2733 | "sha2 0.11.0", | |
| 2688 | 2734 | "thiserror", | |
| 2689 | 2735 | "tokio", | |
| ⋯ | |||
| 2823 | 2869 | ||
| 2824 | 2870 | [[package]] | |
| 2825 | 2871 | name = "pkcs1" | |
| 2872 | + | version = "0.7.5" | |
| 2873 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2874 | + | checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" | |
| 2875 | + | dependencies = [ | |
| 2876 | + | "der 0.7.10", | |
| 2877 | + | "pkcs8 0.10.2", | |
| 2878 | + | "spki 0.7.3", | |
| 2879 | + | ] | |
| 2880 | + | ||
| 2881 | + | [[package]] | |
| 2882 | + | name = "pkcs1" | |
| 2826 | 2883 | version = "0.8.0-rc.4" | |
| 2827 | 2884 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2828 | 2885 | checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" | |
| 2829 | 2886 | dependencies = [ | |
| 2830 | − | "der", | |
| 2831 | − | "spki", | |
| 2887 | + | "der 0.8.2", | |
| 2888 | + | "spki 0.8.0", | |
| 2832 | 2889 | ] | |
| 2833 | 2890 | ||
| 2834 | 2891 | [[package]] | |
| ⋯ | |||
| 2840 | 2897 | "aes 0.9.3", | |
| 2841 | 2898 | "aes-gcm 0.11.1", | |
| 2842 | 2899 | "cbc", | |
| 2843 | − | "der", | |
| 2900 | + | "der 0.8.2", | |
| 2844 | 2901 | "pbkdf2 0.13.0", | |
| 2845 | 2902 | "rand_core 0.10.1", | |
| 2846 | 2903 | "scrypt", | |
| 2847 | 2904 | "sha2 0.11.0", | |
| 2848 | − | "spki", | |
| 2905 | + | "spki 0.8.0", | |
| 2849 | 2906 | ] | |
| 2850 | 2907 | ||
| 2851 | 2908 | [[package]] | |
| 2852 | 2909 | name = "pkcs8" | |
| 2910 | + | version = "0.10.2" | |
| 2911 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2912 | + | checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" | |
| 2913 | + | dependencies = [ | |
| 2914 | + | "der 0.7.10", | |
| 2915 | + | "spki 0.7.3", | |
| 2916 | + | ] | |
| 2917 | + | ||
| 2918 | + | [[package]] | |
| 2919 | + | name = "pkcs8" | |
| 2853 | 2920 | version = "0.11.0" | |
| 2854 | 2921 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2855 | 2922 | checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" | |
| 2856 | 2923 | dependencies = [ | |
| 2857 | − | "der", | |
| 2924 | + | "der 0.8.2", | |
| 2858 | 2925 | "pkcs5", | |
| 2859 | 2926 | "rand_core 0.10.1", | |
| 2860 | − | "spki", | |
| 2927 | + | "spki 0.8.0", | |
| 2861 | 2928 | ] | |
| 2862 | 2929 | ||
| 2863 | 2930 | [[package]] | |
| ⋯ | |||
| 2946 | 3013 | checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" | |
| 2947 | 3014 | ||
| 2948 | 3015 | [[package]] | |
| 3016 | + | name = "ppv-lite86" | |
| 3017 | + | version = "0.2.21" | |
| 3018 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3019 | + | checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" | |
| 3020 | + | dependencies = [ | |
| 3021 | + | "zerocopy", | |
| 3022 | + | ] | |
| 3023 | + | ||
| 3024 | + | [[package]] | |
| 2949 | 3025 | name = "primefield" | |
| 2950 | 3026 | version = "0.14.0" | |
| 2951 | 3027 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 3020 | 3096 | "bytes", | |
| 3021 | 3097 | "getrandom 0.4.3", | |
| 3022 | 3098 | "lru-slab", | |
| 3023 | − | "rand", | |
| 3099 | + | "rand 0.10.3", | |
| 3024 | 3100 | "rand_pcg", | |
| 3025 | 3101 | "ring", | |
| 3026 | 3102 | "rustc-hash", | |
| ⋯ | |||
| 3064 | 3140 | ||
| 3065 | 3141 | [[package]] | |
| 3066 | 3142 | name = "rand" | |
| 3143 | + | version = "0.8.8" | |
| 3144 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3145 | + | checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" | |
| 3146 | + | dependencies = [ | |
| 3147 | + | "rand_chacha", | |
| 3148 | + | "rand_core 0.6.4", | |
| 3149 | + | ] | |
| 3150 | + | ||
| 3151 | + | [[package]] | |
| 3152 | + | name = "rand" | |
| 3067 | 3153 | version = "0.10.3" | |
| 3068 | 3154 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3069 | 3155 | checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" | |
| ⋯ | |||
| 3074 | 3160 | ] | |
| 3075 | 3161 | ||
| 3076 | 3162 | [[package]] | |
| 3163 | + | name = "rand_chacha" | |
| 3164 | + | version = "0.3.1" | |
| 3165 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3166 | + | checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" | |
| 3167 | + | dependencies = [ | |
| 3168 | + | "ppv-lite86", | |
| 3169 | + | "rand_core 0.6.4", | |
| 3170 | + | ] | |
| 3171 | + | ||
| 3172 | + | [[package]] | |
| 3077 | 3173 | name = "rand_core" | |
| 3078 | 3174 | version = "0.6.4" | |
| 3079 | 3175 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 3222 | 3318 | ] | |
| 3223 | 3319 | ||
| 3224 | 3320 | [[package]] | |
| 3321 | + | name = "roxmltree" | |
| 3322 | + | version = "0.20.0" | |
| 3323 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3324 | + | checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" | |
| 3325 | + | ||
| 3326 | + | [[package]] | |
| 3225 | 3327 | name = "rpassword" | |
| 3226 | 3328 | version = "7.5.4" | |
| 3227 | 3329 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ | |||
| 3234 | 3336 | ||
| 3235 | 3337 | [[package]] | |
| 3236 | 3338 | name = "rsa" | |
| 3339 | + | version = "0.9.10" | |
| 3340 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3341 | + | checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" | |
| 3342 | + | dependencies = [ | |
| 3343 | + | "const-oid 0.9.6", | |
| 3344 | + | "digest 0.10.7", | |
| 3345 | + | "num-bigint-dig", | |
| 3346 | + | "num-integer", | |
| 3347 | + | "num-traits", | |
| 3348 | + | "pkcs1 0.7.5", | |
| 3349 | + | "pkcs8 0.10.2", | |
| 3350 | + | "rand_core 0.6.4", | |
| 3351 | + | "sha2 0.10.9", | |
| 3352 | + | "signature 2.2.0", | |
| 3353 | + | "spki 0.7.3", | |
| 3354 | + | "subtle", | |
| 3355 | + | "zeroize", | |
| 3356 | + | ] | |
| 3357 | + | ||
| 3358 | + | [[package]] | |
| 3359 | + | name = "rsa" | |
| 3237 | 3360 | version = "0.10.0-rc.18" | |
| 3238 | 3361 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3239 | 3362 | checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" | |
| 3240 | 3363 | dependencies = [ | |
| 3241 | − | "const-oid", | |
| 3364 | + | "const-oid 0.10.2", | |
| 3242 | 3365 | "crypto-bigint", | |
| 3243 | 3366 | "crypto-primes", | |
| 3244 | 3367 | "digest 0.11.3", | |
| 3245 | − | "pkcs1", | |
| 3246 | − | "pkcs8", | |
| 3368 | + | "pkcs1 0.8.0-rc.4", | |
| 3369 | + | "pkcs8 0.11.0", | |
| 3247 | 3370 | "rand_core 0.10.1", | |
| 3248 | 3371 | "sha2 0.11.0", | |
| 3249 | − | "signature", | |
| 3250 | − | "spki", | |
| 3372 | + | "signature 3.0.0", | |
| 3373 | + | "spki 0.8.0", | |
| 3251 | 3374 | "zeroize", | |
| 3252 | 3375 | ] | |
| 3253 | 3376 | ||
| ⋯ | |||
| 3294 | 3417 | "curve25519-dalek", | |
| 3295 | 3418 | "data-encoding", | |
| 3296 | 3419 | "delegate", | |
| 3297 | − | "der", | |
| 3420 | + | "der 0.8.2", | |
| 3298 | 3421 | "digest 0.11.3", | |
| 3299 | 3422 | "ecdsa", | |
| 3300 | 3423 | "ed25519-dalek", | |
| ⋯ | |||
| 3319 | 3442 | "p521", | |
| 3320 | 3443 | "pageant", | |
| 3321 | 3444 | "pbkdf2 0.13.0", | |
| 3322 | − | "pkcs1", | |
| 3445 | + | "pkcs1 0.8.0-rc.4", | |
| 3323 | 3446 | "pkcs5", | |
| 3324 | − | "pkcs8", | |
| 3447 | + | "pkcs8 0.11.0", | |
| 3325 | 3448 | "polyval 0.7.3", | |
| 3326 | − | "rand", | |
| 3449 | + | "rand 0.10.3", | |
| 3327 | 3450 | "rand_core 0.10.1", | |
| 3328 | − | "rsa", | |
| 3451 | + | "rsa 0.10.0-rc.18", | |
| 3329 | 3452 | "russh-cryptovec", | |
| 3330 | 3453 | "russh-util", | |
| 3331 | 3454 | "salsa20", | |
| ⋯ | |||
| 3334 | 3457 | "sha1 0.11.0", | |
| 3335 | 3458 | "sha2 0.11.0", | |
| 3336 | 3459 | "sha3 0.12.0", | |
| 3337 | − | "signature", | |
| 3338 | − | "spki", | |
| 3460 | + | "signature 3.0.0", | |
| 3461 | + | "spki 0.8.0", | |
| 3339 | 3462 | "ssh-encoding", | |
| 3340 | 3463 | "ssh-key", | |
| 3341 | 3464 | "subtle", | |
| ⋯ | |||
| 3563 | 3686 | dependencies = [ | |
| 3564 | 3687 | "base16ct", | |
| 3565 | 3688 | "ctutils", | |
| 3566 | − | "der", | |
| 3689 | + | "der 0.8.2", | |
| 3567 | 3690 | "hybrid-array", | |
| 3568 | 3691 | "subtle", | |
| 3569 | 3692 | "zeroize", | |
| ⋯ | |||
| 3829 | 3952 | ||
| 3830 | 3953 | [[package]] | |
| 3831 | 3954 | name = "signature" | |
| 3955 | + | version = "2.2.0" | |
| 3956 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3957 | + | checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" | |
| 3958 | + | dependencies = [ | |
| 3959 | + | "digest 0.10.7", | |
| 3960 | + | "rand_core 0.6.4", | |
| 3961 | + | ] | |
| 3962 | + | ||
| 3963 | + | [[package]] | |
| 3964 | + | name = "signature" | |
| 3832 | 3965 | version = "3.0.0" | |
| 3833 | 3966 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3834 | 3967 | checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" | |
| ⋯ | |||
| 3888 | 4021 | ] | |
| 3889 | 4022 | ||
| 3890 | 4023 | [[package]] | |
| 4024 | + | name = "spin" | |
| 4025 | + | version = "0.9.9" | |
| 4026 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4027 | + | checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" | |
| 4028 | + | ||
| 4029 | + | [[package]] | |
| 3891 | 4030 | name = "spki" | |
| 4031 | + | version = "0.7.3" | |
| 4032 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4033 | + | checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" | |
| 4034 | + | dependencies = [ | |
| 4035 | + | "base64ct", | |
| 4036 | + | "der 0.7.10", | |
| 4037 | + | ] | |
| 4038 | + | ||
| 4039 | + | [[package]] | |
| 4040 | + | name = "spki" | |
| 3892 | 4041 | version = "0.8.0" | |
| 3893 | 4042 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3894 | 4043 | checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" | |
| 3895 | 4044 | dependencies = [ | |
| 3896 | 4045 | "base64ct", | |
| 3897 | − | "der", | |
| 4046 | + | "der 0.8.2", | |
| 3898 | 4047 | ] | |
| 3899 | 4048 | ||
| 3900 | 4049 | [[package]] | |
| ⋯ | |||
| 3952 | 4101 | "p384", | |
| 3953 | 4102 | "p521", | |
| 3954 | 4103 | "rand_core 0.10.1", | |
| 3955 | − | "rsa", | |
| 4104 | + | "rsa 0.10.0-rc.18", | |
| 3956 | 4105 | "sec1", | |
| 3957 | 4106 | "sha1 0.11.0", | |
| 3958 | 4107 | "sha2 0.11.0", | |
| 3959 | − | "signature", | |
| 4108 | + | "signature 3.0.0", | |
| 3960 | 4109 | "ssh-cipher", | |
| 3961 | 4110 | "ssh-encoding", | |
| 3962 | 4111 | "zeroize", | |
| ⋯ | |||
| 4380 | 4529 | "http", | |
| 4381 | 4530 | "httparse", | |
| 4382 | 4531 | "log", | |
| 4383 | − | "rand", | |
| 4532 | + | "rand 0.10.3", | |
| 4384 | 4533 | "sha1 0.11.0", | |
| 4385 | 4534 | "thiserror", | |
| 4386 | 4535 | ] | |
| 194 | 194 | { label: 'Workspaces and tokens', slug: 'guides/workspaces' }, | |
| 195 | 195 | { label: 'Access and roles', slug: 'guides/access-and-roles' }, | |
| 196 | 196 | { label: 'Permissions', slug: 'guides/permissions' }, | |
| 197 | + | { label: 'Single sign-on', slug: 'guides/single-sign-on' }, | |
| 197 | 198 | { label: 'Teams', slug: 'guides/teams' }, | |
| 198 | 199 | { label: 'People and teams', slug: 'guides/people-and-teams' }, | |
| 199 | 200 | { label: 'Managing a repository', slug: 'guides/managing-repositories' }, |
| 1 | + | --- | |
| 2 | + | title: Single sign-on | |
| 3 | + | description: Connect a workspace to your identity provider over SAML 2.0, OpenID Connect or OAuth 2.0, let its people join, and require it of members. On every paid plan. | |
| 4 | + | --- | |
| 5 | + | ||
| 6 | + | A workspace can connect to its own identity provider, such as Okta, | |
| 7 | + | Microsoft Entra ID, Google Workspace, Auth0, OneLogin or JumpCloud, over | |
| 8 | + | **OpenID Connect**, **SAML 2.0** or plain **OAuth 2.0**. It comes with | |
| 9 | + | every paid plan at no extra price. A free workspace sees the page with | |
| 10 | + | everything turned off until an owner starts the plan. | |
| 11 | + | ||
| 12 | + | Single sign-on never signs anyone in to g1t. People sign in to g1t as | |
| 13 | + | themselves, with their password, GitHub and two-factor authentication as | |
| 14 | + | usual, then sign in through your provider to use the workspace. The | |
| 15 | + | first time they do, g1t links who they are at your provider to their g1t | |
| 16 | + | account. Their personal repositories and other workspaces are never your | |
| 17 | + | provider's to open. | |
| 18 | + | ||
| 19 | + | ## Set it up | |
| 20 | + | ||
| 21 | + | Owners only, at **Workspace → Access → Single sign-on**, | |
| 22 | + | `g1t.sh/<workspace>/-/sso`. | |
| 23 | + | ||
| 24 | + | 1. Choose the protocol. Use **OpenID Connect** when your provider offers | |
| 25 | + | it; **SAML 2.0** for providers set up for SAML apps; **OAuth 2.0** for | |
| 26 | + | one with neither. | |
| 27 | + | 2. In your provider, create an app with the addresses the page shows: | |
| 28 | + | ||
| 29 | + | | | | | |
| 30 | + | | --- | --- | | |
| 31 | + | | Redirect URI (OpenID Connect, OAuth 2.0) | `https://g1t.sh/auth/sso/<workspace>/callback` | | |
| 32 | + | | Entity id, audience (SAML) | `https://g1t.sh/auth/sso/<workspace>/metadata` | | |
| 33 | + | | Assertion consumer service URL, HTTP-POST (SAML) | `https://g1t.sh/auth/sso/<workspace>/saml` | | |
| 34 | + | | Metadata (SAML) | `https://g1t.sh/auth/sso/<workspace>/metadata` | | |
| 35 | + | ||
| 36 | + | 3. Copy what the provider gives you back into the page: | |
| 37 | + | ||
| 38 | + | - **OpenID Connect**: the issuer (where its | |
| 39 | + | `/.well-known/openid-configuration` lives), the client id and the | |
| 40 | + | client secret. Scopes default to `openid email profile`. | |
| 41 | + | - **SAML 2.0**: the provider's entity id (its issuer), its single | |
| 42 | + | sign-on URL, and its signing certificate (PEM). Paste more than one | |
| 43 | + | while you rotate certificates. | |
| 44 | + | - **OAuth 2.0**: the authorization, token and userinfo URLs, the client | |
| 45 | + | id and secret, the scopes, and which claims of the userinfo answer | |
| 46 | + | are the person's stable id (`sub`, then `id`, by default) and their | |
| 47 | + | address (`email`). | |
| 48 | + | ||
| 49 | + | 4. Choose **Set up single sign-on**. For OpenID Connect g1t reads the | |
| 50 | + | issuer's discovery document first, and says so if it cannot. | |
| 51 | + | ||
| 52 | + | The client secret is kept encrypted and never shown again; leave it empty | |
| 53 | + | when you save other changes to keep it. | |
| 54 | + | ||
| 55 | + | ## Test it | |
| 56 | + | ||
| 57 | + | Choose **Test sign-in**. g1t sends you to your provider and back, links | |
| 58 | + | your account, and marks the connection **Tested** with your name. You | |
| 59 | + | must have signed in through it yourself before you can require it of | |
| 60 | + | everyone. | |
| 61 | + | ||
| 62 | + | ## Let people join | |
| 63 | + | ||
| 64 | + | With **Let people from your provider join** on (the default), anyone your | |
| 65 | + | provider signs in becomes a member of the workspace when they first sign | |
| 66 | + | in through it, with no invitation: send them to | |
| 67 | + | `g1t.sh/auth/sso/<workspace>`. They need a g1t account with a confirmed | |
| 68 | + | email address first, and the workspace's other rules still apply, such as | |
| 69 | + | [requiring two-factor authentication](/guides/workspaces/#require-two-factor-authentication). | |
| 70 | + | Turn it off and only people who are already members can sign in through | |
| 71 | + | it. | |
| 72 | + | ||
| 73 | + | ## Require it | |
| 74 | + | ||
| 75 | + | Turn on **Require single sign-on** under **Test and require**. From then | |
| 76 | + | on a member uses the workspace only: | |
| 77 | + | ||
| 78 | + | - **On the site**, from a session that signed in through your provider in | |
| 79 | + | the last day. After a day they go through it again, which is usually | |
| 80 | + | instant: your provider remembers them. | |
| 81 | + | - **With an access token, SSH key or git credentials**, once they have | |
| 82 | + | linked their account. | |
| 83 | + | ||
| 84 | + | A member who has not keeps their place but cannot use the workspace: its | |
| 85 | + | private repositories, pages and API answer as if they were not a member, | |
| 86 | + | including repositories they have a role on directly, and every page shows | |
| 87 | + | a notice with a link to sign in through your provider. | |
| 88 | + | ||
| 89 | + | **Owners are never held out**, so a provider that breaks or a certificate | |
| 90 | + | that expires never locks you out of your own workspace. Outside | |
| 91 | + | collaborators are not asked: they are not your provider's people. | |
| 92 | + | ||
| 93 | + | ## Linked people | |
| 94 | + | ||
| 95 | + | The page lists everyone who has signed in through your provider, who they | |
| 96 | + | are there, and when they last did. **Unlink** someone to let them link | |
| 97 | + | another account at your provider; their sessions go through it again. | |
| 98 | + | Each account at your provider links to one g1t account, and each g1t | |
| 99 | + | account to one account at your provider. | |
| 100 | + | ||
| 101 | + | Changing the provider itself (the protocol, the issuer, or the SAML entity | |
| 102 | + | id) unlinks everyone and stops requiring it, until someone has signed in | |
| 103 | + | through the new one. Changing the client, the scopes or the certificate | |
| 104 | + | keeps everyone linked. | |
| 105 | + | ||
| 106 | + | ## What g1t checks | |
| 107 | + | ||
| 108 | + | - **OpenID Connect and OAuth 2.0** use the authorization code flow with | |
| 109 | + | PKCE. For OpenID Connect, the ID token must be issued by your issuer, for | |
| 110 | + | your client id, unexpired, and answer this sign-in's nonce. g1t receives | |
| 111 | + | it straight from your provider's token endpoint over TLS. | |
| 112 | + | - **SAML** responses must be signed, the assertion or the whole response, | |
| 113 | + | with RSA and SHA-256, SHA-384 or SHA-512, by one of the certificates you | |
| 114 | + | saved. The response must answer the request g1t sent (sign-ins started | |
| 115 | + | at your provider are refused), be addressed to the workspace's assertion | |
| 116 | + | consumer service, name g1t's entity id as the audience, come from your | |
| 117 | + | provider's entity id, and be in its validity window. Leave the assertion | |
| 118 | + | unencrypted: it reaches g1t over HTTPS. | |
| 119 | + | ||
| 120 | + | ## If the plan ends | |
| 121 | + | ||
| 122 | + | The settings stay. Members cannot sign in through your provider until the | |
| 123 | + | plan starts again, so a workspace that requires it holds its members out | |
| 124 | + | meanwhile; owners can still turn the requirement off. Turning it off and | |
| 125 | + | removing it never need a plan. | |
| 126 | + | ||
| 127 | + | ## Remove it | |
| 128 | + | ||
| 129 | + | **Danger zone → Remove** forgets your provider and who everyone is there, | |
| 130 | + | and stops requiring it. Members keep their place in the workspace. | |
| 131 | + | ||
| 132 | + | ## Audit log | |
| 133 | + | ||
| 134 | + | Recorded in the workspace's [audit log](/guides/audit-log/): | |
| 135 | + | `sso.created`, `sso.updated`, `sso.deleted`, `sso.required`, | |
| 136 | + | `sso.not_required`, `sso.identity_linked`, `sso.identity_unlinked`, and | |
| 137 | + | `member.joined_by_sso`. | |
| 138 | + | ||
| 139 | + | ## Not yet | |
| 140 | + | ||
| 141 | + | Provisioning and deprovisioning with SCIM is not available yet. Until it | |
| 142 | + | is, someone you remove at your provider keeps their g1t session for up to | |
| 143 | + | a day and their tokens until an owner removes them from the workspace or | |
| 144 | + | unlinks them. |
| 587 | 587 | `PATCH /workspaces/{workspace}`. Recorded as `workspace.two_factor_required` | |
| 588 | 588 | and `workspace.two_factor_not_required`. | |
| 589 | 589 | ||
| 590 | + | A workspace on a paid plan can also require | |
| 591 | + | [single sign-on](/guides/single-sign-on/) through its own identity | |
| 592 | + | provider. | |
| 593 | + | ||
| 590 | 594 | ## The workspace's page | |
| 591 | 595 | ||
| 592 | 596 | A workspace's own page, `g1t.sh/<workspace>`, has its icon, name, address | |
| ⋯ | |||
| 811 | 815 | | **Access** | **Members** | Members | Who is in the workspace and their roles, with search and filters; owners [invite people](#add-people), change roles, and see **Outside collaborators**. `-/members` | | |
| 812 | 816 | | | **Invitations** | Owners | Every invitation to join, filtered by where it stands; send one again, revoke it, copy its link. `-/members/invitations` | | |
| 813 | 817 | | | [**Permissions**](/guides/permissions/) | Owners | Who may do what, part by part: the base permission, repositories, teams and chat. `-/permissions` | | |
| 818 | + | | | [**Single sign-on**](/guides/single-sign-on/) | Owners | Your identity provider over SAML 2.0, OpenID Connect or OAuth 2.0, the people linked to it, and requiring it. Every paid plan. `-/sso` | | |
| 814 | 819 | | | [**Access tokens**](#workspace-access-tokens) | Members | The workspace's own tokens. Owners create and delete them. `-/tokens` | | |
| 815 | 820 | | | **Personal access tokens** | Owners | The workspace's rules for members' tokens, and approving them. `-/personal-access-tokens` | | |
| 816 | 821 | | **Money** | [**Usage**](/guides/usage-and-billing/#the-usage-page) | Members | What the workspace used, by product, project and day. `-/usage` | | |
| 5 | 5 | ||
| 6 | 6 | /** | |
| 7 | 7 | * The notice across the top for someone a workspace holds out until they | |
| 8 | − | * meet its policy, such as one that requires two-factor authentication: | |
| 9 | − | * they keep their place and cannot use it until they turn it on. | |
| 8 | + | * meet its policy, such as one that requires two-factor authentication or | |
| 9 | + | * single sign-on: they keep their place and cannot use it until they do. | |
| 10 | 10 | */ | |
| 11 | 11 | export function PolicyNotice({ held }: { held: PolicyHold[] }) { | |
| 12 | 12 | if (held.length === 0) return null; | |
| 13 | 13 | const twoFactor = held.some((hold) => hold.gap === "two_factor"); | |
| 14 | + | // Single sign-on is one workspace's at a time: the first that asks for it. | |
| 15 | + | const sso = held.find((hold) => hold.gap === "sso"); | |
| 14 | 16 | return ( | |
| 15 | 17 | <div | |
| 16 | 18 | role="alert" | |
| ⋯ | |||
| 18 | 20 | > | |
| 19 | 21 | <ShieldAlert size={15} aria-hidden="true" className="hidden shrink-0 text-warn sm:block" /> | |
| 20 | 22 | <span> | |
| 21 | − | {held.length === 1 | |
| 22 | − | ? held[0].reason | |
| 23 | − | : `${held.map((hold) => hold.slug).join(", ")} require two-factor authentication. Turn it on to use them again.`} | |
| 23 | + | {held.map((hold) => hold.reason).join(" ")} | |
| 24 | 24 | </span> | |
| 25 | 25 | {twoFactor && ( | |
| 26 | 26 | <Link to="/settings/two-factor" className="font-medium underline underline-offset-4"> | |
| 27 | 27 | Turn on two-factor authentication | |
| 28 | 28 | </Link> | |
| 29 | 29 | )} | |
| 30 | + | {sso && ( | |
| 31 | + | <Link to={`/auth/sso/${sso.slug}?next=${encodeURIComponent(`/${sso.slug}`)}`} className="font-medium underline underline-offset-4"> | |
| 32 | + | Sign in with {sso.slug}'s single sign-on | |
| 33 | + | </Link> | |
| 34 | + | )} | |
| 30 | 35 | </div> | |
| 31 | 36 | ); | |
| 32 | 37 | } | |
| 52 | 52 | if (STANDALONE.has(path)) return "standalone"; | |
| 53 | 53 | // Marketing pages are the site's front, whoever reads them: never inside the app's rail and sidebar. | |
| 54 | 54 | if (MARKETING.has(path)) return "public"; | |
| 55 | − | // An invite link, and signing in with GitHub, are the front door. | |
| 56 | − | if (path.startsWith("/invite/") || path === "/auth/github" || path.startsWith("/auth/github/")) return "standalone"; | |
| 55 | + | // An invite link, signing in with GitHub, and a workspace's single sign-on are the front door. | |
| 56 | + | if (path.startsWith("/invite/") || path === "/auth/github" || path.startsWith("/auth/github/") || path.startsWith("/auth/sso/")) { | |
| 57 | + | return "standalone"; | |
| 58 | + | } | |
| 57 | 59 | if (path === "/invitations" && viewer.signedIn && !viewer.workspace) return "standalone"; | |
| 58 | 60 | return viewer.signedIn ? "app" : "public"; | |
| 59 | 61 | } |
| 1 | + | import { env } from "cloudflare:workers"; | |
| 2 | + | ||
| 3 | + | import { ssoClient } from "@g1t/contracts"; | |
| 4 | + | ||
| 5 | + | import { instrumented } from "./perf.server"; | |
| 6 | + | ||
| 7 | + | /** A workspace's single sign-on, kept by the identity service. */ | |
| 8 | + | export const sso = ssoClient(instrumented("identity", env.IDENTITY)); |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { ssoAddresses } from "@g1t/contracts/sso"; | |
| 5 | + | ||
| 6 | + | import { SSO_ERROR_COOKIE, afterSso, errorCookie, readErrorCookie, samlPath } from "./sso.ts"; | |
| 7 | + | ||
| 8 | + | test("only a workspace's SAML address is answered before React Router", () => { | |
| 9 | + | assert.equal(samlPath("/auth/sso/acme/saml"), "acme"); | |
| 10 | + | assert.equal(samlPath("/auth/sso/Acme-Co/saml/"), "acme-co"); | |
| 11 | + | assert.equal(samlPath("/auth/sso/acme/callback"), null); | |
| 12 | + | assert.equal(samlPath("/auth/sso/acme/saml/extra"), null); | |
| 13 | + | assert.equal(samlPath("/auth/sso/-acme/saml"), null); | |
| 14 | + | assert.equal(samlPath("/acme/-/sso"), null); | |
| 15 | + | }); | |
| 16 | + | ||
| 17 | + | test("afterwards it goes on to the workspace, never off the site", () => { | |
| 18 | + | assert.equal(afterSso({ slug: "acme", next: "/" }), "/acme"); | |
| 19 | + | assert.equal(afterSso({ slug: "acme", next: "/acme/-/sso" }), "/acme/-/sso"); | |
| 20 | + | assert.equal(afterSso({ slug: "acme", next: "//evil.test" }), "/acme"); | |
| 21 | + | assert.equal(afterSso({ slug: "acme", next: "https://evil.test/" }), "/acme"); | |
| 22 | + | }); | |
| 23 | + | ||
| 24 | + | test("why a SAML sign-in failed survives the trip in a cookie", () => { | |
| 25 | + | const set = errorCookie("The assertion is not for g1t; set the audience = x."); | |
| 26 | + | assert.match(set, /HttpOnly; Secure; SameSite=Lax; Max-Age=60/); | |
| 27 | + | const value = set.split(";")[0]; | |
| 28 | + | assert.equal(readErrorCookie(`a=b; ${value}`), "The assertion is not for g1t; set the audience = x."); | |
| 29 | + | assert.equal(readErrorCookie(`${SSO_ERROR_COOKIE}=%E0%A4%A`), null); | |
| 30 | + | assert.equal(readErrorCookie(null), null); | |
| 31 | + | }); | |
| 32 | + | ||
| 33 | + | test("a workspace's provider is pointed at its own addresses", () => { | |
| 34 | + | assert.deepEqual(ssoAddresses("https://g1t.sh/", "Acme"), { | |
| 35 | + | entityId: "https://g1t.sh/auth/sso/acme/metadata", | |
| 36 | + | metadataUrl: "https://g1t.sh/auth/sso/acme/metadata", | |
| 37 | + | acsUrl: "https://g1t.sh/auth/sso/acme/saml", | |
| 38 | + | callbackUrl: "https://g1t.sh/auth/sso/acme/callback", | |
| 39 | + | }); | |
| 40 | + | }); |
| 1 | + | /** | |
| 2 | + | * The parts of a workspace's single sign-on that the site itself decides: | |
| 3 | + | * the cookies that bind a trip to the provider to this browser, where to | |
| 4 | + | * go afterwards, and the one address the Worker answers before React | |
| 5 | + | * Router (a SAML response, posted from the provider's site). | |
| 6 | + | */ | |
| 7 | + | ||
| 8 | + | import { safeNext } from "./next.ts"; | |
| 9 | + | ||
| 10 | + | /** The OAuth state of a trip to a workspace's provider, for ten minutes. */ | |
| 11 | + | export const SSO_STATE_COOKIE = "g1t_sso_state"; | |
| 12 | + | /** Why a SAML sign-in did not work, shown once on the page it lands on. */ | |
| 13 | + | export const SSO_ERROR_COOKIE = "g1t_sso_error"; | |
| 14 | + | ||
| 15 | + | /** The workspace a `POST /auth/sso/<slug>/saml` is for, or null. */ | |
| 16 | + | export function samlPath(pathname: string): string | null { | |
| 17 | + | const match = /^\/auth\/sso\/([a-z0-9](?:[a-z0-9-]{0,38}))\/saml\/?$/i.exec(pathname); | |
| 18 | + | return match ? match[1].toLowerCase() : null; | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | /** Where to go once signed in through a workspace's provider. */ | |
| 22 | + | export function afterSso(finished: { slug: string; next: string }): string { | |
| 23 | + | const next = safeNext(finished.next); | |
| 24 | + | return next === "/" ? `/${finished.slug}` : next; | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | /** A `Set-Cookie` value carrying `message`, for a minute: Lax, HttpOnly. */ | |
| 28 | + | export function errorCookie(message: string): string { | |
| 29 | + | const value = encodeURIComponent(message.slice(0, 600)); | |
| 30 | + | return `${SSO_ERROR_COOKIE}=${value}; Path=/auth/sso; HttpOnly; Secure; SameSite=Lax; Max-Age=60`; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | /** Clears that cookie. */ | |
| 34 | + | export function clearErrorCookie(): string { | |
| 35 | + | return `${SSO_ERROR_COOKIE}=; Path=/auth/sso; HttpOnly; Secure; SameSite=Lax; Max-Age=0`; | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /** The message in that cookie, or null. */ | |
| 39 | + | export function readErrorCookie(header: string | null): string | null { | |
| 40 | + | for (const part of (header ?? "").split(";")) { | |
| 41 | + | const [key, ...rest] = part.trim().split("="); | |
| 42 | + | if (key !== SSO_ERROR_COOKIE) continue; | |
| 43 | + | try { | |
| 44 | + | return decodeURIComponent(rest.join("=")) || null; | |
| 45 | + | } catch { | |
| 46 | + | return null; | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | return null; | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | /** How each protocol is named on the page. */ | |
| 53 | + | export const PROTOCOL_NAMES = { oidc: "OpenID Connect", oauth2: "OAuth 2.0", saml: "SAML 2.0" } as const; |
| 74 | 74 | // An invite to a workspace is how someone without one gets one, and an | |
| 75 | 75 | // invitation to a repository is answered before anything else. | |
| 76 | 76 | path.startsWith("/invite/") || | |
| 77 | + | // So is joining one through its single sign-on. | |
| 78 | + | path.startsWith("/auth/sso/") || | |
| 77 | 79 | /^\/[^/]+\/[^/]+\/invitations$/.test(path) | |
| 78 | 80 | ) { | |
| 79 | 81 | return null; |
| 53 | 53 | { key: "members", label: "Members", path: "members" }, | |
| 54 | 54 | { key: "invitations", label: "Invitations", path: "members/invitations", owner: true }, | |
| 55 | 55 | { key: "permissions", label: "Permissions", path: "permissions", owner: true }, | |
| 56 | + | { key: "sso", label: "Single sign-on", path: "sso", owner: true }, | |
| 56 | 57 | { key: "tokens", label: "Access tokens", path: "tokens" }, | |
| 57 | 58 | { key: "personal-access-tokens", label: "Personal access tokens", path: "personal-access-tokens", owner: true }, | |
| 58 | 59 | ], | |
| ⋯ | |||
| 250 | 251 | people: "people", | |
| 251 | 252 | members: "workspace", | |
| 252 | 253 | permissions: "workspace", | |
| 254 | + | sso: "workspace", | |
| 253 | 255 | teams: "people", | |
| 254 | 256 | workspace: "workspace", | |
| 255 | 257 | usage: "workspace", | |
| 518 | 518 | </Link> | |
| 519 | 519 | </p> | |
| 520 | 520 | ); | |
| 521 | − | // A workspace that requires two-factor authentication of someone who | |
| 522 | − | // has not turned it on: they keep their place, and cannot use it yet. | |
| 521 | + | // A workspace that requires two-factor authentication or single sign-on | |
| 522 | + | // of someone who has not met it: they keep their place, and cannot use it yet. | |
| 523 | 523 | const held = user?.held && | |
| 524 | 524 | user.held.length > 0 && | |
| 525 | 525 | pathname !== "/settings/two-factor" && <PolicyNotice held={user.held} />; |
| 24 | 24 | route("auth/github", "routes/auth-github.ts"), | |
| 25 | 25 | route("auth/github/callback", "routes/auth-github-callback.tsx"), | |
| 26 | 26 | route("auth/github/username", "routes/auth-github-username.tsx"), | |
| 27 | + | // A workspace's single sign-on: setting off to its identity provider, | |
| 28 | + | // coming back from it (OpenID Connect and OAuth 2.0), what a SAML sign-in | |
| 29 | + | // that did not work says (the provider's POST is answered before React | |
| 30 | + | // Router, workers/sso-saml.ts), and g1t's SAML metadata. | |
| 31 | + | route("auth/sso/:owner", "routes/auth-sso.tsx"), | |
| 32 | + | route("auth/sso/:owner/callback", "routes/auth-sso-callback.tsx"), | |
| 33 | + | route("auth/sso/:owner/saml", "routes/auth-sso-saml.tsx"), | |
| 34 | + | route("auth/sso/:owner/metadata", "routes/auth-sso-metadata.ts"), | |
| 27 | 35 | route("integrations/github/install", "routes/github-install.ts"), | |
| 28 | 36 | route("integrations/github/setup", "routes/github-setup.tsx"), | |
| 29 | 37 | // Your own settings: one page each, so links can name the one they mean. | |
| ⋯ | |||
| 121 | 129 | route("-/members", "routes/workspace/members.tsx"), | |
| 122 | 130 | route("-/members/invitations", "routes/workspace/member-invitations.tsx"), | |
| 123 | 131 | route("-/permissions", "routes/workspace/permissions.tsx"), | |
| 132 | + | // Single sign-on through the workspace's own identity provider. | |
| 133 | + | route("-/sso", "routes/workspace/sso.tsx"), | |
| 124 | 134 | route("-/insights", "routes/workspace/insights.tsx"), | |
| 125 | 135 | // Pinning its projects, for the person signed in. | |
| 126 | 136 | route("-/pins", "routes/workspace/pins.ts"), | |
| 1 | + | import { data, redirect } from "react-router"; | |
| 2 | + | ||
| 3 | + | import type { Route } from "./+types/auth-sso-callback"; | |
| 4 | + | import { cookie, readCookie, stateMatches } from "../lib/github"; | |
| 5 | + | import { page } from "../lib/meta"; | |
| 6 | + | import { SSO_STATE_COOKIE, afterSso } from "../lib/sso"; | |
| 7 | + | import { sso } from "../lib/sso.server"; | |
| 8 | + | import { sessionTokenOf } from "../lib/session.server"; | |
| 9 | + | import { SsoProblem } from "./auth-sso"; | |
| 10 | + | ||
| 11 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 12 | + | return page(args, { title: `Single sign-on · ${params.owner} · g1t` }); | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | /** | |
| 16 | + | * Where a workspace's provider returns over OpenID Connect or OAuth 2.0. | |
| 17 | + | * The state must match this browser's cookie, and the session must be the | |
| 18 | + | * one that set off; identity then spends the state once, exchanges the | |
| 19 | + | * code, and links, admits and marks the session. | |
| 20 | + | */ | |
| 21 | + | export async function loader({ request, params }: Route.LoaderArgs) { | |
| 22 | + | const slug = params.owner.toLowerCase(); | |
| 23 | + | const url = new URL(request.url); | |
| 24 | + | const clear = cookie(SSO_STATE_COOKIE, "", 0); | |
| 25 | + | const failed = (error: string) => data({ slug, error }, { headers: { "set-cookie": clear } }); | |
| 26 | + | const refused = url.searchParams.get("error"); | |
| 27 | + | if (refused) { | |
| 28 | + | const said = url.searchParams.get("error_description")?.slice(0, 300); | |
| 29 | + | return failed(refused === "access_denied" ? "The sign-in was cancelled at the identity provider." : `The identity provider refused the sign-in${said ? `: ${said}` : ` (${refused.slice(0, 60)})`}.`); | |
| 30 | + | } | |
| 31 | + | const code = url.searchParams.get("code"); | |
| 32 | + | const state = url.searchParams.get("state"); | |
| 33 | + | if (!code || !stateMatches(readCookie(request.headers.get("cookie"), SSO_STATE_COOKIE), state)) { | |
| 34 | + | return failed("This sign-in did not start in this browser, or took too long. Start again."); | |
| 35 | + | } | |
| 36 | + | const token = sessionTokenOf(request); | |
| 37 | + | if (!token) return failed("Sign in to g1t again, then start single sign-on again."); | |
| 38 | + | const finished = await sso.finish({ slug, session_token: token, state: state!, code }); | |
| 39 | + | if (!finished.ok) return failed(finished.error.message); | |
| 40 | + | throw redirect(afterSso(finished.value), { headers: { "set-cookie": clear } }); | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | export default function SsoCallback({ loaderData }: Route.ComponentProps) { | |
| 44 | + | return <SsoProblem slug={loaderData.slug} error={loaderData.error} />; | |
| 45 | + | } |
| 1 | + | import { ssoAddresses } from "@g1t/contracts"; | |
| 2 | + | ||
| 3 | + | import type { Route } from "./+types/auth-sso-metadata"; | |
| 4 | + | import { sso } from "../lib/sso.server"; | |
| 5 | + | ||
| 6 | + | /** | |
| 7 | + | * g1t's SAML service provider metadata for a workspace, for its identity | |
| 8 | + | * provider to read: the entity id (this address) and where to post the | |
| 9 | + | * response. The same for everyone; it says nothing about the workspace. | |
| 10 | + | */ | |
| 11 | + | export async function loader({ request, params }: Route.LoaderArgs) { | |
| 12 | + | const addresses = ssoAddresses(new URL(request.url).origin, params.owner); | |
| 13 | + | const xml = await sso.metadata(addresses.entityId, addresses.acsUrl); | |
| 14 | + | return new Response(xml, { | |
| 15 | + | headers: { | |
| 16 | + | "content-type": "application/samlmetadata+xml; charset=utf-8", | |
| 17 | + | "cache-control": "public, max-age=3600", | |
| 18 | + | "content-disposition": `inline; filename="g1t-${params.owner.toLowerCase()}-metadata.xml"`, | |
| 19 | + | }, | |
| 20 | + | }); | |
| 21 | + | } |
| 1 | + | import { data, redirect } from "react-router"; | |
| 2 | + | ||
| 3 | + | import type { Route } from "./+types/auth-sso-saml"; | |
| 4 | + | import { page } from "../lib/meta"; | |
| 5 | + | import { clearErrorCookie, readErrorCookie } from "../lib/sso"; | |
| 6 | + | import { SsoProblem } from "./auth-sso"; | |
| 7 | + | ||
| 8 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 9 | + | return page(args, { title: `Single sign-on · ${params.owner} · g1t` }); | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | /** | |
| 13 | + | * The assertion consumer service, as a page: the provider's POST is | |
| 14 | + | * answered by the Worker (workers/sso-saml.ts), which comes back here with | |
| 15 | + | * why it did not work. Opened by itself, it starts again. | |
| 16 | + | */ | |
| 17 | + | export async function loader({ request, params }: Route.LoaderArgs) { | |
| 18 | + | const slug = params.owner.toLowerCase(); | |
| 19 | + | const error = readErrorCookie(request.headers.get("cookie")); | |
| 20 | + | if (!error) throw redirect(`/auth/sso/${slug}`); | |
| 21 | + | return data({ slug, error }, { headers: { "set-cookie": clearErrorCookie() } }); | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | export default function SsoSaml({ loaderData }: Route.ComponentProps) { | |
| 25 | + | return <SsoProblem slug={loaderData.slug} error={loaderData.error} />; | |
| 26 | + | } |
| 1 | + | import { Link, data, redirect } from "react-router"; | |
| 2 | + | ||
| 3 | + | import { ssoAddresses } from "@g1t/contracts"; | |
| 4 | + | ||
| 5 | + | import type { Route } from "./+types/auth-sso"; | |
| 6 | + | import { AuthCard } from "../components/auth-card"; | |
| 7 | + | import { cookie } from "../lib/github"; | |
| 8 | + | import { page } from "../lib/meta"; | |
| 9 | + | import { SSO_STATE_COOKIE } from "../lib/sso"; | |
| 10 | + | import { sso } from "../lib/sso.server"; | |
| 11 | + | import { nextPath, requireUser, sessionTokenOf } from "../lib/session.server"; | |
| 12 | + | ||
| 13 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 14 | + | return page(args, { title: `Single sign-on · ${params.owner} · g1t` }); | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | /** | |
| 18 | + | * Sends the signed-in person to a workspace's identity provider. Signed | |
| 19 | + | * out, they sign in to g1t first and come back here. The state the | |
| 20 | + | * provider will send back is kept in a short-lived cookie (OpenID Connect | |
| 21 | + | * and OAuth 2.0); a SAML request's id binds its answer instead. | |
| 22 | + | */ | |
| 23 | + | export async function loader({ request, params, context }: Route.LoaderArgs) { | |
| 24 | + | const user = requireUser(context, request); | |
| 25 | + | const slug = params.owner.toLowerCase(); | |
| 26 | + | const token = sessionTokenOf(request); | |
| 27 | + | if (!token) { | |
| 28 | + | return data({ slug, error: "Single sign-on is for people signed in on this site, not for access tokens." }, { status: 403 }); | |
| 29 | + | } | |
| 30 | + | const summary = await sso.summary(slug); | |
| 31 | + | if (!summary.enabled) return data({ slug, error: `${slug} has not set up single sign-on.` }, { status: 404 }); | |
| 32 | + | const addresses = ssoAddresses(new URL(request.url).origin, slug); | |
| 33 | + | const started = await sso.start({ | |
| 34 | + | user, | |
| 35 | + | session_token: token, | |
| 36 | + | slug, | |
| 37 | + | redirect_uri: summary.protocol === "saml" ? addresses.acsUrl : addresses.callbackUrl, | |
| 38 | + | entity_id: addresses.entityId, | |
| 39 | + | next: nextPath(request), | |
| 40 | + | }); | |
| 41 | + | if (!started.ok) return { slug, error: started.error.message }; | |
| 42 | + | const headers = started.value.state ? { "set-cookie": cookie(SSO_STATE_COOKIE, started.value.state, 600) } : undefined; | |
| 43 | + | throw redirect(started.value.url, { headers }); | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | export default function SsoStart({ loaderData }: Route.ComponentProps) { | |
| 47 | + | return <SsoProblem slug={loaderData.slug} error={loaderData.error} />; | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /** What a single sign-on that did not work says, with the ways on. */ | |
| 51 | + | export function SsoProblem({ slug, error }: { slug: string; error: string }) { | |
| 52 | + | return ( | |
| 53 | + | <AuthCard | |
| 54 | + | title="Single sign-on" | |
| 55 | + | subtitle={`Signing in through ${slug}'s identity provider`} | |
| 56 | + | footer={ | |
| 57 | + | <Link to="/" className="text-fg underline underline-offset-4"> | |
| 58 | + | Back to g1t | |
| 59 | + | </Link> | |
| 60 | + | } | |
| 61 | + | > | |
| 62 | + | <p className="text-sm text-danger" role="alert"> | |
| 63 | + | {error} | |
| 64 | + | </p> | |
| 65 | + | <p className="mt-6 text-sm"> | |
| 66 | + | <Link to={`/auth/sso/${slug}`} className="font-medium text-accent underline underline-offset-4"> | |
| 67 | + | Try again | |
| 68 | + | </Link> | |
| 69 | + | </p> | |
| 70 | + | </AuthCard> | |
| 71 | + | ); | |
| 72 | + | } |
| 263 | 263 | note: "Longer by arrangement. Older entries are deleted each day.", | |
| 264 | 264 | }, | |
| 265 | 265 | { | |
| 266 | + | what: "Single sign-on", | |
| 267 | + | free: "Not available", | |
| 268 | + | plan: "Included, at no extra price", | |
| 269 | + | note: "Your identity provider over SAML 2.0, OpenID Connect or OAuth 2.0, and requiring it of members.", | |
| 270 | + | }, | |
| 271 | + | { | |
| 266 | 272 | what: "Secret scanning, push protection, vulnerability alerts, security updates", | |
| 267 | 273 | free: "Included", | |
| 268 | 274 | plan: "Included", |
| 1 | + | import { useState } from "react"; | |
| 2 | + | import { Form, data } from "react-router"; | |
| 3 | + | import { KeyRound, Sparkles } from "lucide-react"; | |
| 4 | + | ||
| 5 | + | import { type SsoConfig, type SsoProtocol, type SsoSettings, ssoAddresses } from "@g1t/contracts"; | |
| 6 | + | ||
| 7 | + | import type { Route } from "./+types/sso"; | |
| 8 | + | import { page } from "../../lib/meta"; | |
| 9 | + | import { DangerAction, DangerZone } from "../../components/danger-zone"; | |
| 10 | + | import { planHref } from "../../components/start-plan"; | |
| 11 | + | import { ButtonLink, CopyLine, ErrorText, Field, Input, SubmitButton, Textarea, TimeAgo } from "../../components/ui"; | |
| 12 | + | import { | |
| 13 | + | AlertDialog, | |
| 14 | + | AlertDialogCancel, | |
| 15 | + | AlertDialogContent, | |
| 16 | + | AlertDialogDescription, | |
| 17 | + | AlertDialogFooter, | |
| 18 | + | AlertDialogHeader, | |
| 19 | + | AlertDialogTitle, | |
| 20 | + | AlertDialogTrigger, | |
| 21 | + | } from "../../components/ui/alert-dialog"; | |
| 22 | + | import { Badge } from "../../components/ui/badge"; | |
| 23 | + | import { Button } from "../../components/ui/button"; | |
| 24 | + | import { RadioGroup, RadioOption } from "../../components/ui/radio-group"; | |
| 25 | + | import { SwitchCard } from "../../components/ui/switch"; | |
| 26 | + | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"; | |
| 27 | + | import { Unavailable } from "../../components/boundary"; | |
| 28 | + | import { isFailed, part } from "../../lib/partial"; | |
| 29 | + | import { billing } from "../../lib/services.server"; | |
| 30 | + | import { PROTOCOL_NAMES } from "../../lib/sso"; | |
| 31 | + | import { sso } from "../../lib/sso.server"; | |
| 32 | + | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 33 | + | ||
| 34 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 35 | + | return page(args, { title: `Single sign-on · ${params.owner} · g1t` }); | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 39 | + | // Owners only; to anyone else the page does not exist. | |
| 40 | + | const viewer = getViewer(context); | |
| 41 | + | if (!viewer || roleIn(viewer, params.owner) !== "owner") throw data(null, { status: 404 }); | |
| 42 | + | const slug = params.owner.toLowerCase(); | |
| 43 | + | const [settings, free] = await Promise.all([ | |
| 44 | + | sso.settings(viewer, slug), | |
| 45 | + | // Whether it can be set up here: every paid plan has it. | |
| 46 | + | part(billing.freeWorkspaces([slug]), "The workspace's plan"), | |
| 47 | + | ]); | |
| 48 | + | return { | |
| 49 | + | settings: unwrap(settings), | |
| 50 | + | plan: isFailed(free) ? free : free.includes(slug) ? ("free" as const) : ("paid" as const), | |
| 51 | + | addresses: ssoAddresses(new URL(request.url).origin, slug), | |
| 52 | + | }; | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | type Answer = { saved?: string; error?: string; intent?: string }; | |
| 56 | + | ||
| 57 | + | export async function action({ request, params, context }: Route.ActionArgs): Promise<Answer> { | |
| 58 | + | assertSameOrigin(request); | |
| 59 | + | const user = requireUser(context, request); | |
| 60 | + | const slug = params.owner.toLowerCase(); | |
| 61 | + | const form = await request.formData(); | |
| 62 | + | const intent = String(form.get("intent") ?? ""); | |
| 63 | + | const text = (name: string) => String(form.get(name) ?? "").trim() || null; | |
| 64 | + | const answer = (result: { ok: true } | { ok: false; error: { message: string } }, saved: string): Answer => | |
| 65 | + | result.ok ? { saved, intent } : { error: result.error.message, intent }; | |
| 66 | + | switch (intent) { | |
| 67 | + | case "save": { | |
| 68 | + | const protocol = String(form.get("protocol") ?? "oidc") as SsoProtocol; | |
| 69 | + | const config: SsoConfig = { | |
| 70 | + | issuer: text("issuer"), | |
| 71 | + | client_id: text("client_id"), | |
| 72 | + | scopes: text("scopes"), | |
| 73 | + | authorization_url: text("authorization_url"), | |
| 74 | + | token_url: text("token_url"), | |
| 75 | + | userinfo_url: text("userinfo_url"), | |
| 76 | + | subject_claim: text("subject_claim"), | |
| 77 | + | email_claim: text("email_claim"), | |
| 78 | + | idp_entity_id: text("idp_entity_id"), | |
| 79 | + | idp_sso_url: text("idp_sso_url"), | |
| 80 | + | }; | |
| 81 | + | const saved = await sso.save(user, slug, { | |
| 82 | + | protocol, | |
| 83 | + | config, | |
| 84 | + | client_secret: text("client_secret"), | |
| 85 | + | certificate: text("certificate"), | |
| 86 | + | allow_join: form.get("allow_join") === "on", | |
| 87 | + | }); | |
| 88 | + | return answer(saved, "Saved."); | |
| 89 | + | } | |
| 90 | + | case "required": { | |
| 91 | + | const required = form.get("required") === "on"; | |
| 92 | + | return answer(await sso.setRequired(user, slug, required), required ? "Single sign-on is required." : "Single sign-on is not required."); | |
| 93 | + | } | |
| 94 | + | case "unlink": { | |
| 95 | + | const username = String(form.get("username") ?? ""); | |
| 96 | + | return answer(await sso.unlink(user, slug, username), `Unlinked ${username}.`); | |
| 97 | + | } | |
| 98 | + | case "delete": | |
| 99 | + | return answer(await sso.remove(user, slug), "Single sign-on is removed."); | |
| 100 | + | default: | |
| 101 | + | return { error: "Unknown change.", intent }; | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | export default function WorkspaceSso({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 106 | + | const { settings, plan, addresses } = loaderData; | |
| 107 | + | const slug = params.owner.toLowerCase(); | |
| 108 | + | const free = plan === "free"; | |
| 109 | + | const said = (intent: string) => (actionData?.intent === intent ? actionData : undefined); | |
| 110 | + | return ( | |
| 111 | + | <div className="max-w-3xl space-y-10"> | |
| 112 | + | <section aria-labelledby="sso-about"> | |
| 113 | + | <h2 id="sso-about" className="flex items-center gap-2 font-medium"> | |
| 114 | + | <KeyRound size={16} className="text-faint" aria-hidden /> | |
| 115 | + | Single sign-on | |
| 116 | + | </h2> | |
| 117 | + | <p className="mt-1.5 text-sm text-muted"> | |
| 118 | + | Connect {slug} to your identity provider over OpenID Connect, OAuth 2.0 or SAML. People sign in to g1t as | |
| 119 | + | themselves, then through your provider to use {slug}: the first time, g1t links who they are there to their g1t | |
| 120 | + | account. Require it, and members use {slug} only after signing in through it. | |
| 121 | + | </p> | |
| 122 | + | {isFailed(plan) ? ( | |
| 123 | + | <div className="mt-4"> | |
| 124 | + | <Unavailable what={plan.what} /> | |
| 125 | + | </div> | |
| 126 | + | ) : ( | |
| 127 | + | free && <PlanNeeded slug={slug} /> | |
| 128 | + | )} | |
| 129 | + | </section> | |
| 130 | + | ||
| 131 | + | <ProviderSection | |
| 132 | + | // Starts from what is saved whenever that changes. | |
| 133 | + | key={settings.connection?.updated_at ?? "none"} | |
| 134 | + | settings={settings} | |
| 135 | + | addresses={addresses} | |
| 136 | + | disabled={free} | |
| 137 | + | answer={said("save")} | |
| 138 | + | /> | |
| 139 | + | ||
| 140 | + | {settings.connection && ( | |
| 141 | + | <RequireSection settings={settings} slug={slug} disabled={free} answer={said("required")} /> | |
| 142 | + | )} | |
| 143 | + | ||
| 144 | + | {settings.connection && <PeopleSection settings={settings} answer={said("unlink")} />} | |
| 145 | + | ||
| 146 | + | {settings.connection && ( | |
| 147 | + | <DangerZone> | |
| 148 | + | <DangerAction | |
| 149 | + | title="Remove single sign-on" | |
| 150 | + | action={<RemoveDialog slug={slug} />} | |
| 151 | + | > | |
| 152 | + | Forgets the provider and who everyone is there, and stops requiring it. Members keep their place in {slug}. | |
| 153 | + | {said("delete")?.error && <ErrorText>{said("delete")?.error}</ErrorText>} | |
| 154 | + | </DangerAction> | |
| 155 | + | </DangerZone> | |
| 156 | + | )} | |
| 157 | + | </div> | |
| 158 | + | ); | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | /** Removing it, once the owner says so in a dialog. */ | |
| 162 | + | function RemoveDialog({ slug }: { slug: string }) { | |
| 163 | + | return ( | |
| 164 | + | <AlertDialog> | |
| 165 | + | <AlertDialogTrigger asChild> | |
| 166 | + | <Button variant="destructive">Remove</Button> | |
| 167 | + | </AlertDialogTrigger> | |
| 168 | + | <AlertDialogContent> | |
| 169 | + | <AlertDialogHeader> | |
| 170 | + | <AlertDialogTitle>Remove single sign-on from {slug}?</AlertDialogTitle> | |
| 171 | + | <AlertDialogDescription> | |
| 172 | + | g1t forgets your provider and who everyone is there, and stops requiring it. Setting it up again links everyone | |
| 173 | + | afresh. | |
| 174 | + | </AlertDialogDescription> | |
| 175 | + | </AlertDialogHeader> | |
| 176 | + | <Form method="post"> | |
| 177 | + | <input type="hidden" name="intent" value="delete" /> | |
| 178 | + | <AlertDialogFooter> | |
| 179 | + | <AlertDialogCancel>Cancel</AlertDialogCancel> | |
| 180 | + | <SubmitButton variant="destructive" match={{ intent: "delete" }} pending="Removing…"> | |
| 181 | + | Remove single sign-on | |
| 182 | + | </SubmitButton> | |
| 183 | + | </AlertDialogFooter> | |
| 184 | + | </Form> | |
| 185 | + | </AlertDialogContent> | |
| 186 | + | </AlertDialog> | |
| 187 | + | ); | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | /** For a free workspace: it is there, and comes with any paid plan. */ | |
| 191 | + | function PlanNeeded({ slug }: { slug: string }) { | |
| 192 | + | return ( | |
| 193 | + | <div role="note" className="mt-4 rounded-xl border border-accent/30 bg-accent/5 p-4"> | |
| 194 | + | <div className="flex flex-col gap-3 sm:flex-row sm:items-center"> | |
| 195 | + | <Sparkles size={16} className="hidden shrink-0 text-accent sm:block" aria-hidden /> | |
| 196 | + | <div className="min-w-0 grow"> | |
| 197 | + | <p className="text-sm font-medium">Single sign-on comes with every paid plan</p> | |
| 198 | + | <p className="mt-1 text-sm text-muted"> | |
| 199 | + | {slug} is a free workspace. Start the plan to connect your identity provider: there is nothing extra to pay | |
| 200 | + | for it. | |
| 201 | + | </p> | |
| 202 | + | </div> | |
| 203 | + | <span className="shrink-0"> | |
| 204 | + | <ButtonLink to={planHref(slug)}>Start the plan</ButtonLink> | |
| 205 | + | </span> | |
| 206 | + | </div> | |
| 207 | + | </div> | |
| 208 | + | ); | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | function Saved({ answer }: { answer?: Answer }) { | |
| 212 | + | if (!answer) return null; | |
| 213 | + | return answer.error ? <ErrorText>{answer.error}</ErrorText> : <span className="text-sm text-success">{answer.saved}</span>; | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | function ProviderSection({ | |
| 217 | + | settings, | |
| 218 | + | addresses, | |
| 219 | + | disabled, | |
| 220 | + | answer, | |
| 221 | + | }: { | |
| 222 | + | settings: SsoSettings; | |
| 223 | + | addresses: ReturnType<typeof ssoAddresses>; | |
| 224 | + | disabled: boolean; | |
| 225 | + | answer?: Answer; | |
| 226 | + | }) { | |
| 227 | + | const connection = settings.connection; | |
| 228 | + | const [protocol, setProtocol] = useState<SsoProtocol>(connection?.protocol ?? "oidc"); | |
| 229 | + | // What is saved for the protocol shown; another protocol starts empty. | |
| 230 | + | const saved = connection?.protocol === protocol ? connection.config : {}; | |
| 231 | + | const value = (name: keyof SsoConfig) => (saved[name] as string | null | undefined) ?? ""; | |
| 232 | + | const keepsSecret = connection?.protocol === protocol && connection.has_client_secret; | |
| 233 | + | return ( | |
| 234 | + | <section aria-labelledby="sso-provider"> | |
| 235 | + | <h2 id="sso-provider" className="font-medium"> | |
| 236 | + | Identity provider | |
| 237 | + | </h2> | |
| 238 | + | <Form method="post" className="mt-5 space-y-5"> | |
| 239 | + | <input type="hidden" name="intent" value="save" /> | |
| 240 | + | <fieldset disabled={disabled} className="space-y-5 disabled:opacity-60"> | |
| 241 | + | <RadioGroup name="protocol" value={protocol} onValueChange={(next) => setProtocol(next as SsoProtocol)} className="sm:grid-cols-3"> | |
| 242 | + | <RadioOption value="oidc" label={PROTOCOL_NAMES.oidc} description="Okta, Microsoft Entra ID, Google, Auth0 and most others." /> | |
| 243 | + | <RadioOption value="saml" label={PROTOCOL_NAMES.saml} description="For providers set up for SAML apps." /> | |
| 244 | + | <RadioOption value="oauth2" label={PROTOCOL_NAMES.oauth2} description="A provider without OpenID Connect." /> | |
| 245 | + | </RadioGroup> | |
| 246 | + | ||
| 247 | + | <div className="space-y-2"> | |
| 248 | + | <p className="text-sm font-medium text-muted">In your provider, set up an app with these</p> | |
| 249 | + | {protocol === "saml" ? ( | |
| 250 | + | <> | |
| 251 | + | <Field label="Entity id (audience) and metadata"> | |
| 252 | + | <CopyLine text={addresses.entityId} breakAtSlashes /> | |
| 253 | + | </Field> | |
| 254 | + | <Field label="Assertion consumer service (ACS) URL, HTTP-POST"> | |
| 255 | + | <CopyLine text={addresses.acsUrl} breakAtSlashes /> | |
| 256 | + | </Field> | |
| 257 | + | </> | |
| 258 | + | ) : ( | |
| 259 | + | <Field label="Redirect URI (callback)"> | |
| 260 | + | <CopyLine text={addresses.callbackUrl} breakAtSlashes /> | |
| 261 | + | </Field> | |
| 262 | + | )} | |
| 263 | + | </div> | |
| 264 | + | ||
| 265 | + | {protocol === "oidc" && ( | |
| 266 | + | <> | |
| 267 | + | <Field label="Issuer" hint="Where its /.well-known/openid-configuration lives, such as https://acme.okta.com."> | |
| 268 | + | <Input name="issuer" type="url" required defaultValue={value("issuer")} placeholder="https://" /> | |
| 269 | + | </Field> | |
| 270 | + | <ClientFields value={value} keepsSecret={keepsSecret} defaultScopes="openid email profile" /> | |
| 271 | + | </> | |
| 272 | + | )} | |
| 273 | + | ||
| 274 | + | {protocol === "oauth2" && ( | |
| 275 | + | <> | |
| 276 | + | <Field label="Authorization URL"> | |
| 277 | + | <Input name="authorization_url" type="url" required defaultValue={value("authorization_url")} placeholder="https://" /> | |
| 278 | + | </Field> | |
| 279 | + | <Field label="Token URL"> | |
| 280 | + | <Input name="token_url" type="url" required defaultValue={value("token_url")} placeholder="https://" /> | |
| 281 | + | </Field> | |
| 282 | + | <Field label="Userinfo URL" hint="Read with the access token: who signed in."> | |
| 283 | + | <Input name="userinfo_url" type="url" required defaultValue={value("userinfo_url")} placeholder="https://" /> | |
| 284 | + | </Field> | |
| 285 | + | <ClientFields value={value} keepsSecret={keepsSecret} defaultScopes="" /> | |
| 286 | + | <div className="grid gap-4 sm:grid-cols-2"> | |
| 287 | + | <Field label="Subject claim" hint="The person's stable id in the userinfo answer. sub, then id, when empty."> | |
| 288 | + | <Input name="subject_claim" defaultValue={value("subject_claim")} placeholder="sub" /> | |
| 289 | + | </Field> | |
| 290 | + | <Field label="Email claim" hint="Shown beside each person; never used to sign in."> | |
| 291 | + | <Input name="email_claim" defaultValue={value("email_claim")} placeholder="email" /> | |
| 292 | + | </Field> | |
| 293 | + | </div> | |
| 294 | + | </> | |
| 295 | + | )} | |
| 296 | + | ||
| 297 | + | {protocol === "saml" && ( | |
| 298 | + | <> | |
| 299 | + | <Field label="Identity provider entity id" hint="Its issuer, as its metadata says it."> | |
| 300 | + | <Input name="idp_entity_id" required defaultValue={value("idp_entity_id")} /> | |
| 301 | + | </Field> | |
| 302 | + | <Field label="Single sign-on URL" hint="Where it takes SAML requests (HTTP-Redirect)."> | |
| 303 | + | <Input name="idp_sso_url" type="url" required defaultValue={value("idp_sso_url")} placeholder="https://" /> | |
| 304 | + | </Field> | |
| 305 | + | <Field | |
| 306 | + | label="Signing certificate" | |
| 307 | + | hint={ | |
| 308 | + | connection?.protocol === "saml" && connection.certificates.length > 0 ? ( | |
| 309 | + | <> | |
| 310 | + | Saved, by SHA-256:{" "} | |
| 311 | + | {connection.certificates.map((c) => ( | |
| 312 | + | <span key={c.fingerprint} className="block font-mono break-all"> | |
| 313 | + | {c.fingerprint} | |
| 314 | + | </span> | |
| 315 | + | ))} | |
| 316 | + | Paste new ones to replace them; several while you rotate. | |
| 317 | + | </> | |
| 318 | + | ) : ( | |
| 319 | + | "PEM, starting -----BEGIN CERTIFICATE-----. Sign the assertion or the response with SHA-256, and leave the assertion unencrypted." | |
| 320 | + | ) | |
| 321 | + | } | |
| 322 | + | > | |
| 323 | + | <Textarea name="certificate" rows={5} required={!(connection?.protocol === "saml" && connection.certificates.length > 0)} /> | |
| 324 | + | </Field> | |
| 325 | + | </> | |
| 326 | + | )} | |
| 327 | + | ||
| 328 | + | <SwitchCard name="allow_join" title="Let people from your provider join" defaultChecked={connection?.allow_join ?? true}> | |
| 329 | + | Anyone your provider signs in becomes a member of the workspace, with no invitation. Off: only members can sign in | |
| 330 | + | through it. | |
| 331 | + | </SwitchCard> | |
| 332 | + | </fieldset> | |
| 333 | + | <div className="flex flex-wrap items-center gap-3"> | |
| 334 | + | <SubmitButton match={{ intent: "save" }} pending="Checking…" disabled={disabled}> | |
| 335 | + | {connection ? "Save" : "Set up single sign-on"} | |
| 336 | + | </SubmitButton> | |
| 337 | + | <Saved answer={answer} /> | |
| 338 | + | </div> | |
| 339 | + | </Form> | |
| 340 | + | </section> | |
| 341 | + | ); | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | function ClientFields({ value, keepsSecret, defaultScopes }: { value: (name: keyof SsoConfig) => string; keepsSecret: boolean; defaultScopes: string }) { | |
| 345 | + | return ( | |
| 346 | + | <> | |
| 347 | + | <div className="grid gap-4 sm:grid-cols-2"> | |
| 348 | + | <Field label="Client id"> | |
| 349 | + | <Input name="client_id" required defaultValue={value("client_id")} /> | |
| 350 | + | </Field> | |
| 351 | + | <Field label="Client secret" hint={keepsSecret ? "Saved, and never shown. Enter a new one to replace it." : "Kept encrypted, and never shown again."}> | |
| 352 | + | <Input name="client_secret" type="password" autoComplete="new-password" required={!keepsSecret} placeholder={keepsSecret ? "••••••••" : undefined} /> | |
| 353 | + | </Field> | |
| 354 | + | </div> | |
| 355 | + | <Field label="Scopes" hint={defaultScopes ? `Space-separated. ${defaultScopes} when empty.` : "Space-separated, as the provider needs to answer userinfo."}> | |
| 356 | + | <Input name="scopes" defaultValue={value("scopes")} placeholder={defaultScopes} /> | |
| 357 | + | </Field> | |
| 358 | + | </> | |
| 359 | + | ); | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | function RequireSection({ settings, slug, disabled, answer }: { settings: SsoSettings; slug: string; disabled: boolean; answer?: Answer }) { | |
| 363 | + | const connection = settings.connection!; | |
| 364 | + | const [chosen, setChosen] = useState(settings.required); | |
| 365 | + | const back = encodeURIComponent(`/${slug}/-/sso`); | |
| 366 | + | // Requiring it needs the owner to have signed in through it; never needed to stop. | |
| 367 | + | const locked = !settings.required && (disabled || !settings.you_linked); | |
| 368 | + | return ( | |
| 369 | + | <section aria-labelledby="sso-require"> | |
| 370 | + | <h2 id="sso-require" className="font-medium"> | |
| 371 | + | Test and require | |
| 372 | + | </h2> | |
| 373 | + | <div className="mt-4 flex flex-wrap items-center gap-3"> | |
| 374 | + | {connection.tested_at ? ( | |
| 375 | + | <Badge tone="success"> | |
| 376 | + | Tested by {connection.tested_by ?? "someone"} <TimeAgo at={connection.tested_at} /> | |
| 377 | + | </Badge> | |
| 378 | + | ) : ( | |
| 379 | + | <Badge tone="warn">Not tested yet</Badge> | |
| 380 | + | )} | |
| 381 | + | <ButtonLink to={`/auth/sso/${slug}?next=${back}`} variant="outline" size="sm"> | |
| 382 | + | {settings.you_linked ? "Sign in through it again" : "Test sign-in"} | |
| 383 | + | </ButtonLink> | |
| 384 | + | </div> | |
| 385 | + | <Form method="post" className="mt-5 space-y-3"> | |
| 386 | + | <input type="hidden" name="intent" value="required" /> | |
| 387 | + | <SwitchCard name="required" title="Require single sign-on" checked={chosen} onCheckedChange={setChosen} disabled={locked}> | |
| 388 | + | {locked && !disabled | |
| 389 | + | ? "Sign in through your provider yourself first, with Test sign-in." | |
| 390 | + | : `Members use ${slug} only from a session that signed in through your provider in the last day, and their access tokens and SSH keys only once they have linked. Owners always can, so a provider that breaks never locks you out. Outside collaborators are not asked.`} | |
| 391 | + | </SwitchCard> | |
| 392 | + | <div className="flex flex-wrap items-center gap-3"> | |
| 393 | + | <SubmitButton variant="outline" size="sm" match={{ intent: "required" }} pending="Saving…" disabled={chosen === settings.required}> | |
| 394 | + | Save | |
| 395 | + | </SubmitButton> | |
| 396 | + | <Saved answer={answer} /> | |
| 397 | + | </div> | |
| 398 | + | </Form> | |
| 399 | + | </section> | |
| 400 | + | ); | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | function PeopleSection({ settings, answer }: { settings: SsoSettings; answer?: Answer }) { | |
| 404 | + | return ( | |
| 405 | + | <section aria-labelledby="sso-people"> | |
| 406 | + | <h2 id="sso-people" className="font-medium"> | |
| 407 | + | Linked people | |
| 408 | + | </h2> | |
| 409 | + | <p className="mt-1.5 text-sm text-muted"> | |
| 410 | + | Who has signed in through your provider, and who they are there. Unlink someone to let them link another account | |
| 411 | + | there; their sessions sign in through it again. | |
| 412 | + | </p> | |
| 413 | + | <div className="mt-4"> | |
| 414 | + | {settings.identities.length === 0 ? ( | |
| 415 | + | <p className="text-sm text-faint">No one yet.</p> | |
| 416 | + | ) : ( | |
| 417 | + | <Table> | |
| 418 | + | <TableHeader> | |
| 419 | + | <TableRow> | |
| 420 | + | <TableHead>Person</TableHead> | |
| 421 | + | <TableHead>At your provider</TableHead> | |
| 422 | + | <TableHead>Last signed in</TableHead> | |
| 423 | + | <TableHead> | |
| 424 | + | <span className="sr-only">Unlink</span> | |
| 425 | + | </TableHead> | |
| 426 | + | </TableRow> | |
| 427 | + | </TableHeader> | |
| 428 | + | <TableBody> | |
| 429 | + | {settings.identities.map((identity) => ( | |
| 430 | + | <TableRow key={identity.username}> | |
| 431 | + | <TableCell className="font-medium">{identity.display_username ?? identity.username}</TableCell> | |
| 432 | + | <TableCell className="max-w-56 truncate text-muted">{identity.email ?? identity.subject}</TableCell> | |
| 433 | + | <TableCell className="text-muted"> | |
| 434 | + | <TimeAgo at={identity.last_used_at} /> | |
| 435 | + | </TableCell> | |
| 436 | + | <TableCell className="text-right"> | |
| 437 | + | <Form method="post"> | |
| 438 | + | <input type="hidden" name="intent" value="unlink" /> | |
| 439 | + | <input type="hidden" name="username" value={identity.username} /> | |
| 440 | + | <SubmitButton variant="ghost" size="sm" match={{ intent: "unlink", username: identity.username }} pending="Unlinking…"> | |
| 441 | + | Unlink | |
| 442 | + | </SubmitButton> | |
| 443 | + | </Form> | |
| 444 | + | </TableCell> | |
| 445 | + | </TableRow> | |
| 446 | + | ))} | |
| 447 | + | </TableBody> | |
| 448 | + | </Table> | |
| 449 | + | )} | |
| 450 | + | <div className="mt-2"> | |
| 451 | + | <Saved answer={answer} /> | |
| 452 | + | </div> | |
| 453 | + | </div> | |
| 454 | + | </section> | |
| 455 | + | ); | |
| 456 | + | } |
| 16 | 16 | import { usercontentPath } from "../app/lib/usercontent"; | |
| 17 | 17 | import { TOKEN_CHALLENGE } from "../app/lib/website-token"; | |
| 18 | 18 | import { serveUsercontent } from "./usercontent"; | |
| 19 | + | import { receiveSaml } from "./sso-saml"; | |
| 20 | + | import { samlPath } from "../app/lib/sso"; | |
| 19 | 21 | import { serveArchivedAsset } from "../app/lib/asset-archive"; | |
| 20 | 22 | import { needsBuildRefresh } from "../app/lib/build-version"; | |
| 21 | 23 | import { developerResource, pageRepresentation } from "../app/lib/agent-pages"; | |
| ⋯ | |||
| 119 | 121 | // session signed in (app/lib/front-door-limits.ts). | |
| 120 | 122 | const limited = await pageLimited(env, request, pathname); | |
| 121 | 123 | if (limited) return limited; | |
| 124 | + | // A workspace's identity provider posting a SAML response, from its own | |
| 125 | + | // site: React Router refuses cross-site posts (workers/sso-saml.ts). | |
| 126 | + | const saml = request.method === "POST" ? samlPath(pathname) : null; | |
| 127 | + | if (saml) return receiveSaml(env, request, saml); | |
| 122 | 128 | // Every page and data request says where its time went (Server-Timing) | |
| 123 | 129 | // and keeps the reader's D1 bookmarks (app/lib/perf.server.ts). | |
| 124 | 130 | const render = () => withRequestPerf(request, async () => finishResponse(request, await requestHandler(request))); | |
| 1 | + | import { ssoClient } from "@g1t/contracts"; | |
| 2 | + | ||
| 3 | + | import { afterSso, errorCookie } from "../app/lib/sso"; | |
| 4 | + | ||
| 5 | + | /** | |
| 6 | + | * A workspace's identity provider posting its SAML response | |
| 7 | + | * (`POST /auth/sso/<slug>/saml`). It comes from the provider's site, so it | |
| 8 | + | * is answered here, before React Router, which refuses cross-site form | |
| 9 | + | * posts; and it carries no session cookie (SameSite=Lax), so identity | |
| 10 | + | * finds the session from the request g1t sent, which the response must | |
| 11 | + | * answer. Either way the browser is sent on with a 303: on to where it was | |
| 12 | + | * going, or back to the page that says what went wrong. | |
| 13 | + | */ | |
| 14 | + | export async function receiveSaml(env: Env, request: Request, slug: string): Promise<Response> { | |
| 15 | + | const back = (message: string) => | |
| 16 | + | new Response(null, { | |
| 17 | + | status: 303, | |
| 18 | + | headers: { location: `/auth/sso/${slug}/saml`, "set-cookie": errorCookie(message), "cache-control": "no-store" }, | |
| 19 | + | }); | |
| 20 | + | let samlResponse = ""; | |
| 21 | + | try { | |
| 22 | + | const form = await request.formData(); | |
| 23 | + | samlResponse = String(form.get("SAMLResponse") ?? ""); | |
| 24 | + | } catch { | |
| 25 | + | return back("The identity provider's response could not be read."); | |
| 26 | + | } | |
| 27 | + | if (!samlResponse) return back("The identity provider sent no SAML response."); | |
| 28 | + | const finished = await ssoClient(env.IDENTITY) | |
| 29 | + | .saml(slug, samlResponse) | |
| 30 | + | .catch(() => null); | |
| 31 | + | if (!finished) return back("g1t could not check the identity provider's response just now. Try again in a minute."); | |
| 32 | + | if (!finished.ok) return back(finished.error.message); | |
| 33 | + | return new Response(null, { status: 303, headers: { location: afterSso(finished.value), "cache-control": "no-store" } }); | |
| 34 | + | } |
| 47 | 47 | pub mod security; | |
| 48 | 48 | pub mod teams; | |
| 49 | 49 | pub mod security_suite; | |
| 50 | + | pub mod sso; | |
| 50 | 51 | pub mod subscribers; | |
| 51 | 52 | pub mod time; | |
| 52 | 53 | pub mod tokens; |
| 1 | + | //! Single sign-on: a workspace's own identity provider, over OpenID | |
| 2 | + | //! Connect, OAuth 2.0 or SAML 2.0. On every paid plan; a free workspace | |
| 3 | + | //! sees it and cannot set it up. Mirrors `packages/contracts/src/sso.ts`. | |
| 4 | + | //! | |
| 5 | + | //! It never signs anyone in to g1t. A person signs in to g1t as | |
| 6 | + | //! themselves, then proves themselves to the workspace's provider to use | |
| 7 | + | //! the workspace, the first time linking who they are there to their g1t | |
| 8 | + | //! account. A workspace can require it of its members: then a member uses | |
| 9 | + | //! it only from a session that went through its provider in the last | |
| 10 | + | //! day, and an access token of theirs only once they have linked. Owners | |
| 11 | + | //! are never held out, so a provider that breaks never locks a workspace | |
| 12 | + | //! out of itself. | |
| 13 | + | //! | |
| 14 | + | //! The identity service (`POST /rpc/<method>`): | |
| 15 | + | //! | |
| 16 | + | //! - `sso_settings` takes `SsoSettingsArgs`, returns `Outcome<SsoSettings>`. | |
| 17 | + | //! - `save_sso` takes `SaveSsoArgs`, returns `Outcome<SsoSettings>`. | |
| 18 | + | //! - `set_sso_required` takes `SetSsoRequiredArgs`, returns `Outcome<bool>`. | |
| 19 | + | //! - `delete_sso` takes `SsoSettingsArgs`, returns `Outcome<bool>`. | |
| 20 | + | //! - `unlink_sso_identity` takes `UnlinkSsoIdentityArgs`, returns `Outcome<bool>`. | |
| 21 | + | //! - `sso_start` takes `SsoStartArgs`, returns `Outcome<SsoStart>`. | |
| 22 | + | //! - `sso_finish` takes `SsoFinishArgs`, returns `Outcome<SsoFinished>`. | |
| 23 | + | //! - `sso_saml` takes `SsoSamlArgs`, returns `Outcome<SsoFinished>`. | |
| 24 | + | //! - `sso_summary` takes `SlugArgs`, returns `SsoSummary`. | |
| 25 | + | //! - `sso_metadata` takes `SsoMetadataArgs`, returns the XML as a string. | |
| 26 | + | ||
| 27 | + | use serde::{Deserialize, Serialize}; | |
| 28 | + | ||
| 29 | + | use crate::User; | |
| 30 | + | use crate::audit::Surface; | |
| 31 | + | ||
| 32 | + | /// How a workspace's provider is spoken to. | |
| 33 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 34 | + | #[serde(rename_all = "snake_case")] | |
| 35 | + | pub enum SsoProtocol { | |
| 36 | + | /// OpenID Connect: an issuer whose discovery document says the rest. | |
| 37 | + | Oidc, | |
| 38 | + | /// Plain OAuth 2.0: authorize, token and userinfo endpoints, and which | |
| 39 | + | /// claims of the userinfo answer are the person's id and address. | |
| 40 | + | Oauth2, | |
| 41 | + | /// SAML 2.0: the provider's entity id, sign-in URL and certificates. | |
| 42 | + | Saml, | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | impl SsoProtocol { | |
| 46 | + | pub fn as_str(self) -> &'static str { | |
| 47 | + | match self { | |
| 48 | + | SsoProtocol::Oidc => "oidc", | |
| 49 | + | SsoProtocol::Oauth2 => "oauth2", | |
| 50 | + | SsoProtocol::Saml => "saml", | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | pub fn parse(text: &str) -> Option<SsoProtocol> { | |
| 55 | + | match text { | |
| 56 | + | "oidc" => Some(SsoProtocol::Oidc), | |
| 57 | + | "oauth2" => Some(SsoProtocol::Oauth2), | |
| 58 | + | "saml" => Some(SsoProtocol::Saml), | |
| 59 | + | _ => None, | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | /// What a connection needs, by protocol; the fields of the others are | |
| 65 | + | /// absent. Stored as JSON, and shown to owners as it is: it holds no | |
| 66 | + | /// secret. | |
| 67 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 68 | + | pub struct SsoConfig { | |
| 69 | + | /// OpenID Connect: the issuer, such as `https://acme.okta.com`. | |
| 70 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 71 | + | pub issuer: Option<String>, | |
| 72 | + | /// OpenID Connect and OAuth 2.0. | |
| 73 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 74 | + | pub client_id: Option<String>, | |
| 75 | + | /// Space-separated. OpenID Connect always asks for `openid`. | |
| 76 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 77 | + | pub scopes: Option<String>, | |
| 78 | + | /// OAuth 2.0. | |
| 79 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 80 | + | pub authorization_url: Option<String>, | |
| 81 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 82 | + | pub token_url: Option<String>, | |
| 83 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 84 | + | pub userinfo_url: Option<String>, | |
| 85 | + | /// The userinfo claim that is the person's stable id; `sub` when | |
| 86 | + | /// absent, then `id`. | |
| 87 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 88 | + | pub subject_claim: Option<String>, | |
| 89 | + | /// The userinfo claim that is their address; `email` when absent. | |
| 90 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 91 | + | pub email_claim: Option<String>, | |
| 92 | + | /// SAML: the provider's entity id (its issuer). | |
| 93 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 94 | + | pub idp_entity_id: Option<String>, | |
| 95 | + | /// SAML: where its single sign-on service takes requests. | |
| 96 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 97 | + | pub idp_sso_url: Option<String>, | |
| 98 | + | /// SAML: its signing certificates, base64 DER; more than one while a | |
| 99 | + | /// certificate is rotated. | |
| 100 | + | #[serde(default, skip_serializing_if = "Vec::is_empty")] | |
| 101 | + | pub certificates: Vec<String>, | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | /// A SAML signing certificate, as shown. | |
| 105 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 106 | + | pub struct SsoCertificate { | |
| 107 | + | /// SHA-256, `AB:CD:…`, as providers show it. | |
| 108 | + | pub fingerprint: String, | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | /// A workspace's connection to its provider, as its owners see it. | |
| 112 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 113 | + | pub struct SsoConnection { | |
| 114 | + | pub id: String, | |
| 115 | + | pub protocol: SsoProtocol, | |
| 116 | + | pub config: SsoConfig, | |
| 117 | + | /// Whether a client secret is kept; it is never shown. | |
| 118 | + | pub has_client_secret: bool, | |
| 119 | + | pub certificates: Vec<SsoCertificate>, | |
| 120 | + | /// Whether anyone the provider signs in may join as a member. | |
| 121 | + | pub allow_join: bool, | |
| 122 | + | /// When someone first signed in through it, and who (a username). | |
| 123 | + | pub tested_at: Option<String>, | |
| 124 | + | pub tested_by: Option<String>, | |
| 125 | + | pub created_at: String, | |
| 126 | + | pub updated_at: String, | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | /// Someone linked to who they are at the workspace's provider. | |
| 130 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 131 | + | pub struct SsoIdentity { | |
| 132 | + | pub username: String, | |
| 133 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 134 | + | pub display_username: Option<String>, | |
| 135 | + | /// The provider's id for them. | |
| 136 | + | pub subject: String, | |
| 137 | + | /// The address the provider gave, for showing. | |
| 138 | + | pub email: Option<String>, | |
| 139 | + | pub linked_at: String, | |
| 140 | + | pub last_used_at: String, | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | /// A workspace's single sign-on, for its settings page. | |
| 144 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 145 | + | pub struct SsoSettings { | |
| 146 | + | pub slug: String, | |
| 147 | + | pub connection: Option<SsoConnection>, | |
| 148 | + | /// Whether members must sign in through it. | |
| 149 | + | pub required: bool, | |
| 150 | + | /// Whether the person asking has signed in through it: an owner must | |
| 151 | + | /// before requiring it. | |
| 152 | + | pub you_linked: bool, | |
| 153 | + | pub identities: Vec<SsoIdentity>, | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | /// Whether a workspace has single sign-on, for anyone: the sign-in page | |
| 157 | + | /// and the notice for someone it holds out. | |
| 158 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 159 | + | pub struct SsoSummary { | |
| 160 | + | pub enabled: bool, | |
| 161 | + | pub protocol: Option<SsoProtocol>, | |
| 162 | + | pub required: bool, | |
| 163 | + | pub allow_join: bool, | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 167 | + | pub struct SsoSettingsArgs { | |
| 168 | + | pub actor: User, | |
| 169 | + | pub slug: String, | |
| 170 | + | #[serde(default)] | |
| 171 | + | pub surface: Option<Surface>, | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | /// `save_sso`: sets up or changes the workspace's connection. Owners only, | |
| 175 | + | /// on a paid plan. A secret or certificate left empty keeps the one saved. | |
| 176 | + | /// Changing which provider it is (protocol, issuer, entity id) unlinks | |
| 177 | + | /// everyone, stops requiring it, and needs a sign-in through it again. | |
| 178 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 179 | + | pub struct SaveSsoArgs { | |
| 180 | + | pub actor: User, | |
| 181 | + | pub slug: String, | |
| 182 | + | pub protocol: SsoProtocol, | |
| 183 | + | #[serde(default)] | |
| 184 | + | pub config: SsoConfig, | |
| 185 | + | #[serde(default)] | |
| 186 | + | pub client_secret: Option<String>, | |
| 187 | + | /// SAML: certificates as pasted (PEM). | |
| 188 | + | #[serde(default)] | |
| 189 | + | pub certificate: Option<String>, | |
| 190 | + | #[serde(default = "yes")] | |
| 191 | + | pub allow_join: bool, | |
| 192 | + | #[serde(default)] | |
| 193 | + | pub surface: Option<Surface>, | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | fn yes() -> bool { | |
| 197 | + | true | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | /// `set_sso_required`: whether members must sign in through the provider. | |
| 201 | + | /// Turning it on needs a paid plan and the owner to have signed in through | |
| 202 | + | /// it themselves; turning it off never does. | |
| 203 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 204 | + | pub struct SetSsoRequiredArgs { | |
| 205 | + | pub actor: User, | |
| 206 | + | pub slug: String, | |
| 207 | + | pub required: bool, | |
| 208 | + | #[serde(default)] | |
| 209 | + | pub surface: Option<Surface>, | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | /// `unlink_sso_identity`: forgets who someone is at the provider, so they | |
| 213 | + | /// link again (or another person can) the next time. Owners only. | |
| 214 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 215 | + | pub struct UnlinkSsoIdentityArgs { | |
| 216 | + | pub actor: User, | |
| 217 | + | pub slug: String, | |
| 218 | + | pub username: String, | |
| 219 | + | #[serde(default)] | |
| 220 | + | pub surface: Option<Surface>, | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | /// `sso_start`: the signed-in person sets off to the workspace's provider. | |
| 224 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 225 | + | pub struct SsoStartArgs { | |
| 226 | + | pub user: User, | |
| 227 | + | /// The session it is for: what comes back counts for it alone. | |
| 228 | + | pub session_token: String, | |
| 229 | + | pub slug: String, | |
| 230 | + | /// The callback (OpenID Connect, OAuth 2.0) or assertion consumer | |
| 231 | + | /// service (SAML), exactly as registered with the provider. | |
| 232 | + | pub redirect_uri: String, | |
| 233 | + | /// SAML: g1t's entity id for the workspace. | |
| 234 | + | #[serde(default)] | |
| 235 | + | pub entity_id: Option<String>, | |
| 236 | + | /// A same-site path to return to. | |
| 237 | + | #[serde(default)] | |
| 238 | + | pub next: String, | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 242 | + | pub struct SsoStart { | |
| 243 | + | /// Where to send the browser. | |
| 244 | + | pub url: String, | |
| 245 | + | /// OpenID Connect and OAuth 2.0: the state, for a cookie that binds | |
| 246 | + | /// the trip to this browser. Absent for SAML, whose response is posted | |
| 247 | + | /// cross-site, without cookies; its request id binds it instead. | |
| 248 | + | pub state: Option<String>, | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | /// `sso_finish`: the provider sent the browser back with a code. | |
| 252 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 253 | + | pub struct SsoFinishArgs { | |
| 254 | + | pub slug: String, | |
| 255 | + | pub session_token: String, | |
| 256 | + | pub state: String, | |
| 257 | + | pub code: String, | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | /// `sso_saml`: the provider posted its response. | |
| 261 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 262 | + | pub struct SsoSamlArgs { | |
| 263 | + | pub slug: String, | |
| 264 | + | pub saml_response: String, | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 268 | + | pub struct SsoFinished { | |
| 269 | + | pub slug: String, | |
| 270 | + | pub next: String, | |
| 271 | + | /// Whether they joined the workspace just now. | |
| 272 | + | pub joined: bool, | |
| 273 | + | /// Whether this was the first sign-in through the connection, which | |
| 274 | + | /// marks it tested. | |
| 275 | + | pub first: bool, | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | /// `sso_metadata`: g1t's SAML service provider metadata for a workspace. | |
| 279 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 280 | + | pub struct SsoMetadataArgs { | |
| 281 | + | pub entity_id: String, | |
| 282 | + | pub acs_url: String, | |
| 283 | + | } |
| 21 | 21 | "./coding-agents": "./src/coding-agents.ts", | |
| 22 | 22 | "./discovery": "./src/discovery.ts", | |
| 23 | 23 | "./telemetry": "./src/telemetry.ts", | |
| 24 | − | "./agent-setup": "./src/agent-setup.ts" | |
| 24 | + | "./agent-setup": "./src/agent-setup.ts", | |
| 25 | + | "./sso": "./src/sso.ts" | |
| 25 | 26 | }, | |
| 26 | 27 | "scripts": { | |
| 27 | 28 | "test": "node --test src/*.test.ts", |
| 53 | 53 | export * from "./search"; | |
| 54 | 54 | export * from "./security"; | |
| 55 | 55 | export * from "./security-suite"; | |
| 56 | + | export * from "./sso"; | |
| 56 | 57 | export * from "./status"; | |
| 57 | 58 | export * from "./teams"; | |
| 58 | 59 | export * from "./people"; |
| 33 | 33 | export type PolicyHold = { | |
| 34 | 34 | slug: string; | |
| 35 | 35 | reason: string; | |
| 36 | − | gap: "two_factor" | "verified_email" | "email_domain"; | |
| 36 | + | gap: "two_factor" | "verified_email" | "email_domain" | "sso"; | |
| 37 | 37 | }; |
| 1 | + | /** | |
| 2 | + | * Single sign-on: a workspace's own identity provider, over OpenID | |
| 3 | + | * Connect, OAuth 2.0 or SAML 2.0, on every paid plan. Mirrors | |
| 4 | + | * `crates/contracts/src/sso.rs`, which says how it works: it never signs | |
| 5 | + | * anyone in to g1t; a person signed in to g1t proves themselves to the | |
| 6 | + | * workspace's provider to use the workspace. | |
| 7 | + | */ | |
| 8 | + | import type { ServiceBinding } from "./clients"; | |
| 9 | + | import type { User } from "./identity"; | |
| 10 | + | import type { Result } from "./result"; | |
| 11 | + | ||
| 12 | + | export type SsoProtocol = "oidc" | "oauth2" | "saml"; | |
| 13 | + | ||
| 14 | + | /** What a connection needs, by protocol; it holds no secret. */ | |
| 15 | + | export type SsoConfig = { | |
| 16 | + | issuer?: string | null; | |
| 17 | + | client_id?: string | null; | |
| 18 | + | scopes?: string | null; | |
| 19 | + | authorization_url?: string | null; | |
| 20 | + | token_url?: string | null; | |
| 21 | + | userinfo_url?: string | null; | |
| 22 | + | subject_claim?: string | null; | |
| 23 | + | email_claim?: string | null; | |
| 24 | + | idp_entity_id?: string | null; | |
| 25 | + | idp_sso_url?: string | null; | |
| 26 | + | certificates?: string[]; | |
| 27 | + | }; | |
| 28 | + | ||
| 29 | + | export type SsoConnection = { | |
| 30 | + | id: string; | |
| 31 | + | protocol: SsoProtocol; | |
| 32 | + | config: SsoConfig; | |
| 33 | + | has_client_secret: boolean; | |
| 34 | + | /** SAML's signing certificates, by SHA-256 fingerprint. */ | |
| 35 | + | certificates: { fingerprint: string }[]; | |
| 36 | + | allow_join: boolean; | |
| 37 | + | tested_at: string | null; | |
| 38 | + | /** The username of whoever first signed in through it. */ | |
| 39 | + | tested_by: string | null; | |
| 40 | + | created_at: string; | |
| 41 | + | updated_at: string; | |
| 42 | + | }; | |
| 43 | + | ||
| 44 | + | export type SsoIdentity = { | |
| 45 | + | username: string; | |
| 46 | + | display_username?: string | null; | |
| 47 | + | subject: string; | |
| 48 | + | email: string | null; | |
| 49 | + | linked_at: string; | |
| 50 | + | last_used_at: string; | |
| 51 | + | }; | |
| 52 | + | ||
| 53 | + | export type SsoSettings = { | |
| 54 | + | slug: string; | |
| 55 | + | connection: SsoConnection | null; | |
| 56 | + | required: boolean; | |
| 57 | + | /** Whether the owner asking has signed in through it, which requiring it needs. */ | |
| 58 | + | you_linked: boolean; | |
| 59 | + | identities: SsoIdentity[]; | |
| 60 | + | }; | |
| 61 | + | ||
| 62 | + | export type SsoSummary = { | |
| 63 | + | enabled: boolean; | |
| 64 | + | protocol: SsoProtocol | null; | |
| 65 | + | required: boolean; | |
| 66 | + | allow_join: boolean; | |
| 67 | + | }; | |
| 68 | + | ||
| 69 | + | export type SsoFinished = { slug: string; next: string; joined: boolean; first: boolean }; | |
| 70 | + | ||
| 71 | + | export type SaveSso = { | |
| 72 | + | protocol: SsoProtocol; | |
| 73 | + | config: SsoConfig; | |
| 74 | + | /** Empty keeps the one saved. */ | |
| 75 | + | client_secret?: string | null; | |
| 76 | + | /** SAML certificates as pasted (PEM); empty keeps the ones saved. */ | |
| 77 | + | certificate?: string | null; | |
| 78 | + | allow_join: boolean; | |
| 79 | + | }; | |
| 80 | + | ||
| 81 | + | async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> { | |
| 82 | + | const response = await service.fetch(`https://service/rpc/${method}`, { | |
| 83 | + | method: "POST", | |
| 84 | + | headers: { "content-type": "application/json" }, | |
| 85 | + | body: JSON.stringify(args), | |
| 86 | + | }); | |
| 87 | + | if (!response.ok) throw new Error(`${method} failed with status ${response.status}`); | |
| 88 | + | return (await response.json()) as T; | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /** Single sign-on: methods of the identity service. */ | |
| 92 | + | export function ssoClient(identity: ServiceBinding) { | |
| 93 | + | const call = <T>(method: string, args: object) => rpc<T>(identity, method, args); | |
| 94 | + | return { | |
| 95 | + | settings: (actor: User, slug: string) => call<Result<SsoSettings>>("sso_settings", { actor, slug }), | |
| 96 | + | summary: (slug: string) => call<SsoSummary>("sso_summary", { slug }), | |
| 97 | + | save: (actor: User, slug: string, input: SaveSso) => | |
| 98 | + | call<Result<SsoSettings>>("save_sso", { actor, slug, surface: "web", ...input }), | |
| 99 | + | setRequired: (actor: User, slug: string, required: boolean) => | |
| 100 | + | call<Result<boolean>>("set_sso_required", { actor, slug, required, surface: "web" }), | |
| 101 | + | remove: (actor: User, slug: string) => call<Result<boolean>>("delete_sso", { actor, slug, surface: "web" }), | |
| 102 | + | unlink: (actor: User, slug: string, username: string) => | |
| 103 | + | call<Result<boolean>>("unlink_sso_identity", { actor, slug, username, surface: "web" }), | |
| 104 | + | start: (input: { user: User; session_token: string; slug: string; redirect_uri: string; entity_id?: string | null; next: string }) => | |
| 105 | + | call<Result<{ url: string; state: string | null }>>("sso_start", input), | |
| 106 | + | finish: (input: { slug: string; session_token: string; state: string; code: string }) => | |
| 107 | + | call<Result<SsoFinished>>("sso_finish", input), | |
| 108 | + | saml: (slug: string, samlResponse: string) => call<Result<SsoFinished>>("sso_saml", { slug, saml_response: samlResponse }), | |
| 109 | + | metadata: (entityId: string, acsUrl: string) => call<string>("sso_metadata", { entity_id: entityId, acs_url: acsUrl }), | |
| 110 | + | }; | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | /** | |
| 114 | + | * Where a workspace's provider is pointed, from the site's origin: g1t's | |
| 115 | + | * entity id (its metadata's address), the assertion consumer service, and | |
| 116 | + | * the callback for OpenID Connect and OAuth 2.0. | |
| 117 | + | */ | |
| 118 | + | export function ssoAddresses(origin: string, slug: string) { | |
| 119 | + | const base = `${origin.replace(/\/+$/, "")}/auth/sso/${encodeURIComponent(slug.toLowerCase())}`; | |
| 120 | + | return { entityId: `${base}/metadata`, metadataUrl: `${base}/metadata`, acsUrl: `${base}/saml`, callbackUrl: `${base}/callback` }; | |
| 121 | + | } |
| 22 | 22 | sha1 = "0.10" | |
| 23 | 23 | pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] } | |
| 24 | 24 | sha2 = "0.10" | |
| 25 | + | roxmltree = "0.20" | |
| 26 | + | miniz_oxide = "0.9" | |
| 27 | + | rsa = { version = "0.9", default-features = false, features = ["std", "sha2"] } | |
| 25 | 28 | ||
| 26 | 29 | # wasm-opt at -O1: about the same gzipped size as -O in a tenth of the | |
| 27 | 30 | # time. |
Comments
Sign in to comment.