collin/anvil · 18e442f7
Replace passkey sign-in with OIDC single sign-on
Collin Richards · 2026-08-18 14:31 UTC · 18e442f7a5fdffb13c441320e35224938e3116d2 · parent 9162f830 · browse files
modified.gitignore+2 −0
| ⋯ 4 unchanged lines | |||
| 5 | 5 | *.db-shm | |
| 6 | 6 | anvil.toml | |
| 7 | 7 | /deploy/anvild | |
| 8 | + | # CA bundle deploy/dev.sh builds so the dev container trusts portless (docs/oidc.md). | |
| 9 | + | /deploy/dev-ca.crt | |
| 8 | 10 | # Local-only API credentials for fetching attachments (never committed). | |
| 9 | 11 | /.anvil-credentials | |
modifiedCargo.lock+50 −382
| ⋯ 165 unchanged lines | |||
| 166 | 166 | "argon2 0.5.3", | |
| 167 | 167 | "async-trait", | |
| 168 | 168 | "base64", | |
| 169 | - | "curve25519-dalek 5.0.0-rc.0", | |
| 169 | + | "curve25519-dalek", | |
| 170 | 170 | "gix", | |
| 171 | 171 | "hmac 0.12.1", | |
| 172 | 172 | "pulldown-cmark", | |
| ⋯ 9 unchanged lines | |||
| 182 | 182 | "tokio", | |
| 183 | 183 | "toml", | |
| 184 | 184 | "tracing", | |
| 185 | - | "webauthn_rp", | |
| 186 | 185 | ] | |
| 187 | 186 | ||
| 188 | 187 | [[package]] | |
| ⋯ 34 unchanged lines | |||
| 223 | 222 | "axum", | |
| 224 | 223 | "axum-extra", | |
| 225 | 224 | "base64", | |
| 226 | - | "ciborium", | |
| 227 | 225 | "lru", | |
| 228 | 226 | "maud", | |
| 229 | - | "p256 0.13.2", | |
| 230 | 227 | "pulldown-cmark", | |
| 228 | + | "reqwest", | |
| 229 | + | "ring", | |
| 230 | + | "rsa 0.9.10", | |
| 231 | + | "rustls", | |
| 231 | 232 | "serde", | |
| 232 | 233 | "serde_json", | |
| 233 | 234 | "sha2 0.10.9", | |
| ⋯ 7 unchanged lines | |||
| 241 | 242 | "tower", | |
| 242 | 243 | "tower-http", | |
| 243 | 244 | "tracing", | |
| 244 | - | "webauthn_rp", | |
| 245 | 245 | ] | |
| 246 | 246 | ||
| 247 | 247 | [[package]] | |
| ⋯ 141 unchanged lines | |||
| 389 | 389 | ||
| 390 | 390 | [[package]] | |
| 391 | 391 | name = "base16ct" | |
| 392 | - | version = "0.2.0" | |
| 393 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 394 | - | checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" | |
| 395 | - | ||
| 396 | - | [[package]] | |
| 397 | - | name = "base16ct" | |
| 398 | 392 | version = "1.0.0" | |
| 399 | 393 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 400 | 394 | checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" | |
| ⋯ 257 unchanged lines | |||
| 658 | 652 | ] | |
| 659 | 653 | ||
| 660 | 654 | [[package]] | |
| 661 | - | name = "ciborium" | |
| 662 | - | version = "0.2.2" | |
| 663 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 664 | - | checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" | |
| 665 | - | dependencies = [ | |
| 666 | - | "ciborium-io", | |
| 667 | - | "ciborium-ll", | |
| 668 | - | "serde", | |
| 669 | - | ] | |
| 670 | - | ||
| 671 | - | [[package]] | |
| 672 | - | name = "ciborium-io" | |
| 673 | - | version = "0.2.2" | |
| 674 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 675 | - | checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" | |
| 676 | - | ||
| 677 | - | [[package]] | |
| 678 | - | name = "ciborium-ll" | |
| 679 | - | version = "0.2.2" | |
| 680 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 681 | - | checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" | |
| 682 | - | dependencies = [ | |
| 683 | - | "ciborium-io", | |
| 684 | - | "half", | |
| 685 | - | ] | |
| 686 | - | ||
| 687 | - | [[package]] | |
| 688 | 655 | name = "cipher" | |
| 689 | 656 | version = "0.5.2" | |
| 690 | 657 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 154 unchanged lines | |||
| 845 | 812 | checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" | |
| 846 | 813 | ||
| 847 | 814 | [[package]] | |
| 848 | - | name = "crunchy" | |
| 849 | - | version = "0.2.4" | |
| 850 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 851 | - | checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" | |
| 852 | - | ||
| 853 | - | [[package]] | |
| 854 | - | name = "crypto-bigint" | |
| 855 | - | version = "0.5.5" | |
| 856 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 857 | - | checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" | |
| 858 | - | dependencies = [ | |
| 859 | - | "generic-array 0.14.7", | |
| 860 | - | "rand_core 0.6.4", | |
| 861 | - | "subtle", | |
| 862 | - | "zeroize", | |
| 863 | - | ] | |
| 864 | - | ||
| 865 | - | [[package]] | |
| 866 | 815 | name = "crypto-bigint" | |
| 867 | 816 | version = "0.7.3" | |
| 868 | 817 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 37 unchanged lines | |||
| 906 | 855 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 907 | 856 | checksum = "21f41f23de7d24cdbda7f0c4d9c0351f99a4ceb258ef30e5c1927af8987ffe5a" | |
| 908 | 857 | dependencies = [ | |
| 909 | - | "crypto-bigint 0.7.3", | |
| 858 | + | "crypto-bigint", | |
| 910 | 859 | "libm", | |
| 911 | 860 | "rand_core 0.10.1", | |
| 912 | 861 | ] | |
| ⋯ 19 unchanged lines | |||
| 932 | 881 | ||
| 933 | 882 | [[package]] | |
| 934 | 883 | name = "curve25519-dalek" | |
| 935 | - | version = "4.1.3" | |
| 936 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 937 | - | checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" | |
| 938 | - | dependencies = [ | |
| 939 | - | "cfg-if", | |
| 940 | - | "cpufeatures 0.2.17", | |
| 941 | - | "curve25519-dalek-derive", | |
| 942 | - | "digest 0.10.7", | |
| 943 | - | "fiat-crypto 0.2.9", | |
| 944 | - | "rustc_version", | |
| 945 | - | "subtle", | |
| 946 | - | ] | |
| 947 | - | ||
| 948 | - | [[package]] | |
| 949 | - | name = "curve25519-dalek" | |
| 950 | 884 | version = "5.0.0-rc.0" | |
| 951 | 885 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 952 | 886 | checksum = "4f359e08ca85e7bd759e1fd933ff2bccd81864c60a8fba0e259c7f822b0924bf" | |
| ⋯ 2 unchanged lines | |||
| 955 | 889 | "cpufeatures 0.3.0", | |
| 956 | 890 | "curve25519-dalek-derive", | |
| 957 | 891 | "digest 0.11.3", | |
| 958 | - | "fiat-crypto 0.3.0", | |
| 892 | + | "fiat-crypto", | |
| 959 | 893 | "rustc_version", | |
| 960 | 894 | "subtle", | |
| 961 | 895 | "zeroize", | |
| ⋯ 151 unchanged lines | |||
| 1113 | 1047 | ||
| 1114 | 1048 | [[package]] | |
| 1115 | 1049 | name = "ecdsa" | |
| 1116 | - | version = "0.16.9" | |
| 1117 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1118 | - | checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" | |
| 1119 | - | dependencies = [ | |
| 1120 | - | "der 0.7.10", | |
| 1121 | - | "digest 0.10.7", | |
| 1122 | - | "elliptic-curve 0.13.8", | |
| 1123 | - | "rfc6979 0.4.0", | |
| 1124 | - | "signature 2.2.0", | |
| 1125 | - | "spki 0.7.3", | |
| 1126 | - | ] | |
| 1127 | - | ||
| 1128 | - | [[package]] | |
| 1129 | - | name = "ecdsa" | |
| 1130 | 1050 | version = "0.17.0-rc.18" | |
| 1131 | 1051 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1132 | 1052 | checksum = "54fb064faabbee66e1fc8e5c5a9458d4269dc2d8b638fe86a425adb2510d1a96" | |
| 1133 | 1053 | dependencies = [ | |
| 1134 | 1054 | "der 0.8.0", | |
| 1135 | 1055 | "digest 0.11.3", | |
| 1136 | - | "elliptic-curve 0.14.0-rc.33", | |
| 1137 | - | "rfc6979 0.5.0", | |
| 1056 | + | "elliptic-curve", | |
| 1057 | + | "rfc6979", | |
| 1138 | 1058 | "signature 3.0.0", | |
| 1139 | 1059 | "spki 0.8.0", | |
| 1140 | 1060 | "zeroize", | |
| ⋯ 1 unchanged line | |||
| 1142 | 1062 | ||
| 1143 | 1063 | [[package]] | |
| 1144 | 1064 | name = "ed25519" | |
| 1145 | - | version = "2.2.3" | |
| 1146 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1147 | - | checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" | |
| 1148 | - | dependencies = [ | |
| 1149 | - | "signature 2.2.0", | |
| 1150 | - | ] | |
| 1151 | - | ||
| 1152 | - | [[package]] | |
| 1153 | - | name = "ed25519" | |
| 1154 | 1065 | version = "3.0.0" | |
| 1155 | 1066 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1156 | 1067 | checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" | |
| ⋯ 4 unchanged lines | |||
| 1161 | 1072 | ||
| 1162 | 1073 | [[package]] | |
| 1163 | 1074 | name = "ed25519-dalek" | |
| 1164 | - | version = "2.2.0" | |
| 1165 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1166 | - | checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" | |
| 1167 | - | dependencies = [ | |
| 1168 | - | "curve25519-dalek 4.1.3", | |
| 1169 | - | "ed25519 2.2.3", | |
| 1170 | - | "sha2 0.10.9", | |
| 1171 | - | "subtle", | |
| 1172 | - | ] | |
| 1173 | - | ||
| 1174 | - | [[package]] | |
| 1175 | - | name = "ed25519-dalek" | |
| 1176 | 1075 | version = "3.0.0-rc.0" | |
| 1177 | 1076 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1178 | 1077 | checksum = "b011170fe4f04665565b4110afef66774fe9ffff278f3eb5b81cc73d26e27d60" | |
| 1179 | 1078 | dependencies = [ | |
| 1180 | - | "curve25519-dalek 5.0.0-rc.0", | |
| 1181 | - | "ed25519 3.0.0", | |
| 1079 | + | "curve25519-dalek", | |
| 1080 | + | "ed25519", | |
| 1182 | 1081 | "rand_core 0.10.1", | |
| 1183 | 1082 | "serde", | |
| 1184 | 1083 | "sha2 0.11.0", | |
| ⋯ 4 unchanged lines | |||
| 1189 | 1088 | ||
| 1190 | 1089 | [[package]] | |
| 1191 | 1090 | name = "elliptic-curve" | |
| 1192 | - | version = "0.13.8" | |
| 1193 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1194 | - | checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" | |
| 1195 | - | dependencies = [ | |
| 1196 | - | "base16ct 0.2.0", | |
| 1197 | - | "crypto-bigint 0.5.5", | |
| 1198 | - | "digest 0.10.7", | |
| 1199 | - | "ff 0.13.1", | |
| 1200 | - | "generic-array 0.14.7", | |
| 1201 | - | "group 0.13.0", | |
| 1202 | - | "pem-rfc7468 0.7.0", | |
| 1203 | - | "pkcs8 0.10.2", | |
| 1204 | - | "rand_core 0.6.4", | |
| 1205 | - | "sec1 0.7.3", | |
| 1206 | - | "subtle", | |
| 1207 | - | "zeroize", | |
| 1208 | - | ] | |
| 1209 | - | ||
| 1210 | - | [[package]] | |
| 1211 | - | name = "elliptic-curve" | |
| 1212 | 1091 | version = "0.14.0-rc.33" | |
| 1213 | 1092 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1214 | 1093 | checksum = "102d3643d30dd8b559613c5cced68317199597fffb278cdc88daa2ef7fafc935" | |
| 1215 | 1094 | dependencies = [ | |
| 1216 | - | "base16ct 1.0.0", | |
| 1217 | - | "crypto-bigint 0.7.3", | |
| 1095 | + | "base16ct", | |
| 1096 | + | "crypto-bigint", | |
| 1218 | 1097 | "crypto-common 0.2.2", | |
| 1219 | 1098 | "digest 0.11.3", | |
| 1220 | - | "ff 0.14.0", | |
| 1221 | - | "group 0.14.0", | |
| 1099 | + | "ff", | |
| 1100 | + | "group", | |
| 1222 | 1101 | "hkdf", | |
| 1223 | 1102 | "hybrid-array", | |
| 1224 | 1103 | "once_cell", | |
| 1225 | 1104 | "pem-rfc7468 1.0.0", | |
| 1226 | 1105 | "pkcs8 0.11.0", | |
| 1227 | 1106 | "rand_core 0.10.1", | |
| 1228 | - | "sec1 0.8.1", | |
| 1107 | + | "sec1", | |
| 1229 | 1108 | "subtle", | |
| 1230 | 1109 | "zeroize", | |
| 1231 | 1110 | ] | |
| ⋯ 65 unchanged lines | |||
| 1297 | 1176 | ||
| 1298 | 1177 | [[package]] | |
| 1299 | 1178 | name = "ff" | |
| 1300 | - | version = "0.13.1" | |
| 1301 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1302 | - | checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" | |
| 1303 | - | dependencies = [ | |
| 1304 | - | "rand_core 0.6.4", | |
| 1305 | - | "subtle", | |
| 1306 | - | ] | |
| 1307 | - | ||
| 1308 | - | [[package]] | |
| 1309 | - | name = "ff" | |
| 1310 | 1179 | version = "0.14.0" | |
| 1311 | 1180 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1312 | 1181 | checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" | |
| ⋯ 4 unchanged lines | |||
| 1317 | 1186 | ||
| 1318 | 1187 | [[package]] | |
| 1319 | 1188 | name = "fiat-crypto" | |
| 1320 | - | version = "0.2.9" | |
| 1321 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1322 | - | checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" | |
| 1323 | - | ||
| 1324 | - | [[package]] | |
| 1325 | - | name = "fiat-crypto" | |
| 1326 | 1189 | version = "0.3.0" | |
| 1327 | 1190 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1328 | 1191 | checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" | |
| ⋯ 147 unchanged lines | |||
| 1476 | 1339 | dependencies = [ | |
| 1477 | 1340 | "typenum", | |
| 1478 | 1341 | "version_check", | |
| 1479 | - | "zeroize", | |
| 1480 | 1342 | ] | |
| 1481 | 1343 | ||
| 1482 | 1344 | [[package]] | |
| ⋯ 20 unchanged lines | |||
| 1503 | 1365 | ||
| 1504 | 1366 | [[package]] | |
| 1505 | 1367 | name = "getrandom" | |
| 1506 | - | version = "0.3.4" | |
| 1507 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1508 | - | checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" | |
| 1509 | - | dependencies = [ | |
| 1510 | - | "cfg-if", | |
| 1511 | - | "libc", | |
| 1512 | - | "r-efi 5.3.0", | |
| 1513 | - | "wasip2", | |
| 1514 | - | ] | |
| 1515 | - | ||
| 1516 | - | [[package]] | |
| 1517 | - | name = "getrandom" | |
| 1518 | 1368 | version = "0.4.2" | |
| 1519 | 1369 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1520 | 1370 | checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" | |
| ⋯ 1 unchanged line | |||
| 1522 | 1372 | "cfg-if", | |
| 1523 | 1373 | "js-sys", | |
| 1524 | 1374 | "libc", | |
| 1525 | - | "r-efi 6.0.0", | |
| 1375 | + | "r-efi", | |
| 1526 | 1376 | "rand_core 0.10.1", | |
| 1527 | 1377 | "wasip2", | |
| 1528 | 1378 | "wasip3", | |
| ⋯ 756 unchanged lines | |||
| 2285 | 2135 | ||
| 2286 | 2136 | [[package]] | |
| 2287 | 2137 | name = "group" | |
| 2288 | - | version = "0.13.0" | |
| 2289 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2290 | - | checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" | |
| 2291 | - | dependencies = [ | |
| 2292 | - | "ff 0.13.1", | |
| 2293 | - | "rand_core 0.6.4", | |
| 2294 | - | "subtle", | |
| 2295 | - | ] | |
| 2296 | - | ||
| 2297 | - | [[package]] | |
| 2298 | - | name = "group" | |
| 2299 | 2138 | version = "0.14.0" | |
| 2300 | 2139 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2301 | 2140 | checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" | |
| 2302 | 2141 | dependencies = [ | |
| 2303 | - | "ff 0.14.0", | |
| 2142 | + | "ff", | |
| 2304 | 2143 | "rand_core 0.10.1", | |
| 2305 | 2144 | "subtle", | |
| 2306 | 2145 | ] | |
| 2307 | 2146 | ||
| 2308 | 2147 | [[package]] | |
| 2309 | - | name = "half" | |
| 2310 | - | version = "2.7.1" | |
| 2311 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2312 | - | checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" | |
| 2313 | - | dependencies = [ | |
| 2314 | - | "cfg-if", | |
| 2315 | - | "crunchy", | |
| 2316 | - | "zerocopy", | |
| 2317 | - | ] | |
| 2318 | - | ||
| 2319 | - | [[package]] | |
| 2320 | 2148 | name = "hash32" | |
| 2321 | 2149 | version = "0.3.1" | |
| 2322 | 2150 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 915 unchanged lines | |||
| 3238 | 3066 | ||
| 3239 | 3067 | [[package]] | |
| 3240 | 3068 | name = "p256" | |
| 3241 | - | version = "0.13.2" | |
| 3242 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3243 | - | checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" | |
| 3244 | - | dependencies = [ | |
| 3245 | - | "ecdsa 0.16.9", | |
| 3246 | - | "elliptic-curve 0.13.8", | |
| 3247 | - | "primeorder 0.13.6", | |
| 3248 | - | "sha2 0.10.9", | |
| 3249 | - | ] | |
| 3250 | - | ||
| 3251 | - | [[package]] | |
| 3252 | - | name = "p256" | |
| 3253 | 3069 | version = "0.14.0-rc.10" | |
| 3254 | 3070 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3255 | 3071 | checksum = "41adc63effe99d48837a8cc0e6d7a77e32ae6a07f6000df466178dbc2193093e" | |
| 3256 | 3072 | dependencies = [ | |
| 3257 | - | "ecdsa 0.17.0-rc.18", | |
| 3258 | - | "elliptic-curve 0.14.0-rc.33", | |
| 3073 | + | "ecdsa", | |
| 3074 | + | "elliptic-curve", | |
| 3259 | 3075 | "primefield", | |
| 3260 | - | "primeorder 0.14.0-rc.10", | |
| 3076 | + | "primeorder", | |
| 3261 | 3077 | "sha2 0.11.0", | |
| 3262 | 3078 | ] | |
| 3263 | 3079 | ||
| 3264 | 3080 | [[package]] | |
| 3265 | 3081 | name = "p384" | |
| 3266 | - | version = "0.13.1" | |
| 3267 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3268 | - | checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" | |
| 3269 | - | dependencies = [ | |
| 3270 | - | "ecdsa 0.16.9", | |
| 3271 | - | "elliptic-curve 0.13.8", | |
| 3272 | - | "primeorder 0.13.6", | |
| 3273 | - | "sha2 0.10.9", | |
| 3274 | - | ] | |
| 3275 | - | ||
| 3276 | - | [[package]] | |
| 3277 | - | name = "p384" | |
| 3278 | 3082 | version = "0.14.0-rc.10" | |
| 3279 | 3083 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3280 | 3084 | checksum = "9bd5333afa5ae0347f39e6a0f2c9c155da431583fd71fe5555bd0521b4ccaf02" | |
| 3281 | 3085 | dependencies = [ | |
| 3282 | - | "ecdsa 0.17.0-rc.18", | |
| 3283 | - | "elliptic-curve 0.14.0-rc.33", | |
| 3284 | - | "fiat-crypto 0.3.0", | |
| 3086 | + | "ecdsa", | |
| 3087 | + | "elliptic-curve", | |
| 3088 | + | "fiat-crypto", | |
| 3285 | 3089 | "primefield", | |
| 3286 | - | "primeorder 0.14.0-rc.10", | |
| 3090 | + | "primeorder", | |
| 3287 | 3091 | "sha2 0.11.0", | |
| 3288 | 3092 | ] | |
| 3289 | 3093 | ||
| ⋯ 3 unchanged lines | |||
| 3293 | 3097 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3294 | 3098 | checksum = "a3a5297f53dc16d35909060ba3032cff7867e8809f01e273ff325579d5f0ceae" | |
| 3295 | 3099 | dependencies = [ | |
| 3296 | - | "base16ct 1.0.0", | |
| 3297 | - | "ecdsa 0.17.0-rc.18", | |
| 3298 | - | "elliptic-curve 0.14.0-rc.33", | |
| 3100 | + | "base16ct", | |
| 3101 | + | "ecdsa", | |
| 3102 | + | "elliptic-curve", | |
| 3299 | 3103 | "primefield", | |
| 3300 | - | "primeorder 0.14.0-rc.10", | |
| 3104 | + | "primeorder", | |
| 3301 | 3105 | "sha2 0.11.0", | |
| 3302 | 3106 | ] | |
| 3303 | 3107 | ||
| ⋯ 3 unchanged lines | |||
| 3307 | 3111 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3308 | 3112 | checksum = "4f3a5ae18f65a85c67a77d18d42d3606c07948e3c17c1e5f74852b26589e88a5" | |
| 3309 | 3113 | dependencies = [ | |
| 3310 | - | "base16ct 1.0.0", | |
| 3114 | + | "base16ct", | |
| 3311 | 3115 | "byteorder", | |
| 3312 | 3116 | "bytes", | |
| 3313 | 3117 | "delegate", | |
| ⋯ 250 unchanged lines | |||
| 3564 | 3368 | ] | |
| 3565 | 3369 | ||
| 3566 | 3370 | [[package]] | |
| 3567 | - | name = "precis-core" | |
| 3568 | - | version = "0.1.11" | |
| 3569 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3570 | - | checksum = "9c2e7b31f132e0c6f8682cfb7bf4a5340dbe925b7986618d0826a56dfe0c8e56" | |
| 3571 | - | dependencies = [ | |
| 3572 | - | "precis-tools", | |
| 3573 | - | "ucd-parse", | |
| 3574 | - | "unicode-normalization", | |
| 3575 | - | ] | |
| 3576 | - | ||
| 3577 | - | [[package]] | |
| 3578 | - | name = "precis-profiles" | |
| 3579 | - | version = "0.1.13" | |
| 3580 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3581 | - | checksum = "31e2768890a47af73a032af9f0cedbddce3c9d06cf8de201d5b8f2436ded7674" | |
| 3582 | - | dependencies = [ | |
| 3583 | - | "lazy_static", | |
| 3584 | - | "precis-core", | |
| 3585 | - | "precis-tools", | |
| 3586 | - | "unicode-normalization", | |
| 3587 | - | ] | |
| 3588 | - | ||
| 3589 | - | [[package]] | |
| 3590 | - | name = "precis-tools" | |
| 3591 | - | version = "0.1.9" | |
| 3592 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3593 | - | checksum = "6cc1eb2d5887ac7bfd2c0b745764db89edb84b856e4214e204ef48ef96d10c4a" | |
| 3594 | - | dependencies = [ | |
| 3595 | - | "lazy_static", | |
| 3596 | - | "regex", | |
| 3597 | - | "ucd-parse", | |
| 3598 | - | ] | |
| 3599 | - | ||
| 3600 | - | [[package]] | |
| 3601 | 3371 | name = "prettyplease" | |
| 3602 | 3372 | version = "0.2.37" | |
| 3603 | 3373 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 9 unchanged lines | |||
| 3613 | 3383 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3614 | 3384 | checksum = "f845ec3240cd5ed5e1e31cf3ff633a5bf47c698dc4092ba9e767415b3d393406" | |
| 3615 | 3385 | dependencies = [ | |
| 3616 | - | "crypto-bigint 0.7.3", | |
| 3386 | + | "crypto-bigint", | |
| 3617 | 3387 | "crypto-common 0.2.2", | |
| 3618 | - | "ff 0.14.0", | |
| 3388 | + | "ff", | |
| 3619 | 3389 | "rand_core 0.10.1", | |
| 3620 | 3390 | "subtle", | |
| 3621 | 3391 | "zeroize", | |
| ⋯ 1 unchanged line | |||
| 3623 | 3393 | ||
| 3624 | 3394 | [[package]] | |
| 3625 | 3395 | name = "primeorder" | |
| 3626 | - | version = "0.13.6" | |
| 3627 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3628 | - | checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" | |
| 3629 | - | dependencies = [ | |
| 3630 | - | "elliptic-curve 0.13.8", | |
| 3631 | - | ] | |
| 3632 | - | ||
| 3633 | - | [[package]] | |
| 3634 | - | name = "primeorder" | |
| 3635 | 3396 | version = "0.14.0-rc.10" | |
| 3636 | 3397 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3637 | 3398 | checksum = "7d2793f22b9b6fd11ef3ac1d59bf003c2573593e4968702341605c2748fd90bf" | |
| 3638 | 3399 | dependencies = [ | |
| 3639 | - | "elliptic-curve 0.14.0-rc.33", | |
| 3400 | + | "elliptic-curve", | |
| 3640 | 3401 | ] | |
| 3641 | 3402 | ||
| 3642 | 3403 | [[package]] | |
| ⋯ 64 unchanged lines | |||
| 3707 | 3468 | ||
| 3708 | 3469 | [[package]] | |
| 3709 | 3470 | name = "r-efi" | |
| 3710 | - | version = "5.3.0" | |
| 3711 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3712 | - | checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" | |
| 3713 | - | ||
| 3714 | - | [[package]] | |
| 3715 | - | name = "r-efi" | |
| 3716 | 3471 | version = "6.0.0" | |
| 3717 | 3472 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3718 | 3473 | checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" | |
| ⋯ 4 unchanged lines | |||
| 3723 | 3478 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3724 | 3479 | checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" | |
| 3725 | 3480 | dependencies = [ | |
| 3726 | - | "rand_chacha 0.3.1", | |
| 3481 | + | "rand_chacha", | |
| 3727 | 3482 | "rand_core 0.6.4", | |
| 3728 | 3483 | ] | |
| 3729 | 3484 | ||
| 3730 | 3485 | [[package]] | |
| 3731 | 3486 | name = "rand" | |
| 3732 | - | version = "0.9.5" | |
| 3733 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3734 | - | checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" | |
| 3735 | - | dependencies = [ | |
| 3736 | - | "rand_chacha 0.9.0", | |
| 3737 | - | "rand_core 0.9.5", | |
| 3738 | - | ] | |
| 3739 | - | ||
| 3740 | - | [[package]] | |
| 3741 | - | name = "rand" | |
| 3742 | 3487 | version = "0.10.1" | |
| 3743 | 3488 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3744 | 3489 | checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" | |
| ⋯ 14 unchanged lines | |||
| 3759 | 3504 | ] | |
| 3760 | 3505 | ||
| 3761 | 3506 | [[package]] | |
| 3762 | - | name = "rand_chacha" | |
| 3763 | - | version = "0.9.0" | |
| 3764 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3765 | - | checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" | |
| 3766 | - | dependencies = [ | |
| 3767 | - | "ppv-lite86", | |
| 3768 | - | "rand_core 0.9.5", | |
| 3769 | - | ] | |
| 3770 | - | ||
| 3771 | - | [[package]] | |
| 3772 | 3507 | name = "rand_core" | |
| 3773 | 3508 | version = "0.6.4" | |
| 3774 | 3509 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 4 unchanged lines | |||
| 3779 | 3514 | ||
| 3780 | 3515 | [[package]] | |
| 3781 | 3516 | name = "rand_core" | |
| 3782 | - | version = "0.9.5" | |
| 3783 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3784 | - | checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" | |
| 3785 | - | dependencies = [ | |
| 3786 | - | "getrandom 0.3.4", | |
| 3787 | - | ] | |
| 3788 | - | ||
| 3789 | - | [[package]] | |
| 3790 | - | name = "rand_core" | |
| 3791 | 3517 | version = "0.10.1" | |
| 3792 | 3518 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3793 | 3519 | checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" | |
| ⋯ 49 unchanged lines | |||
| 3843 | 3569 | "memchr", | |
| 3844 | 3570 | "regex-syntax", | |
| 3845 | 3571 | ] | |
| 3846 | - | ||
| 3847 | - | [[package]] | |
| 3848 | - | name = "regex-lite" | |
| 3849 | - | version = "0.1.9" | |
| 3850 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3851 | - | checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" | |
| 3852 | 3572 | ||
| 3853 | 3573 | [[package]] | |
| 3854 | 3574 | name = "regex-syntax" | |
| ⋯ 40 unchanged lines | |||
| 3895 | 3615 | ||
| 3896 | 3616 | [[package]] | |
| 3897 | 3617 | name = "rfc6979" | |
| 3898 | - | version = "0.4.0" | |
| 3899 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3900 | - | checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" | |
| 3901 | - | dependencies = [ | |
| 3902 | - | "hmac 0.12.1", | |
| 3903 | - | "subtle", | |
| 3904 | - | ] | |
| 3905 | - | ||
| 3906 | - | [[package]] | |
| 3907 | - | name = "rfc6979" | |
| 3908 | 3618 | version = "0.5.0" | |
| 3909 | 3619 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3910 | 3620 | checksum = "5236ce872cac07e0fb3969b0cbf468c7d2f37d432f1b627dcb7b8d34563fb0c3" | |
| ⋯ 41 unchanged lines | |||
| 3952 | 3662 | "pkcs1 0.7.5", | |
| 3953 | 3663 | "pkcs8 0.10.2", | |
| 3954 | 3664 | "rand_core 0.6.4", | |
| 3955 | - | "sha2 0.10.9", | |
| 3956 | 3665 | "signature 2.2.0", | |
| 3957 | 3666 | "spki 0.7.3", | |
| 3958 | 3667 | "subtle", | |
| ⋯ 7 unchanged lines | |||
| 3966 | 3675 | checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" | |
| 3967 | 3676 | dependencies = [ | |
| 3968 | 3677 | "const-oid 0.10.2", | |
| 3969 | - | "crypto-bigint 0.7.3", | |
| 3678 | + | "crypto-bigint", | |
| 3970 | 3679 | "crypto-primes", | |
| 3971 | 3680 | "digest 0.11.3", | |
| 3972 | 3681 | "pkcs1 0.8.0-rc.4", | |
| ⋯ 53 unchanged lines | |||
| 4026 | 3735 | "bytes", | |
| 4027 | 3736 | "cbc", | |
| 4028 | 3737 | "cipher", | |
| 4029 | - | "crypto-bigint 0.7.3", | |
| 3738 | + | "crypto-bigint", | |
| 4030 | 3739 | "ctr", | |
| 4031 | - | "curve25519-dalek 5.0.0-rc.0", | |
| 3740 | + | "curve25519-dalek", | |
| 4032 | 3741 | "data-encoding", | |
| 4033 | 3742 | "delegate", | |
| 4034 | 3743 | "der 0.8.0", | |
| 4035 | 3744 | "digest 0.11.3", | |
| 4036 | - | "ecdsa 0.17.0-rc.18", | |
| 4037 | - | "ed25519-dalek 3.0.0-rc.0", | |
| 4038 | - | "elliptic-curve 0.14.0-rc.33", | |
| 3745 | + | "ecdsa", | |
| 3746 | + | "ed25519-dalek", | |
| 3747 | + | "elliptic-curve", | |
| 4039 | 3748 | "enum_dispatch", | |
| 4040 | 3749 | "flate2", | |
| 4041 | 3750 | "futures", | |
| ⋯ 10 unchanged lines | |||
| 4052 | 3761 | "ml-kem", | |
| 4053 | 3762 | "module-lattice", | |
| 4054 | 3763 | "num-bigint", | |
| 4055 | - | "p256 0.14.0-rc.10", | |
| 4056 | - | "p384 0.14.0-rc.10", | |
| 3764 | + | "p256", | |
| 3765 | + | "p384", | |
| 4057 | 3766 | "p521", | |
| 4058 | 3767 | "pageant", | |
| 4059 | 3768 | "pbkdf2", | |
| ⋯ 9 unchanged lines | |||
| 4069 | 3778 | "russh-util", | |
| 4070 | 3779 | "salsa20", | |
| 4071 | 3780 | "scrypt", | |
| 4072 | - | "sec1 0.8.1", | |
| 3781 | + | "sec1", | |
| 4073 | 3782 | "sha1 0.11.0", | |
| 4074 | 3783 | "sha2 0.11.0", | |
| 4075 | 3784 | "sha3", | |
| ⋯ 186 unchanged lines | |||
| 4262 | 3971 | ||
| 4263 | 3972 | [[package]] | |
| 4264 | 3973 | name = "sec1" | |
| 4265 | - | version = "0.7.3" | |
| 4266 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4267 | - | checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" | |
| 4268 | - | dependencies = [ | |
| 4269 | - | "base16ct 0.2.0", | |
| 4270 | - | "der 0.7.10", | |
| 4271 | - | "generic-array 0.14.7", | |
| 4272 | - | "pkcs8 0.10.2", | |
| 4273 | - | "subtle", | |
| 4274 | - | "zeroize", | |
| 4275 | - | ] | |
| 4276 | - | ||
| 4277 | - | [[package]] | |
| 4278 | - | name = "sec1" | |
| 4279 | 3974 | version = "0.8.1" | |
| 4280 | 3975 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4281 | 3976 | checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" | |
| 4282 | 3977 | dependencies = [ | |
| 4283 | - | "base16ct 1.0.0", | |
| 3978 | + | "base16ct", | |
| 4284 | 3979 | "ctutils", | |
| 4285 | 3980 | "der 0.8.0", | |
| 4286 | 3981 | "hybrid-array", | |
| ⋯ 154 unchanged lines | |||
| 4441 | 4136 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4442 | 4137 | checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" | |
| 4443 | 4138 | dependencies = [ | |
| 4444 | - | "base16ct 1.0.0", | |
| 4139 | + | "base16ct", | |
| 4445 | 4140 | "serde", | |
| 4446 | 4141 | ] | |
| 4447 | 4142 | ||
| ⋯ 212 unchanged lines | |||
| 4660 | 4355 | dependencies = [ | |
| 4661 | 4356 | "base64ct", | |
| 4662 | 4357 | "bytes", | |
| 4663 | - | "crypto-bigint 0.7.3", | |
| 4358 | + | "crypto-bigint", | |
| 4664 | 4359 | "ctutils", | |
| 4665 | 4360 | "digest 0.11.3", | |
| 4666 | 4361 | "pem-rfc7468 1.0.0", | |
| ⋯ 9 unchanged lines | |||
| 4676 | 4371 | "argon2 0.6.0-rc.8", | |
| 4677 | 4372 | "bcrypt-pbkdf", | |
| 4678 | 4373 | "ctutils", | |
| 4679 | - | "ed25519-dalek 3.0.0-rc.0", | |
| 4374 | + | "ed25519-dalek", | |
| 4680 | 4375 | "hex", | |
| 4681 | 4376 | "hmac 0.13.0", | |
| 4682 | - | "p256 0.14.0-rc.10", | |
| 4683 | - | "p384 0.14.0-rc.10", | |
| 4377 | + | "p256", | |
| 4378 | + | "p384", | |
| 4684 | 4379 | "p521", | |
| 4685 | 4380 | "rand_core 0.10.1", | |
| 4686 | 4381 | "rsa 0.10.0-rc.18", | |
| 4687 | - | "sec1 0.8.1", | |
| 4382 | + | "sec1", | |
| 4688 | 4383 | "sha1 0.11.0", | |
| 4689 | 4384 | "sha2 0.11.0", | |
| 4690 | 4385 | "signature 3.0.0", | |
| ⋯ 503 unchanged lines | |||
| 5194 | 4889 | checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" | |
| 5195 | 4890 | ||
| 5196 | 4891 | [[package]] | |
| 5197 | - | name = "ucd-parse" | |
| 5198 | - | version = "0.1.13" | |
| 5199 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5200 | - | checksum = "c06ff81122fcbf4df4c1660b15f7e3336058e7aec14437c9f85c6b31a0f279b9" | |
| 5201 | - | dependencies = [ | |
| 5202 | - | "regex-lite", | |
| 5203 | - | ] | |
| 5204 | - | ||
| 5205 | - | [[package]] | |
| 5206 | 4892 | name = "uluru" | |
| 5207 | 4893 | version = "3.1.0" | |
| 5208 | 4894 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 251 unchanged lines | |||
| 5460 | 5146 | dependencies = [ | |
| 5461 | 5147 | "js-sys", | |
| 5462 | 5148 | "wasm-bindgen", | |
| 5463 | - | ] | |
| 5464 | - | ||
| 5465 | - | [[package]] | |
| 5466 | - | name = "webauthn_rp" | |
| 5467 | - | version = "0.3.0" | |
| 5468 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5469 | - | checksum = "a3a3b672b5e6ffc799106fb40c86d5787e331d5491452ffc3eb616b418e04e85" | |
| 5470 | - | dependencies = [ | |
| 5471 | - | "data-encoding", | |
| 5472 | - | "ed25519-dalek 2.2.0", | |
| 5473 | - | "p256 0.13.2", | |
| 5474 | - | "p384 0.13.1", | |
| 5475 | - | "precis-profiles", | |
| 5476 | - | "rand 0.9.5", | |
| 5477 | - | "rsa 0.9.10", | |
| 5478 | - | "serde", | |
| 5479 | - | "serde_json", | |
| 5480 | - | "url", | |
| 5481 | 5149 | ] | |
| 5482 | 5150 | ||
| 5483 | 5151 | [[package]] | |
| ⋯ 473 unchanged lines | |||
modifiedCargo.toml+5 −4
| ⋯ 67 unchanged lines | |||
| 68 | 68 | rusqlite = "0.39" | |
| 69 | 69 | # `anvild secret` prompts for an ssh key passphrase / an account password. | |
| 70 | 70 | rpassword = "7" | |
| 71 | - | # WebAuthn/passkey sign-in, relying-party side. Pure Rust on purpose: the | |
| 72 | - | # better-known webauthn-rs pulls in OpenSSL, which the static-musl deploy build | |
| 73 | - | # (deploy/build.sh) cannot link. | |
| 74 | - | webauthn_rp = { version = "0.3", features = ["serde_relaxed"] } | |
| 71 | + | # RS256 verification of OIDC id tokens (docs/oidc.md). ring rather than a JWT | |
| 72 | + | # crate: it is already in the tree under rustls, cross-compiles to static musl | |
| 73 | + | # (aws-lc-rs, which the maintained JWT crates default to, needs cmake and does | |
| 74 | + | # not), and one signature check over `header.payload` is all we need. | |
| 75 | + | ring = "0.17" | |
| 75 | 76 | serde = { version = "1", features = ["derive"] } | |
| 76 | 77 | serde_json = "1" | |
| 77 | 78 | serde_yaml = "0.9" | |
| ⋯ 25 unchanged lines | |||
modifiedREADME.md+1 −1
| ⋯ 36 unchanged lines | |||
| 37 | 37 | ## Docs | |
| 38 | 38 | ||
| 39 | 39 | - [CI artifacts](docs/ci-artifacts.md) | |
| 40 | - | - [Passkeys](docs/passkeys.md) — WebAuthn sign-in | |
| 40 | + | - [Single sign-on](docs/oidc.md) — OIDC sign-in, alongside passwords | |
| 41 | 41 | - [Repository secrets](docs/secrets.md) — encrypted to your ssh keys in the | |
| 42 | 42 | browser; anvil stores ciphertext it cannot open | |
| 43 | 43 | - [Threat model for untrusted users](docs/untrusted-mode.md) | |
modifiedTODO.md+4 −3
| ⋯ 29 unchanged lines | |||
| 30 | 30 | repo listings for visual browsing | |
| 31 | 31 | - [ ] API tokens: a `write` scope (would need CSRF-exempt write paths) and | |
| 32 | 32 | `last_used_at` tracking | |
| 33 | - | - [ ] passkey follow-ups (docs/passkeys.md): conditional UI (autofill-style | |
| 34 | - | sign-in), and a warning before removing the last passkey on a | |
| 35 | - | password-less-by-preference account | |
| 33 | + | - [ ] single sign-on follow-ups (docs/oidc.md): silent renewal | |
| 34 | + | (`prompt=none` on a short local session, which is what makes revoking an SSO | |
| 35 | + | session propagate here), an admin view of who is linked to which `sub`, and | |
| 36 | + | unlinking an account from the settings page | |
| 36 | 37 | - [ ] secrets follow-ups (docs/secrets.md): authenticate `anvild secret` with an | |
| 37 | 38 | ssh signature instead of the account password; per-step rather than per- | |
| 38 | 39 | pipeline scoping; `ssh-rsa` recipients (needs an RSA-OAEP branch in both the | |
| ⋯ 1 unchanged line | |||
modifiedanvil.example.toml+20 −0
| ⋯ 18 unchanged lines | |||
| 19 | 19 | # 0 means unlimited. | |
| 20 | 20 | attachment_quota_mb = 0 | |
| 21 | 21 | ||
| 22 | + | # Single sign-on against an OpenID Connect provider (see docs/oidc.md). | |
| 23 | + | # Off unless `issuer` is set; password sign-in keeps working either way. | |
| 24 | + | [oidc] | |
| 25 | + | # e.g. "https://login.richardscollin.com", or "https://login.localhost" for a | |
| 26 | + | # provider running locally. Empty disables single sign-on entirely. | |
| 27 | + | issuer = "" | |
| 28 | + | # Client id registered at the provider. | |
| 29 | + | client_id = "anvil" | |
| 30 | + | # Client secret. Prefer the ANVIL_OIDC_CLIENT_SECRET environment variable — | |
| 31 | + | # config files get committed, this must not. Empty for a public client. | |
| 32 | + | client_secret = "" | |
| 33 | + | # Defaults to base_url + "/-/oidc/callback". Must match the URI registered at | |
| 34 | + | # the provider exactly; there are no wildcards. | |
| 35 | + | redirect_uri = "" | |
| 36 | + | # Sign-in button text, after "Sign in with ". Defaults to the issuer's host. | |
| 37 | + | label = "" | |
| 38 | + | # Whether signing out of anvil also ends the provider's session. Needs a | |
| 39 | + | # post-logout URI registered for this client to come back here afterwards. | |
| 40 | + | sso_logout = true | |
| 41 | + | ||
| 22 | 42 | [ssh] | |
| 23 | 43 | enabled = false | |
| 24 | 44 | # Internal bind address. Under Docker, set host = "0.0.0.0" and forward the port. | |
| ⋯ 38 unchanged lines | |||
modifiedcrates/anvil-cli/src/main.rs+0 −5
| ⋯ 185 unchanged lines | |||
| 186 | 186 | Box::new(anvil_core::periodic::SecretVaultSweepJob) | |
| 187 | 187 | as Box<dyn anvil_core::periodic::PeriodicJob>, | |
| 188 | 188 | ), | |
| 189 | - | ( | |
| 190 | - | std::time::Duration::from_secs(300), | |
| 191 | - | Box::new(anvil_core::periodic::PasskeyCeremonySweepJob) | |
| 192 | - | as Box<dyn anvil_core::periodic::PeriodicJob>, | |
| 193 | - | ), | |
| 194 | 189 | ]; | |
| 195 | 190 | anvil_core::periodic::spawn_runner(app.clone(), periodic_jobs).await; | |
| 196 | 191 | ||
| ⋯ 140 unchanged lines | |||
modifiedcrates/anvil-core/Cargo.toml+0 −1
| ⋯ 18 unchanged lines | |||
| 19 | 19 | hmac.workspace = true | |
| 20 | 20 | sha2.workspace = true | |
| 21 | 21 | ssh-key.workspace = true | |
| 22 | - | webauthn_rp.workspace = true | |
| 23 | 22 | serde.workspace = true | |
| 24 | 23 | serde_json.workspace = true | |
| 25 | 24 | serde_yaml.workspace = true | |
| ⋯ 13 unchanged lines | |||
modifiedcrates/anvil-core/src/config.rs+113 −0
| ⋯ 30 unchanged lines | |||
| 31 | 31 | pub ci: CiConfig, | |
| 32 | 32 | /// Periodic background job settings. | |
| 33 | 33 | pub periodic: PeriodicConfig, | |
| 34 | + | /// Single sign-on against an OpenID Connect provider. | |
| 35 | + | pub oidc: OidcConfig, | |
| 34 | 36 | } | |
| 35 | 37 | ||
| 38 | + | /// Path the provider redirects back to after an authorization. Registered at | |
| 39 | + | /// the provider as this app's redirect URI, and matched there character for | |
| 40 | + | /// character — see `docs/oidc.md`. | |
| 41 | + | pub const OIDC_CALLBACK_PATH: &str = "/-/oidc/callback"; | |
| 42 | + | ||
| 43 | + | /// Sign-in delegated to an OpenID Connect provider (authorization code flow | |
| 44 | + | /// with PKCE). Off unless [`issuer`](OidcConfig::issuer) is set, so an | |
| 45 | + | /// unconfigured instance behaves exactly as it did before: local passwords | |
| 46 | + | /// only. When on, it is *additional* — existing accounts keep their passwords, | |
| 47 | + | /// and the two are reconciled on the `sub` claim. | |
| 48 | + | #[derive(Clone, Debug, Deserialize, Serialize)] | |
| 49 | + | #[serde(default)] | |
| 50 | + | pub struct OidcConfig { | |
| 51 | + | /// Issuer URL, e.g. `https://login.richardscollin.com`. Empty disables | |
| 52 | + | /// single sign-on entirely. Discovery, and the `iss` claim every id token | |
| 53 | + | /// is checked against, both come from this. | |
| 54 | + | pub issuer: String, | |
| 55 | + | /// Client id registered at the provider. Defaults to `anvil`. | |
| 56 | + | pub client_id: String, | |
| 57 | + | /// Client secret. Empty for a client registered as public — PKCE protects | |
| 58 | + | /// the code either way. | |
| 59 | + | pub client_secret: String, | |
| 60 | + | /// Overrides the redirect URI, which otherwise is | |
| 61 | + | /// `base_url` + [`OIDC_CALLBACK_PATH`]. Must match the provider's | |
| 62 | + | /// allowlist exactly. | |
| 63 | + | pub redirect_uri: String, | |
| 64 | + | /// What the sign-in button says, after `Sign in with `. Defaults to the | |
| 65 | + | /// issuer's hostname. | |
| 66 | + | pub label: String, | |
| 67 | + | /// Whether signing out of anvil also ends the provider's session (an | |
| 68 | + | /// RP-initiated logout). Needs a post-logout URI registered for this | |
| 69 | + | /// client, or the provider drops the user on its own page instead of | |
| 70 | + | /// bringing them back. Defaults to `true`. | |
| 71 | + | pub sso_logout: bool, | |
| 72 | + | } | |
| 73 | + | ||
| 36 | 74 | /// CI configuration: job sandbox limits and the single-repo redeploy webhook. | |
| 37 | 75 | /// | |
| 38 | 76 | /// On a successful CI run of [`deploy_branch`](CiConfig::deploy_branch) in the | |
| ⋯ 117 unchanged lines | |||
| 156 | 194 | ssh: SshConfig::default(), | |
| 157 | 195 | ci: CiConfig::default(), | |
| 158 | 196 | periodic: PeriodicConfig::default(), | |
| 197 | + | oidc: OidcConfig::default(), | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | impl Default for OidcConfig { | |
| 203 | + | fn default() -> Self { | |
| 204 | + | Self { | |
| 205 | + | issuer: String::new(), | |
| 206 | + | client_id: "anvil".to_string(), | |
| 207 | + | client_secret: String::new(), | |
| 208 | + | redirect_uri: String::new(), | |
| 209 | + | label: String::new(), | |
| 210 | + | sso_logout: true, | |
| 159 | 211 | } | |
| 160 | 212 | } | |
| 161 | 213 | } | |
| 162 | 214 | ||
| 215 | + | impl OidcConfig { | |
| 216 | + | /// Whether single sign-on is configured at all. | |
| 217 | + | pub fn enabled(&self) -> bool { | |
| 218 | + | !self.issuer.is_empty() | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | /// The issuer with any trailing slashes removed — the exact string the | |
| 222 | + | /// `iss` claim must equal, and the prefix every endpoint is built from. | |
| 223 | + | pub fn issuer(&self) -> &str { | |
| 224 | + | self.issuer.trim_end_matches('/') | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | /// Text for the sign-in button, after `Sign in with `. The configured | |
| 228 | + | /// label wins; otherwise the issuer's host, with a leading `login.` peeled | |
| 229 | + | /// off when a domain is left over — `login.richardscollin.com` reads | |
| 230 | + | /// better as `richardscollin.com`, while `login.localhost` must keep its | |
| 231 | + | /// prefix or it would collapse to a bare `localhost`. | |
| 232 | + | pub fn label(&self) -> String { | |
| 233 | + | if !self.label.is_empty() { | |
| 234 | + | return self.label.clone(); | |
| 235 | + | } | |
| 236 | + | let host = self | |
| 237 | + | .issuer() | |
| 238 | + | .split_once("://") | |
| 239 | + | .map_or(self.issuer(), |(_, rest)| rest) | |
| 240 | + | .split(['/', ':']) | |
| 241 | + | .next() | |
| 242 | + | .unwrap_or_default(); | |
| 243 | + | match host.strip_prefix("login.") { | |
| 244 | + | Some(domain) if domain.contains('.') => domain.to_string(), | |
| 245 | + | _ => host.to_string(), | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | ||
| 163 | 250 | impl Default for CiConfig { | |
| 164 | 251 | fn default() -> Self { | |
| 165 | 252 | Self { | |
| ⋯ 122 unchanged lines | |||
| 288 | 375 | if let Some(dir) = env("ANVIL_DATA_DIR") { | |
| 289 | 376 | self.data_dir = dir.into(); | |
| 290 | 377 | } | |
| 378 | + | // Single sign-on. The secret especially wants an env var: config files | |
| 379 | + | // get committed, and this one must not be. | |
| 380 | + | if let Some(issuer) = env("ANVIL_OIDC_ISSUER") { | |
| 381 | + | self.oidc.issuer = issuer.trim().trim_end_matches('/').to_string(); | |
| 382 | + | } | |
| 383 | + | if let Some(id) = env("ANVIL_OIDC_CLIENT_ID") { | |
| 384 | + | self.oidc.client_id = id; | |
| 385 | + | } | |
| 386 | + | if let Some(secret) = env("ANVIL_OIDC_CLIENT_SECRET") { | |
| 387 | + | self.oidc.client_secret = secret; | |
| 388 | + | } | |
| 389 | + | if let Some(uri) = env("ANVIL_OIDC_REDIRECT_URI") { | |
| 390 | + | self.oidc.redirect_uri = uri; | |
| 391 | + | } | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | /// The redirect URI handed to the provider: the configured override, or | |
| 395 | + | /// [`OIDC_CALLBACK_PATH`] on the public base URL. | |
| 396 | + | pub fn oidc_redirect_uri(&self) -> String { | |
| 397 | + | if !self.oidc.redirect_uri.is_empty() { | |
| 398 | + | return self.oidc.redirect_uri.clone(); | |
| 399 | + | } | |
| 400 | + | format!( | |
| 401 | + | "{}{OIDC_CALLBACK_PATH}", | |
| 402 | + | self.http.base_url.trim_end_matches('/') | |
| 403 | + | ) | |
| 291 | 404 | } | |
| 292 | 405 | ||
| 293 | 406 | /// Filesystem path to the SQLite database file. | |
| ⋯ 144 unchanged lines | |||
modifiedcrates/anvil-core/src/db.rs+23 −25
| ⋯ 11 unchanged lines | |||
| 12 | 12 | CiRun, | |
| 13 | 13 | Issue, | |
| 14 | 14 | IssueComment, | |
| 15 | - | Passkey, | |
| 16 | 15 | RepoSecret, | |
| 17 | 16 | Repository, | |
| 18 | 17 | Session, | |
| ⋯ 29 unchanged lines | |||
| 48 | 47 | Attachment, | |
| 49 | 48 | ApiToken, | |
| 50 | 49 | AdminCache, | |
| 51 | - | RepoSecret, | |
| 52 | - | Passkey | |
| 50 | + | RepoSecret | |
| 53 | 51 | )) | |
| 54 | 52 | .connect(&url) | |
| 55 | 53 | .await?; | |
| ⋯ 26 unchanged lines | |||
| 82 | 80 | ADMIN_CACHE_DDL, | |
| 83 | 81 | REPO_SECRETS_DDL, | |
| 84 | 82 | r#"CREATE INDEX IF NOT EXISTS "index_repo_secrets_by_repo_id" ON "repo_secrets" ("repo_id")"#, | |
| 85 | - | PASSKEYS_DDL, | |
| 86 | - | r#"CREATE INDEX IF NOT EXISTS "index_passkeys_by_user_id" ON "passkeys" ("user_id")"#, | |
| 87 | - | r#"CREATE UNIQUE INDEX IF NOT EXISTS "index_passkeys_by_credential_id" ON "passkeys" ("credential_id")"#, | |
| 88 | 83 | ]; | |
| 89 | 84 | ||
| 90 | 85 | const CI_ARTIFACTS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "ci_artifacts" ( | |
| ⋯ 51 unchanged lines | |||
| 142 | 137 | "recipients" TEXT NOT NULL, | |
| 143 | 138 | "created_at" BIGINT NOT NULL, | |
| 144 | 139 | "updated_at" BIGINT NOT NULL )"#; | |
| 145 | - | ||
| 146 | - | const PASSKEYS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "passkeys" ( | |
| 147 | - | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, | |
| 148 | - | "user_id" BIGINT NOT NULL, | |
| 149 | - | "name" TEXT NOT NULL, | |
| 150 | - | "credential_id" TEXT NOT NULL, | |
| 151 | - | "user_handle" TEXT NOT NULL, | |
| 152 | - | "static_state" TEXT NOT NULL, | |
| 153 | - | "dynamic_state" TEXT NOT NULL, | |
| 154 | - | "transports" BIGINT NOT NULL, | |
| 155 | - | "created_at" BIGINT NOT NULL, | |
| 156 | - | "last_used_at" BIGINT NOT NULL )"#; | |
| 157 | 140 | ||
| 158 | 141 | const ADMIN_CACHE_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "admin_cache" ( | |
| 159 | 142 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, | |
| ⋯ 28 unchanged lines | |||
| 188 | 171 | "languages_json", | |
| 189 | 172 | r#"ALTER TABLE "repositories" ADD COLUMN "languages_json" TEXT NOT NULL DEFAULT ''"#, | |
| 190 | 173 | ), | |
| 174 | + | ( | |
| 175 | + | "users", | |
| 176 | + | "sso_sub", | |
| 177 | + | r#"ALTER TABLE "users" ADD COLUMN "sso_sub" TEXT NOT NULL DEFAULT ''"#, | |
| 178 | + | ), | |
| 191 | 179 | ]; | |
| 192 | 180 | ||
| 193 | 181 | /// Apply [`SCHEMA_SHIMS`] and [`COLUMN_SHIMS`] to an existing database. Uses | |
| ⋯ 33 unchanged lines | |||
| 227 | 215 | "attachments", | |
| 228 | 216 | "api_tokens", | |
| 229 | 217 | "repo_secrets", | |
| 230 | - | "passkeys", | |
| 231 | 218 | ]; | |
| 232 | 219 | ||
| 233 | 220 | /// Every schema object (table + indexes) for `table`, normalized. | |
| ⋯ 66 unchanged lines | |||
| 300 | 287 | /// A column shim must converge an old table to the fresh schema's columns | |
| 301 | 288 | /// (same names, order, types, nullability — the DDL text itself differs | |
| 302 | 289 | /// because of the backfill `DEFAULT`). | |
| 290 | + | /// | |
| 291 | + | /// The shimmed columns are dropped newest-first, which is the only shape a | |
| 292 | + | /// real database takes: each was appended by a deployment, so an older one | |
| 293 | + | /// is missing that column *and every column added after it*. Dropping one | |
| 294 | + | /// from the middle instead would re-add it at the end and diverge — which | |
| 295 | + | /// says nothing about the migration, only about `ALTER TABLE`. | |
| 303 | 296 | #[tokio::test] | |
| 304 | 297 | async fn column_shim_matches_push_schema() { | |
| 305 | 298 | let dir = tempfile::tempdir().unwrap(); | |
| ⋯ 4 unchanged lines | |||
| 310 | 303 | let migrated = dir.path().join("migrated.db"); | |
| 311 | 304 | connect(&migrated).await.unwrap(); | |
| 312 | 305 | let conn = rusqlite::Connection::open(&migrated).unwrap(); | |
| 313 | - | conn.execute_batch(r#"ALTER TABLE "repositories" DROP COLUMN "mirror_url""#) | |
| 314 | - | .unwrap(); | |
| 306 | + | for (table, column, _) in COLUMN_SHIMS.iter().rev() { | |
| 307 | + | conn.execute_batch(&format!(r#"ALTER TABLE "{table}" DROP COLUMN "{column}""#)) | |
| 308 | + | .unwrap(); | |
| 309 | + | } | |
| 315 | 310 | drop(conn); | |
| 316 | 311 | connect(&migrated).await.unwrap(); | |
| 317 | 312 | connect(&migrated).await.unwrap(); // idempotent | |
| 318 | 313 | ||
| 319 | - | assert_eq!( | |
| 320 | - | columns(&fresh, "repositories"), | |
| 321 | - | columns(&migrated, "repositories") | |
| 322 | - | ); | |
| 314 | + | for table in ["repositories", "users"] { | |
| 315 | + | assert_eq!( | |
| 316 | + | columns(&fresh, table), | |
| 317 | + | columns(&migrated, table), | |
| 318 | + | "columns diverge for {table}" | |
| 319 | + | ); | |
| 320 | + | } | |
| 323 | 321 | } | |
| 324 | 322 | ||
| 325 | 323 | /// Reconnecting to an existing database that predates a table must create | |
| ⋯ 26 unchanged lines | |||
modifiedcrates/anvil-core/src/lib.rs+0 −5
| ⋯ 15 unchanged lines | |||
| 16 | 16 | pub mod issues; | |
| 17 | 17 | pub mod language; | |
| 18 | 18 | pub mod models; | |
| 19 | - | pub mod passkeys; | |
| 20 | 19 | pub mod periodic; | |
| 21 | 20 | pub mod preview_images; | |
| 22 | 21 | pub mod repos; | |
| ⋯ 16 unchanged lines | |||
| 39 | 38 | CiRun, | |
| 40 | 39 | Issue, | |
| 41 | 40 | IssueComment, | |
| 42 | - | Passkey, | |
| 43 | 41 | RepoSecret, | |
| 44 | 42 | Repository, | |
| 45 | 43 | Session, | |
| ⋯ 23 unchanged lines | |||
| 69 | 67 | /// Plaintext repo secrets for CI, held in memory only and lost on | |
| 70 | 68 | /// restart — see [`secrets::Vault`]. | |
| 71 | 69 | pub vault: secrets::Vault, | |
| 72 | - | /// WebAuthn challenges awaiting an answer — see [`passkeys::Ceremonies`]. | |
| 73 | - | pub ceremonies: passkeys::Ceremonies, | |
| 74 | 70 | /// Server-wide secret keying CSRF tokens. Persisted in the data dir so | |
| 75 | 71 | /// tokens survive restarts. Wrapped in `Arc` to keep `App: Clone` cheap. | |
| 76 | 72 | csrf_secret: std::sync::Arc<[u8; 32]>, | |
| ⋯ 14 unchanged lines | |||
| 91 | 87 | db, | |
| 92 | 88 | ci_tx: None, | |
| 93 | 89 | vault: secrets::Vault::default(), | |
| 94 | - | ceremonies: passkeys::Ceremonies::default(), | |
| 95 | 90 | csrf_secret, | |
| 96 | 91 | }) | |
| 97 | 92 | } | |
| ⋯ 52 unchanged lines | |||
modifiedcrates/anvil-core/src/models.rs+9 −35
| ⋯ 13 unchanged lines | |||
| 14 | 14 | #[unique] | |
| 15 | 15 | pub username: String, | |
| 16 | 16 | pub email: String, | |
| 17 | - | /// Argon2 PHC-format password hash. | |
| 17 | + | /// Argon2 PHC-format password hash. Empty for an account that has only | |
| 18 | + | /// ever signed in through the identity provider — no password can hash to | |
| 19 | + | /// it, so [`crate::users::verify_password`] refuses every guess. | |
| 18 | 20 | pub password_hash: String, | |
| 19 | 21 | pub is_admin: bool, | |
| 20 | 22 | /// Unix timestamp (seconds) of account creation. | |
| 21 | 23 | pub created_at: i64, | |
| 24 | + | /// The OIDC `sub` claim this account is linked to, or empty if it isn't. | |
| 25 | + | /// Accounts are keyed on `sub` rather than email because `sub` is the one | |
| 26 | + | /// claim the provider promises never changes. New columns go last so | |
| 27 | + | /// `ALTER TABLE ADD COLUMN` on existing databases agrees with the | |
| 28 | + | /// fresh-schema column order. | |
| 29 | + | pub sso_sub: String, | |
| 22 | 30 | } | |
| 23 | 31 | ||
| 24 | 32 | /// A hosted repository, owned by a [`User`]. | |
| ⋯ 188 unchanged lines | |||
| 213 | 221 | /// Normalized OpenSSH public-key line. | |
| 214 | 222 | pub content: String, | |
| 215 | 223 | pub created_at: i64, | |
| 216 | - | } | |
| 217 | - | ||
| 218 | - | /// A registered passkey (WebAuthn credential) used to sign in. | |
| 219 | - | /// | |
| 220 | - | /// Only public material is here: the credential id, its public key, and the | |
| 221 | - | /// counters the spec asks a relying party to track. The private key lives in | |
| 222 | - | /// the authenticator and is never transmitted, so this table is not a | |
| 223 | - | /// credential store in the way a password hash is — losing it costs users | |
| 224 | - | /// their registrations, not their secrets. See [`crate::passkeys`]. | |
| 225 | - | #[derive(Clone, Debug, toasty::Model)] | |
| 226 | - | pub struct Passkey { | |
| 227 | - | #[key] | |
| 228 | - | #[auto] | |
| 229 | - | pub id: i64, | |
| 230 | - | #[index] | |
| 231 | - | pub user_id: i64, | |
| 232 | - | /// User-supplied label, e.g. "MacBook Touch ID". | |
| 233 | - | pub name: String, | |
| 234 | - | /// Base64url credential id, as the authenticator reports it. | |
| 235 | - | #[unique] | |
| 236 | - | pub credential_id: String, | |
| 237 | - | /// Base64 WebAuthn user handle: opaque, per account, shared by that | |
| 238 | - | /// account's passkeys. | |
| 239 | - | pub user_handle: String, | |
| 240 | - | /// Base64 of the credential's immutable state (its public key). | |
| 241 | - | pub static_state: String, | |
| 242 | - | /// Base64 of the mutable state (signature counter, backup and | |
| 243 | - | /// user-verification flags), rewritten after every sign-in. | |
| 244 | - | pub dynamic_state: String, | |
| 245 | - | /// Encoded transport hints (USB, NFC, internal, …) for re-prompting. | |
| 246 | - | pub transports: i64, | |
| 247 | - | pub created_at: i64, | |
| 248 | - | /// Unix time of the last successful sign-in, or 0 if never used. | |
| 249 | - | pub last_used_at: i64, | |
| 250 | 224 | } | |
| 251 | 225 | ||
| 252 | 226 | /// A per-repository secret, stored only as a sealed envelope. | |
| ⋯ 36 unchanged lines | |||
deletedcrates/anvil-core/src/passkeys.rs+0 −342
| 1 | - | //! Passkeys: WebAuthn sign-in, as an alternative to the account password. | |
| 2 | - | //! | |
| 3 | - | //! anvil is the relying party. A passkey's private half never leaves the | |
| 4 | - | //! authenticator (Touch ID, Windows Hello, a security key, a phone); all we | |
| 5 | - | //! store is the credential id and its public key, and all a login proves is a | |
| 6 | - | //! signature over a challenge we issued. Nothing here can be replayed against | |
| 7 | - | //! another site: the authenticator binds every signature to our RP id. | |
| 8 | - | //! | |
| 9 | - | //! The ceremony protocol runs in two round trips — *begin* hands the browser a | |
| 10 | - | //! challenge, *finish* verifies what the authenticator signed — so the server | |
| 11 | - | //! has to remember the challenge in between. [`Ceremonies`] holds those, in | |
| 12 | - | //! memory, briefly. Verification itself lives in `anvil-web`, next to the JSON. | |
| 13 | - | //! | |
| 14 | - | //! Only passkeys (discoverable, user-verifying credentials) are supported, so | |
| 15 | - | //! signing in needs no username: the authenticator tells us which credential it | |
| 16 | - | //! used, and that identifies the account. | |
| 17 | - | ||
| 18 | - | use webauthn_rp::{ | |
| 19 | - | DiscoverableAuthenticationServerState, | |
| 20 | - | RegistrationServerState, | |
| 21 | - | request::{ | |
| 22 | - | AsciiDomain, | |
| 23 | - | RpId, | |
| 24 | - | register::{ | |
| 25 | - | USER_HANDLE_MAX_LEN, | |
| 26 | - | UserHandle64, | |
| 27 | - | }, | |
| 28 | - | }, | |
| 29 | - | }; | |
| 30 | - | ||
| 31 | - | use crate::{ | |
| 32 | - | error::{ | |
| 33 | - | Error, | |
| 34 | - | Result, | |
| 35 | - | }, | |
| 36 | - | models::Passkey, | |
| 37 | - | }; | |
| 38 | - | ||
| 39 | - | /// Length of the WebAuthn user handle, in bytes. The crate's maximum, and an | |
| 40 | - | /// opaque random value — deliberately *not* the account id, since the handle is | |
| 41 | - | /// visible to the authenticator and syncs to the user's password manager. | |
| 42 | - | pub const USER_HANDLE_LEN: usize = USER_HANDLE_MAX_LEN; | |
| 43 | - | ||
| 44 | - | /// How long a browser has to complete a ceremony before its challenge is | |
| 45 | - | /// forgotten. Matches the five-minute timeout sent to the authenticator. | |
| 46 | - | const CEREMONY_TTL_SECS: i64 = 300; | |
| 47 | - | ||
| 48 | - | /// Cap on outstanding ceremonies, so an unauthenticated endpoint that mints | |
| 49 | - | /// challenges cannot grow the map without bound. | |
| 50 | - | const MAX_CEREMONIES: usize = 512; | |
| 51 | - | ||
| 52 | - | /// The relying-party id for this deployment: the base URL's host. | |
| 53 | - | /// | |
| 54 | - | /// WebAuthn scopes a credential to exactly this string, so it must be stable — | |
| 55 | - | /// change the host and existing passkeys stop working (they are not lost, they | |
| 56 | - | /// simply belong to a different site now). | |
| 57 | - | pub fn rp_id(base_url: &str) -> Result<RpId> { | |
| 58 | - | let host = base_url | |
| 59 | - | .split_once("://") | |
| 60 | - | .map_or(base_url, |(_, rest)| rest) | |
| 61 | - | .split('/') | |
| 62 | - | .next() | |
| 63 | - | .unwrap_or_default() | |
| 64 | - | .split(':') | |
| 65 | - | .next() | |
| 66 | - | .unwrap_or_default() | |
| 67 | - | .to_ascii_lowercase(); | |
| 68 | - | if host.is_empty() { | |
| 69 | - | return Err(Error::Config(format!( | |
| 70 | - | "cannot derive a WebAuthn relying-party id from base_url `{base_url}`" | |
| 71 | - | ))); | |
| 72 | - | } | |
| 73 | - | AsciiDomain::try_from(host.clone()) | |
| 74 | - | .map(RpId::Domain) | |
| 75 | - | .map_err(|_| { | |
| 76 | - | Error::Config(format!( | |
| 77 | - | "base_url host `{host}` is not a domain WebAuthn accepts" | |
| 78 | - | )) | |
| 79 | - | }) | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | /// The exact origin browsers must report, i.e. scheme + host + any explicit | |
| 83 | - | /// port. Compared verbatim during verification, which is what stops a | |
| 84 | - | /// look-alike site from replaying a ceremony. | |
| 85 | - | pub fn origin(base_url: &str) -> String { | |
| 86 | - | base_url.trim_end_matches('/').to_string() | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | /// A ceremony in flight, keyed by an opaque id the browser echoes back. | |
| 90 | - | pub enum Ceremony { | |
| 91 | - | /// Registering a new passkey for an already signed-in user. | |
| 92 | - | Register { | |
| 93 | - | state: Box<RegistrationServerState<USER_HANDLE_LEN>>, | |
| 94 | - | user_id: i64, | |
| 95 | - | }, | |
| 96 | - | /// Signing in with an existing passkey. No user is known yet — the | |
| 97 | - | /// authenticator's response is what identifies the account. | |
| 98 | - | Authenticate { | |
| 99 | - | state: Box<DiscoverableAuthenticationServerState>, | |
| 100 | - | }, | |
| 101 | - | } | |
| 102 | - | ||
| 103 | - | /// Challenges issued but not yet completed. | |
| 104 | - | /// | |
| 105 | - | /// In memory only, and deliberately so: a challenge is single-use and expires | |
| 106 | - | /// in minutes, so persisting it would buy nothing but a table to clean up. A | |
| 107 | - | /// restart invalidates ceremonies in flight, which costs a user one retry. | |
| 108 | - | #[derive(Clone, Default)] | |
| 109 | - | pub struct Ceremonies { | |
| 110 | - | inner: std::sync::Arc<std::sync::Mutex<std::collections::HashMap<String, Pending>>>, | |
| 111 | - | } | |
| 112 | - | ||
| 113 | - | struct Pending { | |
| 114 | - | ceremony: Ceremony, | |
| 115 | - | expires_at: i64, | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | impl Ceremonies { | |
| 119 | - | /// Store `ceremony` and return the id the browser must send back. | |
| 120 | - | pub fn insert(&self, ceremony: Ceremony) -> String { | |
| 121 | - | let id = random_id(); | |
| 122 | - | let mut map = self.inner.lock().expect("ceremony mutex"); | |
| 123 | - | let now = crate::now(); | |
| 124 | - | map.retain(|_, pending| pending.expires_at > now); | |
| 125 | - | // Under flood, drop the oldest rather than refuse new sign-ins. | |
| 126 | - | while map.len() >= MAX_CEREMONIES { | |
| 127 | - | let oldest = map | |
| 128 | - | .iter() | |
| 129 | - | .min_by_key(|(_, pending)| pending.expires_at) | |
| 130 | - | .map(|(key, _)| key.clone()); | |
| 131 | - | match oldest { | |
| 132 | - | Some(key) => { | |
| 133 | - | map.remove(&key); | |
| 134 | - | } | |
| 135 | - | None => break, | |
| 136 | - | } | |
| 137 | - | } | |
| 138 | - | map.insert( | |
| 139 | - | id.clone(), | |
| 140 | - | Pending { | |
| 141 | - | ceremony, | |
| 142 | - | expires_at: now + CEREMONY_TTL_SECS, | |
| 143 | - | }, | |
| 144 | - | ); | |
| 145 | - | id | |
| 146 | - | } | |
| 147 | - | ||
| 148 | - | /// Consume a ceremony. Single-use: a challenge answered twice is answered | |
| 149 | - | /// once, which is what makes replaying a captured assertion useless. | |
| 150 | - | pub fn take(&self, id: &str) -> Option<Ceremony> { | |
| 151 | - | let mut map = self.inner.lock().expect("ceremony mutex"); | |
| 152 | - | let pending = map.remove(id)?; | |
| 153 | - | (pending.expires_at > crate::now()).then_some(pending.ceremony) | |
| 154 | - | } | |
| 155 | - | ||
| 156 | - | /// Drop expired entries (called from the periodic sweep). | |
| 157 | - | pub fn sweep(&self) { | |
| 158 | - | let now = crate::now(); | |
| 159 | - | self.inner | |
| 160 | - | .lock() | |
| 161 | - | .expect("ceremony mutex") | |
| 162 | - | .retain(|_, pending| pending.expires_at > now); | |
| 163 | - | } | |
| 164 | - | } | |
| 165 | - | ||
| 166 | - | fn random_id() -> String { | |
| 167 | - | use argon2::password_hash::rand_core::{ | |
| 168 | - | OsRng, | |
| 169 | - | RngCore, | |
| 170 | - | }; | |
| 171 | - | let mut bytes = [0u8; 32]; | |
| 172 | - | OsRng.fill_bytes(&mut bytes); | |
| 173 | - | bytes.iter().map(|b| format!("{b:02x}")).collect() | |
| 174 | - | } | |
| 175 | - | ||
| 176 | - | /// Generate a fresh WebAuthn user handle. | |
| 177 | - | pub fn new_user_handle() -> UserHandle64 { | |
| 178 | - | UserHandle64::new() | |
| 179 | - | } | |
| 180 | - | ||
| 181 | - | // --- persistence ----------------------------------------------------------- | |
| 182 | - | ||
| 183 | - | /// List a user's passkeys, newest first. | |
| 184 | - | pub async fn list(db: &toasty::Db, user_id: i64) -> Result<Vec<Passkey>> { | |
| 185 | - | let mut conn = db.clone(); | |
| 186 | - | let mut keys = Passkey::filter(Passkey::fields().user_id().eq(user_id)) | |
| 187 | - | .exec(&mut conn) | |
| 188 | - | .await?; | |
| 189 | - | keys.sort_by_key(|k| std::cmp::Reverse(k.created_at)); | |
| 190 | - | Ok(keys) | |
| 191 | - | } | |
| 192 | - | ||
| 193 | - | /// Look a credential up by its id (base64url), as presented at sign-in. | |
| 194 | - | pub async fn find_by_credential_id( | |
| 195 | - | db: &toasty::Db, | |
| 196 | - | credential_id: &str, | |
| 197 | - | ) -> Result<Option<Passkey>> { | |
| 198 | - | let mut conn = db.clone(); | |
| 199 | - | Ok( | |
| 200 | - | Passkey::filter(Passkey::fields().credential_id().eq(credential_id)) | |
| 201 | - | .first() | |
| 202 | - | .exec(&mut conn) | |
| 203 | - | .await?, | |
| 204 | - | ) | |
| 205 | - | } | |
| 206 | - | ||
| 207 | - | /// Record a newly registered passkey. | |
| 208 | - | #[allow(clippy::too_many_arguments)] | |
| 209 | - | pub async fn add( | |
| 210 | - | db: &toasty::Db, | |
| 211 | - | user_id: i64, | |
| 212 | - | name: &str, | |
| 213 | - | credential_id: &str, | |
| 214 | - | user_handle: &str, | |
| 215 | - | static_state: &str, | |
| 216 | - | dynamic_state: &str, | |
| 217 | - | transports: i64, | |
| 218 | - | ) -> Result<Passkey> { | |
| 219 | - | if find_by_credential_id(db, credential_id).await?.is_some() { | |
| 220 | - | return Err(Error::AlreadyExists("passkey".into())); | |
| 221 | - | } | |
| 222 | - | let now = crate::now(); | |
| 223 | - | let mut conn = db.clone(); | |
| 224 | - | Ok(toasty::create!(Passkey { | |
| 225 | - | user_id: user_id, | |
| 226 | - | name: display_name(name), | |
| 227 | - | credential_id: credential_id, | |
| 228 | - | user_handle: user_handle, | |
| 229 | - | static_state: static_state, | |
| 230 | - | dynamic_state: dynamic_state, | |
| 231 | - | transports: transports, | |
| 232 | - | created_at: now, | |
| 233 | - | last_used_at: 0, | |
| 234 | - | }) | |
| 235 | - | .exec(&mut conn) | |
| 236 | - | .await?) | |
| 237 | - | } | |
| 238 | - | ||
| 239 | - | /// Persist the credential's post-authentication state (the signature counter | |
| 240 | - | /// and flags) and stamp its last use. | |
| 241 | - | pub async fn record_use(db: &toasty::Db, passkey: Passkey, dynamic_state: &str) -> Result<()> { | |
| 242 | - | let mut conn = db.clone(); | |
| 243 | - | let mut passkey = passkey; | |
| 244 | - | passkey | |
| 245 | - | .update() | |
| 246 | - | .dynamic_state(dynamic_state) | |
| 247 | - | .last_used_at(crate::now()) | |
| 248 | - | .exec(&mut conn) | |
| 249 | - | .await?; | |
| 250 | - | Ok(()) | |
| 251 | - | } | |
| 252 | - | ||
| 253 | - | /// Delete one of `user_id`'s passkeys. No-op if it is missing or someone | |
| 254 | - | /// else's. | |
| 255 | - | pub async fn delete(db: &toasty::Db, id: i64, user_id: i64) -> Result<()> { | |
| 256 | - | let mut conn = db.clone(); | |
| 257 | - | if let Some(passkey) = Passkey::filter(Passkey::fields().id().eq(id)) | |
| 258 | - | .first() | |
| 259 | - | .exec(&mut conn) | |
| 260 | - | .await? | |
| 261 | - | && passkey.user_id == user_id | |
| 262 | - | { | |
| 263 | - | let mut conn = db.clone(); | |
| 264 | - | passkey.delete().exec(&mut conn).await?; | |
| 265 | - | } | |
| 266 | - | Ok(()) | |
| 267 | - | } | |
| 268 | - | ||
| 269 | - | /// A user's stable WebAuthn handle, shared by all of their passkeys: reusing it | |
| 270 | - | /// lets an authenticator recognize a second registration as the same account | |
| 271 | - | /// rather than a second one. | |
| 272 | - | pub async fn handle_for_user(db: &toasty::Db, user_id: i64) -> Result<Option<String>> { | |
| 273 | - | Ok(list(db, user_id) | |
| 274 | - | .await? | |
| 275 | - | .into_iter() | |
| 276 | - | .next() | |
| 277 | - | .map(|k| k.user_handle)) | |
| 278 | - | } | |
| 279 | - | ||
| 280 | - | /// Trim and bound a user-supplied label, falling back to something useful. | |
| 281 | - | fn display_name(name: &str) -> String { | |
| 282 | - | let name = name.trim(); | |
| 283 | - | if name.is_empty() { | |
| 284 | - | "passkey".to_string() | |
| 285 | - | } else { | |
| 286 | - | name.chars().take(64).collect() | |
| 287 | - | } | |
| 288 | - | } | |
| 289 | - | ||
| 290 | - | #[cfg(test)] | |
| 291 | - | mod tests { | |
| 292 | - | use super::*; | |
| 293 | - | ||
| 294 | - | #[test] | |
| 295 | - | fn derives_the_rp_id_from_the_base_url() { | |
| 296 | - | let id = |url: &str| rp_id(url).map(|id| id.as_ref().to_string()); | |
| 297 | - | assert_eq!(id("https://anvil.localhost").unwrap(), "anvil.localhost"); | |
| 298 | - | assert_eq!(id("http://localhost:3000").unwrap(), "localhost"); | |
| 299 | - | assert_eq!( | |
| 300 | - | id("https://anvil.richardscollin.com/").unwrap(), | |
| 301 | - | "anvil.richardscollin.com" | |
| 302 | - | ); | |
| 303 | - | // Case is normalized: browsers report the host lowercased. | |
| 304 | - | assert_eq!(id("https://Anvil.LOCALHOST").unwrap(), "anvil.localhost"); | |
| 305 | - | assert!(id("").is_err()); | |
| 306 | - | } | |
| 307 | - | ||
| 308 | - | #[test] | |
| 309 | - | fn the_origin_keeps_scheme_and_port() { | |
| 310 | - | assert_eq!(origin("http://localhost:3000/"), "http://localhost:3000"); | |
| 311 | - | assert_eq!(origin("https://anvil.localhost"), "https://anvil.localhost"); | |
| 312 | - | } | |
| 313 | - | ||
| 314 | - | #[test] | |
| 315 | - | fn ceremonies_are_single_use_and_expire() { | |
| 316 | - | let ceremonies = Ceremonies::default(); | |
| 317 | - | let id = ceremonies.insert(Ceremony::Authenticate { | |
| 318 | - | state: Box::new(fake_auth_state()), | |
| 319 | - | }); | |
| 320 | - | assert!(ceremonies.take(&id).is_some()); | |
| 321 | - | assert!( | |
| 322 | - | ceremonies.take(&id).is_none(), | |
| 323 | - | "a challenge must not be answerable twice" | |
| 324 | - | ); | |
| 325 | - | } | |
| 326 | - | ||
| 327 | - | /// A real ceremony state, built the way the server builds one. | |
| 328 | - | fn fake_auth_state() -> DiscoverableAuthenticationServerState { | |
| 329 | - | let rp = rp_id("https://anvil.localhost").unwrap(); | |
| 330 | - | webauthn_rp::DiscoverableCredentialRequestOptions::passkey(&rp) | |
| 331 | - | .start_ceremony() | |
| 332 | - | .expect("default passkey options are valid") | |
| 333 | - | .0 | |
| 334 | - | } | |
| 335 | - | ||
| 336 | - | #[test] | |
| 337 | - | fn labels_are_trimmed_and_defaulted() { | |
| 338 | - | assert_eq!(display_name(" MacBook "), "MacBook"); | |
| 339 | - | assert_eq!(display_name(""), "passkey"); | |
| 340 | - | assert_eq!(display_name(&"x".repeat(100)).len(), 64); | |
| 341 | - | } | |
| 342 | - | } |
modifiedcrates/anvil-core/src/periodic.rs+6 −15
| ⋯ 22 unchanged lines | |||
| 23 | 23 | }; | |
| 24 | 24 | ||
| 25 | 25 | /// A periodic task to be run on an interval. | |
| 26 | + | // `async_trait` marks the boxed future it desugars `run` to as `#[must_use]`, | |
| 27 | + | // and the `Result` inside it carries its own — which clippy reads as one | |
| 28 | + | // `must_use` too many. Neither is ours to remove, and the trait must stay | |
| 29 | + | // `dyn`-compatible (the runner holds `Box<dyn PeriodicJob>`), which a native | |
| 30 | + | // `async fn` in a trait is not. | |
| 31 | + | #[allow(clippy::double_must_use)] | |
| 26 | 32 | #[async_trait] | |
| 27 | 33 | pub trait PeriodicJob: Send + Sync { | |
| 28 | 34 | /// Run the job once. Errors are logged but do not stop the runner. | |
| ⋯ 150 unchanged lines | |||
| 179 | 185 | ||
| 180 | 186 | fn name(&self) -> &str { | |
| 181 | 187 | "secret_vault_sweep" | |
| 182 | - | } | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | /// Job that drops WebAuthn challenges nobody answered. | |
| 186 | - | pub struct PasskeyCeremonySweepJob; | |
| 187 | - | ||
| 188 | - | #[async_trait::async_trait] | |
| 189 | - | impl PeriodicJob for PasskeyCeremonySweepJob { | |
| 190 | - | async fn run(&self, app: &App) -> Result<()> { | |
| 191 | - | app.ceremonies.sweep(); | |
| 192 | - | Ok(()) | |
| 193 | - | } | |
| 194 | - | ||
| 195 | - | fn name(&self) -> &str { | |
| 196 | - | "passkey_ceremony_sweep" | |
| 197 | 188 | } | |
| 198 | 189 | } | |
| 199 | 190 | ||
| ⋯ 24 unchanged lines | |||
modifiedcrates/anvil-core/src/users.rs+180 −5
| ⋯ 47 unchanged lines | |||
| 48 | 48 | } | |
| 49 | 49 | ||
| 50 | 50 | /// Verify a plaintext password against a stored PHC hash. | |
| 51 | + | /// | |
| 52 | + | /// An empty hash is not a parse failure to report but an account with no | |
| 53 | + | /// password at all (one provisioned through single sign-on): every guess is | |
| 54 | + | /// simply wrong. | |
| 51 | 55 | pub fn verify_password(hash: &str, password: &str) -> Result<bool> { | |
| 56 | + | if hash.is_empty() { | |
| 57 | + | return Ok(false); | |
| 58 | + | } | |
| 52 | 59 | let parsed = PasswordHash::new(hash).map_err(|e| Error::Password(e.to_string()))?; | |
| 53 | 60 | Ok(Argon2::default() | |
| 54 | 61 | .verify_password(password.as_bytes(), &parsed) | |
| ⋯ 10 unchanged lines | |||
| 65 | 72 | password: &str, | |
| 66 | 73 | is_admin: bool, | |
| 67 | 74 | ) -> Result<User> { | |
| 75 | + | insert(db, username, email, hash_password(password)?, is_admin, "").await | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// Reject a username that cannot safely be one. | |
| 79 | + | /// | |
| 80 | + | /// Usernames live in the URL root namespace (e.g. `/<username>`) and on disk | |
| 81 | + | /// under `repositories/<username>/`, so path-unsafe characters are out, as are | |
| 82 | + | /// names reserved for system routes (the `/-/…` prefix is reserved | |
| 83 | + | /// structurally, but we keep a denylist as defense-in-depth). | |
| 84 | + | fn validate_username(username: &str) -> Result<()> { | |
| 68 | 85 | if username.trim().is_empty() { | |
| 69 | 86 | return Err(Error::Invalid("username must not be empty".into())); | |
| 70 | 87 | } | |
| 71 | - | // Usernames live in the URL root namespace (e.g. `/<username>`) and on disk | |
| 72 | - | // under `repositories/<username>/`. Reject path-unsafe characters and names | |
| 73 | - | // reserved for system routes (the `/-/…` prefix is reserved structurally, | |
| 74 | - | // but we keep a denylist as defense-in-depth). | |
| 75 | 88 | if username.contains('/') || username.contains('\\') || username.contains("..") { | |
| 76 | 89 | return Err(Error::Invalid(format!("invalid username: {username:?}"))); | |
| 77 | 90 | } | |
| 78 | 91 | if RESERVED_USERNAMES.contains(&username.to_ascii_lowercase().as_str()) { | |
| 79 | 92 | return Err(Error::Invalid(format!("username '{username}' is reserved"))); | |
| 80 | 93 | } | |
| 94 | + | Ok(()) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /// Insert a user row from an already-hashed password. The one place a `User` | |
| 98 | + | /// is created, so every path shares the name checks. | |
| 99 | + | async fn insert( | |
| 100 | + | db: &toasty::Db, | |
| 101 | + | username: &str, | |
| 102 | + | email: &str, | |
| 103 | + | password_hash: String, | |
| 104 | + | is_admin: bool, | |
| 105 | + | sso_sub: &str, | |
| 106 | + | ) -> Result<User> { | |
| 107 | + | validate_username(username)?; | |
| 81 | 108 | if find_by_username(db, username).await?.is_some() { | |
| 82 | 109 | return Err(Error::AlreadyExists(format!("user {username}"))); | |
| 83 | 110 | } | |
| ⋯ 2 unchanged lines | |||
| 86 | 113 | let user = toasty::create!(User { | |
| 87 | 114 | username: username, | |
| 88 | 115 | email: email, | |
| 89 | - | password_hash: hash_password(password)?, | |
| 116 | + | password_hash: password_hash, | |
| 90 | 117 | is_admin: is_admin, | |
| 91 | 118 | created_at: crate::now(), | |
| 119 | + | sso_sub: sso_sub, | |
| 92 | 120 | }) | |
| 93 | 121 | .exec(&mut db) | |
| 94 | 122 | .await?; | |
| ⋯ 42 unchanged lines | |||
| 137 | 165 | Ok(user) | |
| 138 | 166 | } | |
| 139 | 167 | ||
| 168 | + | /// Look up a user by exact email. Empty matches nothing: plenty of accounts | |
| 169 | + | /// have no address, and they are not all the same person. | |
| 170 | + | pub async fn find_by_email(db: &toasty::Db, email: &str) -> Result<Option<User>> { | |
| 171 | + | if email.is_empty() { | |
| 172 | + | return Ok(None); | |
| 173 | + | } | |
| 174 | + | let mut db = db.clone(); | |
| 175 | + | let user = User::filter(User::fields().email().eq(email)) | |
| 176 | + | .first() | |
| 177 | + | .exec(&mut db) | |
| 178 | + | .await?; | |
| 179 | + | Ok(user) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | /// Look up the account linked to an OIDC `sub`. | |
| 183 | + | pub async fn find_by_sso_sub(db: &toasty::Db, sub: &str) -> Result<Option<User>> { | |
| 184 | + | if sub.is_empty() { | |
| 185 | + | return Ok(None); | |
| 186 | + | } | |
| 187 | + | let mut db = db.clone(); | |
| 188 | + | let user = User::filter(User::fields().sso_sub().eq(sub)) | |
| 189 | + | .first() | |
| 190 | + | .exec(&mut db) | |
| 191 | + | .await?; | |
| 192 | + | Ok(user) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | /// Link an existing account to an OIDC `sub`, so later sign-ins find it by | |
| 196 | + | /// subject rather than by address. | |
| 197 | + | /// | |
| 198 | + | /// Refuses an account already linked to a *different* subject: that is either | |
| 199 | + | /// a provider reissuing subjects or two identities converging on one row, and | |
| 200 | + | /// silently repointing it would hand one person another's account. | |
| 201 | + | pub async fn link_sso_sub(db: &toasty::Db, user_id: i64, sub: &str) -> Result<User> { | |
| 202 | + | let Some(mut user) = find_by_id(db, user_id).await? else { | |
| 203 | + | return Err(Error::NotFound(format!("user id {user_id}"))); | |
| 204 | + | }; | |
| 205 | + | if user.sso_sub == sub { | |
| 206 | + | return Ok(user); | |
| 207 | + | } | |
| 208 | + | if !user.sso_sub.is_empty() { | |
| 209 | + | return Err(Error::Invalid(format!( | |
| 210 | + | "{} is already linked to a different sign-in identity", | |
| 211 | + | user.username | |
| 212 | + | ))); | |
| 213 | + | } | |
| 214 | + | let mut conn = db.clone(); | |
| 215 | + | user.update().sso_sub(sub).exec(&mut conn).await?; | |
| 216 | + | Ok(user) | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | /// Copy the claims the provider owns onto a linked account: the address it | |
| 220 | + | /// vouches for, and whether this app considers them an admin. | |
| 221 | + | /// | |
| 222 | + | /// The email is skipped when another account already holds it — the provider | |
| 223 | + | /// is authoritative about identity, not about which local row gets the string. | |
| 224 | + | /// `is_admin` is `None` when the provider asserted no role, which leaves the | |
| 225 | + | /// local flag alone rather than quietly demoting an admin. | |
| 226 | + | pub async fn sync_from_sso( | |
| 227 | + | db: &toasty::Db, | |
| 228 | + | user_id: i64, | |
| 229 | + | email: &str, | |
| 230 | + | is_admin: Option<bool>, | |
| 231 | + | ) -> Result<User> { | |
| 232 | + | let Some(mut user) = find_by_id(db, user_id).await? else { | |
| 233 | + | return Err(Error::NotFound(format!("user id {user_id}"))); | |
| 234 | + | }; | |
| 235 | + | let taken = match find_by_email(db, email).await? { | |
| 236 | + | Some(other) => other.id != user.id, | |
| 237 | + | None => false, | |
| 238 | + | }; | |
| 239 | + | let email = if email.is_empty() || taken { | |
| 240 | + | user.email.clone() | |
| 241 | + | } else { | |
| 242 | + | email.to_string() | |
| 243 | + | }; | |
| 244 | + | let is_admin = is_admin.unwrap_or(user.is_admin); | |
| 245 | + | if user.email == email && user.is_admin == is_admin { | |
| 246 | + | return Ok(user); | |
| 247 | + | } | |
| 248 | + | let mut conn = db.clone(); | |
| 249 | + | user.update() | |
| 250 | + | .email(email) | |
| 251 | + | .is_admin(is_admin) | |
| 252 | + | .exec(&mut conn) | |
| 253 | + | .await?; | |
| 254 | + | Ok(user) | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | /// Create an account for an identity the provider vouches for. It has no | |
| 258 | + | /// password: `password_hash` is empty, which | |
| 259 | + | /// [`verify_password`] refuses unconditionally, so the only way in is the | |
| 260 | + | /// provider (or an admin setting a password later). | |
| 261 | + | pub async fn create_from_sso( | |
| 262 | + | db: &toasty::Db, | |
| 263 | + | preferred_username: &str, | |
| 264 | + | email: &str, | |
| 265 | + | is_admin: bool, | |
| 266 | + | sub: &str, | |
| 267 | + | ) -> Result<User> { | |
| 268 | + | let username = allocate_username(db, preferred_username, email).await?; | |
| 269 | + | insert(db, &username, email, String::new(), is_admin, sub).await | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | /// Pick a free, path-safe username from what the provider suggested. | |
| 273 | + | /// | |
| 274 | + | /// The provider's `preferred_username` is a display preference, not a | |
| 275 | + | /// namespace reservation: it can collide, be reserved, or contain characters a | |
| 276 | + | /// URL path cannot. Sanitize it, fall back to the email's local part, then | |
| 277 | + | /// append `-2`, `-3`, … until one is free. | |
| 278 | + | async fn allocate_username(db: &toasty::Db, preferred: &str, email: &str) -> Result<String> { | |
| 279 | + | let sanitize = |raw: &str| -> String { | |
| 280 | + | raw.trim() | |
| 281 | + | .to_ascii_lowercase() | |
| 282 | + | .chars() | |
| 283 | + | .map(|c| match c { | |
| 284 | + | 'a'..='z' | '0'..='9' | '-' | '_' => c, | |
| 285 | + | _ => '-', | |
| 286 | + | }) | |
| 287 | + | .collect::<String>() | |
| 288 | + | .trim_matches('-') | |
| 289 | + | .to_string() | |
| 290 | + | }; | |
| 291 | + | ||
| 292 | + | let base = [preferred, email.split('@').next().unwrap_or_default()] | |
| 293 | + | .into_iter() | |
| 294 | + | .map(sanitize) | |
| 295 | + | .find(|s| !s.is_empty() && validate_username(s).is_ok()) | |
| 296 | + | .unwrap_or_else(|| "user".to_string()); | |
| 297 | + | ||
| 298 | + | for suffix in 1..1000 { | |
| 299 | + | let candidate = if suffix == 1 { | |
| 300 | + | base.clone() | |
| 301 | + | } else { | |
| 302 | + | format!("{base}-{suffix}") | |
| 303 | + | }; | |
| 304 | + | if validate_username(&candidate).is_ok() | |
| 305 | + | && find_by_username(db, &candidate).await?.is_none() | |
| 306 | + | { | |
| 307 | + | return Ok(candidate); | |
| 308 | + | } | |
| 309 | + | } | |
| 310 | + | Err(Error::AlreadyExists(format!( | |
| 311 | + | "no free username near {base}" | |
| 312 | + | ))) | |
| 313 | + | } | |
| 314 | + | ||
| 140 | 315 | #[cfg(test)] | |
| 141 | 316 | mod tests { | |
| 142 | 317 | use super::*; | |
| ⋯ 32 unchanged lines | |||
modifiedcrates/anvil-web/Cargo.toml+9 −6
| ⋯ 9 unchanged lines | |||
| 10 | 10 | [dependencies] | |
| 11 | 11 | anvil-core.workspace = true | |
| 12 | 12 | anvil-git.workspace = true | |
| 13 | - | webauthn_rp.workspace = true | |
| 14 | 13 | axum.workspace = true | |
| 15 | 14 | axum-extra.workspace = true | |
| 16 | 15 | tokio.workspace = true | |
| ⋯ 3 unchanged lines | |||
| 20 | 19 | serde.workspace = true | |
| 21 | 20 | serde_json.workspace = true | |
| 22 | 21 | base64.workspace = true | |
| 22 | + | reqwest.workspace = true | |
| 23 | + | ring.workspace = true | |
| 24 | + | rustls.workspace = true | |
| 23 | 25 | lru.workspace = true | |
| 24 | 26 | maud.workspace = true | |
| 25 | 27 | pulldown-cmark.workspace = true | |
| ⋯ 8 unchanged lines | |||
| 34 | 36 | ssh-key = { workspace = true, features = ["ed25519"] } | |
| 35 | 37 | tokio = { workspace = true } | |
| 36 | 38 | tower = { workspace = true, features = ["util"] } | |
| 37 | - | # A software authenticator for the passkey tests: CBOR for attestation | |
| 38 | - | # objects and COSE keys, P-256 for the signatures a security key would make. | |
| 39 | - | ciborium = "0.2" | |
| 40 | - | p256 = "0.13" | |
| 41 | - | sha2.workspace = true | |
| 39 | + | # The stand-in identity provider in tests/oidc_flow.rs generates a key and | |
| 40 | + | # signs its own id tokens. ring, which verifies them on anvil's side, can only | |
| 41 | + | # verify with an RSA key, not make one. | |
| 42 | + | rsa = "0.9" | |
| 43 | + | # `oid` for the DigestInfo prefix PKCS#1 v1.5 (and so RS256) signs over. | |
| 44 | + | sha2 = { workspace = true, features = ["oid"] } | |
modifiedcrates/anvil-web/src/auth.rs+34 −10
| ⋯ 148 unchanged lines | |||
| 149 | 149 | } | |
| 150 | 150 | ||
| 151 | 151 | /// Length-independent constant-time byte comparison. | |
| 152 | - | fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { | |
| 152 | + | pub(crate) fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { | |
| 153 | 153 | if a.len() != b.len() { | |
| 154 | 154 | return false; | |
| 155 | 155 | } | |
| ⋯ 18 unchanged lines | |||
| 174 | 174 | pub csrf: String, | |
| 175 | 175 | } | |
| 176 | 176 | ||
| 177 | + | /// Query on `GET /login`: where to go once signed in, carried through to the | |
| 178 | + | /// identity provider so an interrupted request resumes. | |
| 179 | + | #[derive(serde::Deserialize)] | |
| 180 | + | pub struct LoginQuery { | |
| 181 | + | #[serde(default)] | |
| 182 | + | next: Option<String>, | |
| 183 | + | } | |
| 184 | + | ||
| 177 | 185 | /// `GET /login` — show the login form (or bounce home if already signed in). | |
| 178 | - | pub async fn login_form(CurrentUser(user): CurrentUser) -> Response { | |
| 186 | + | pub async fn login_form( | |
| 187 | + | State(app): State<App>, | |
| 188 | + | CurrentUser(user): CurrentUser, | |
| 189 | + | axum::extract::Query(query): axum::extract::Query<LoginQuery>, | |
| 190 | + | ) -> Response { | |
| 179 | 191 | if user.is_some() { | |
| 180 | 192 | return Redirect::to("/").into_response(); | |
| 181 | 193 | } | |
| 182 | - | login_page(None).into_response() | |
| 194 | + | login_page(&app, query.next.as_deref(), None).into_response() | |
| 183 | 195 | } | |
| 184 | 196 | ||
| 185 | 197 | /// `POST /login` — verify credentials, create a session, set the cookie. | |
| ⋯ 12 unchanged lines | |||
| 198 | 210 | let Some(user) = ok else { | |
| 199 | 211 | return ( | |
| 200 | 212 | axum::http::StatusCode::UNAUTHORIZED, | |
| 201 | - | login_page(Some("Invalid username or password.")), | |
| 213 | + | login_page(&app, None, Some("Invalid username or password.")), | |
| 202 | 214 | ) | |
| 203 | 215 | .into_response(); | |
| 204 | 216 | }; | |
| ⋯ 8 unchanged lines | |||
| 213 | 225 | tracing::error!("session create failed: {e}"); | |
| 214 | 226 | ( | |
| 215 | 227 | axum::http::StatusCode::INTERNAL_SERVER_ERROR, | |
| 216 | - | login_page(Some("Could not start a session.")), | |
| 228 | + | login_page(&app, None, Some("Could not start a session.")), | |
| 217 | 229 | ) | |
| 218 | 230 | .into_response() | |
| 219 | 231 | } | |
| ⋯ 5 unchanged lines | |||
| 225 | 237 | /// from cross-site POSTs (so a forced logout can't identify the session), and | |
| 226 | 238 | /// the impact of a forced logout is trivial. The high-value mutating forms | |
| 227 | 239 | /// (SSH keys, repo creation/visibility) do carry tokens via [`verify_csrf`]. | |
| 228 | - | pub async fn logout(State(app): State<App>, jar: CookieJar) -> Response { | |
| 240 | + | pub async fn logout( | |
| 241 | + | State(app): State<App>, | |
| 242 | + | CurrentUser(user): CurrentUser, | |
| 243 | + | jar: CookieJar, | |
| 244 | + | ) -> Response { | |
| 229 | 245 | if let Some(cookie) = jar.get(SESSION_COOKIE) { | |
| 230 | 246 | let _ = sessions::delete(&app.db, cookie.value()).await; | |
| 231 | 247 | } | |
| 232 | - | (jar.remove(Cookie::from(SESSION_COOKIE)), Redirect::to("/")).into_response() | |
| 248 | + | // For an account that came from the identity provider, ending only anvil's | |
| 249 | + | // session would leave the provider ready to sign them straight back in. | |
| 250 | + | let destination = crate::oidc::end_session_url(&app, user.as_ref()) | |
| 251 | + | .await | |
| 252 | + | .unwrap_or_else(|| "/".to_string()); | |
| 253 | + | ( | |
| 254 | + | jar.remove(Cookie::from(SESSION_COOKIE)), | |
| 255 | + | Redirect::to(&destination), | |
| 256 | + | ) | |
| 257 | + | .into_response() | |
| 233 | 258 | } | |
| 234 | 259 | ||
| 235 | - | fn login_page(error: Option<&str>) -> Markup { | |
| 260 | + | fn login_page(app: &App, next: Option<&str>, error: Option<&str>) -> Markup { | |
| 236 | 261 | layout( | |
| 237 | 262 | "Sign in", | |
| 238 | 263 | None, | |
| ⋯ 2 unchanged lines | |||
| 241 | 266 | @if let Some(error) = error { | |
| 242 | 267 | p.error-msg { (error) } | |
| 243 | 268 | } | |
| 269 | + | (crate::oidc::sign_in_button(&app.config.oidc, next)) | |
| 244 | 270 | form method="post" action="/-/login" style="max-width:320px" { | |
| 245 | 271 | p { label { "Username" br; input name="username" autofocus; } } | |
| 246 | 272 | p { label { "Password" br; input name="password" type="password"; } } | |
| 247 | 273 | button type="submit" { "Sign in" } | |
| 248 | 274 | } | |
| 249 | - | (crate::passkeys::shared_script()) | |
| 250 | - | (crate::passkeys::login_button()) | |
| 251 | 275 | }, | |
| 252 | 276 | ) | |
| 253 | 277 | } | |
| ⋯ 30 unchanged lines | |||
modifiedcrates/anvil-web/src/lib.rs+2 −2
| ⋯ 24 unchanged lines | |||
| 25 | 25 | pub mod attachments; | |
| 26 | 26 | pub mod auth; | |
| 27 | 27 | pub mod git_http; | |
| 28 | + | pub mod oidc; | |
| 28 | 29 | pub mod pages; | |
| 29 | - | pub mod passkeys; | |
| 30 | 30 | pub mod secrets; | |
| 31 | 31 | pub mod todomd; | |
| 32 | 32 | pub mod ui; | |
| ⋯ 12 unchanged lines | |||
| 45 | 45 | router, | |
| 46 | 46 | app.config.http.attachment_max_mb.saturating_mul(1 << 20), | |
| 47 | 47 | ); // uploaded image attachments | |
| 48 | - | router = passkeys::routes(router); // WebAuthn sign-in | |
| 48 | + | router = oidc::routes(router); // single sign-on, when configured | |
| 49 | 49 | router = secrets::routes(router); // sealed per-repo secrets + unlock API | |
| 50 | 50 | router = git_http::routes(router); // smart-HTTP git endpoints | |
| 51 | 51 | router | |
| ⋯ 23 unchanged lines | |||
addedcrates/anvil-web/src/oidc.rs+834 −0
| 1 | + | //! Single sign-on against an OpenID Connect provider (authorization code flow | |
| 2 | + | //! with PKCE) — see `docs/oidc.md`. | |
| 3 | + | //! | |
| 4 | + | //! Off unless `[oidc] issuer` is configured, so an unconfigured instance | |
| 5 | + | //! behaves exactly as it did before: local passwords only. When it is on it is | |
| 6 | + | //! *additional* rather than a replacement — existing accounts keep their | |
| 7 | + | //! passwords, and the two are reconciled on the `sub` claim, which the provider | |
| 8 | + | //! promises never changes, rather than on email, which does. | |
| 9 | + | //! | |
| 10 | + | //! Three routes make up the hand-off: | |
| 11 | + | //! | |
| 12 | + | //! | | | | |
| 13 | + | //! |---|---| | |
| 14 | + | //! | `GET /-/oidc/login?next=/path` | start the flow; stash state/nonce/PKCE in a ten-minute cookie | | |
| 15 | + | //! | `GET /-/oidc/callback` | exchange the code, provision or link the account, set the session | | |
| 16 | + | //! | `POST /-/logout` | (in [`crate::auth`]) end the provider's session too, when asked to | | |
| 17 | + | //! | |
| 18 | + | //! Everything the provider hands back is verified here: the `state` against the | |
| 19 | + | //! cookie, the id token's signature against the published JWKS, and its `iss`, | |
| 20 | + | //! `aud`, `exp` and `nonce` against what we asked for. | |
| 21 | + | ||
| 22 | + | use std::{ | |
| 23 | + | collections::HashMap, | |
| 24 | + | sync::{ | |
| 25 | + | Mutex, | |
| 26 | + | OnceLock, | |
| 27 | + | }, | |
| 28 | + | }; | |
| 29 | + | ||
| 30 | + | use anvil_core::{ | |
| 31 | + | App, | |
| 32 | + | User, | |
| 33 | + | config::{ | |
| 34 | + | OIDC_CALLBACK_PATH, | |
| 35 | + | OidcConfig, | |
| 36 | + | }, | |
| 37 | + | sessions, | |
| 38 | + | users, | |
| 39 | + | }; | |
| 40 | + | use axum::{ | |
| 41 | + | Router, | |
| 42 | + | extract::{ | |
| 43 | + | Query, | |
| 44 | + | State, | |
| 45 | + | }, | |
| 46 | + | http::StatusCode, | |
| 47 | + | response::{ | |
| 48 | + | IntoResponse, | |
| 49 | + | Redirect, | |
| 50 | + | Response, | |
| 51 | + | }, | |
| 52 | + | routing::get, | |
| 53 | + | }; | |
| 54 | + | use axum_extra::extract::cookie::{ | |
| 55 | + | Cookie, | |
| 56 | + | CookieJar, | |
| 57 | + | SameSite, | |
| 58 | + | }; | |
| 59 | + | use base64::{ | |
| 60 | + | Engine, | |
| 61 | + | engine::general_purpose::URL_SAFE_NO_PAD, | |
| 62 | + | }; | |
| 63 | + | use maud::{ | |
| 64 | + | Markup, | |
| 65 | + | html, | |
| 66 | + | }; | |
| 67 | + | use ring::{ | |
| 68 | + | digest, | |
| 69 | + | rand::{ | |
| 70 | + | SecureRandom, | |
| 71 | + | SystemRandom, | |
| 72 | + | }, | |
| 73 | + | signature, | |
| 74 | + | }; | |
| 75 | + | use serde::Deserialize; | |
| 76 | + | ||
| 77 | + | use crate::{ | |
| 78 | + | auth::session_cookie, | |
| 79 | + | ui::layout, | |
| 80 | + | }; | |
| 81 | + | ||
| 82 | + | /// Where the in-flight login's state, nonce and PKCE verifier live between the | |
| 83 | + | /// redirect out and the redirect back. Scoped to `/-/oidc` so it rides along on | |
| 84 | + | /// the callback and nothing else. | |
| 85 | + | const PENDING_COOKIE: &str = "anvil_oidc"; | |
| 86 | + | ||
| 87 | + | /// How long a login has to complete. Only has to survive one round trip. | |
| 88 | + | const PENDING_TTL_SECS: i64 = 600; | |
| 89 | + | ||
| 90 | + | /// Clock skew tolerated when checking an id token's `exp`. | |
| 91 | + | const CLOCK_SKEW_SECS: i64 = 60; | |
| 92 | + | ||
| 93 | + | pub fn routes(router: Router<App>) -> Router<App> { | |
| 94 | + | router | |
| 95 | + | .route("/-/oidc/login", get(login)) | |
| 96 | + | .route(OIDC_CALLBACK_PATH, get(callback)) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // --- the provider's metadata ------------------------------------------------ | |
| 100 | + | ||
| 101 | + | /// The subset of the discovery document we act on. | |
| 102 | + | #[derive(Clone, Debug, Deserialize)] | |
| 103 | + | struct Discovery { | |
| 104 | + | issuer: String, | |
| 105 | + | authorization_endpoint: String, | |
| 106 | + | token_endpoint: String, | |
| 107 | + | jwks_uri: String, | |
| 108 | + | #[serde(default)] | |
| 109 | + | end_session_endpoint: String, | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | /// One RSA signing key from the provider's JWKS. | |
| 113 | + | #[derive(Clone, Debug, Deserialize)] | |
| 114 | + | struct Jwk { | |
| 115 | + | #[serde(default)] | |
| 116 | + | kty: String, | |
| 117 | + | #[serde(default)] | |
| 118 | + | kid: String, | |
| 119 | + | /// Base64url big-endian modulus. | |
| 120 | + | #[serde(default)] | |
| 121 | + | n: String, | |
| 122 | + | /// Base64url big-endian public exponent. | |
| 123 | + | #[serde(default)] | |
| 124 | + | e: String, | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | /// Discovery documents, keyed by issuer. Endpoints do not move under a running | |
| 128 | + | /// server, so this is a process-lifetime cache: a provider that relocates one | |
| 129 | + | /// wants a restart here anyway. | |
| 130 | + | static DISCOVERY: OnceLock<Mutex<HashMap<String, Discovery>>> = OnceLock::new(); | |
| 131 | + | ||
| 132 | + | /// Signing keys, keyed by `jwks_uri`. Refetched when a token arrives under a | |
| 133 | + | /// `kid` we have not seen, which is how a key rotation propagates. | |
| 134 | + | static JWKS: OnceLock<Mutex<HashMap<String, Vec<Jwk>>>> = OnceLock::new(); | |
| 135 | + | ||
| 136 | + | fn cache<T: 'static>( | |
| 137 | + | slot: &'static OnceLock<Mutex<HashMap<String, T>>>, | |
| 138 | + | ) -> &'static Mutex<HashMap<String, T>> { | |
| 139 | + | slot.get_or_init(|| Mutex::new(HashMap::new())) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | /// The HTTP client for back-channel calls. Native (system) roots so a | |
| 143 | + | /// `.localhost` provider fronted by portless's CA is trusted without extra | |
| 144 | + | /// configuration; the ring provider because that is what this workspace builds | |
| 145 | + | /// rustls with (see the manifest). | |
| 146 | + | fn http() -> reqwest::Client { | |
| 147 | + | static CLIENT: OnceLock<reqwest::Client> = OnceLock::new(); | |
| 148 | + | CLIENT | |
| 149 | + | .get_or_init(|| { | |
| 150 | + | let _ = rustls::crypto::ring::default_provider().install_default(); | |
| 151 | + | reqwest::Client::builder() | |
| 152 | + | .timeout(std::time::Duration::from_secs(15)) | |
| 153 | + | .build() | |
| 154 | + | .expect("client with default settings builds") | |
| 155 | + | }) | |
| 156 | + | .clone() | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | /// Fetch (once) the provider's discovery document. | |
| 160 | + | /// | |
| 161 | + | /// The document's own `issuer` must equal the one we were configured with — | |
| 162 | + | /// otherwise a redirect or a DNS takeover could point us at somebody else's | |
| 163 | + | /// tokens while every later `iss` check still passed. | |
| 164 | + | async fn discovery(issuer: &str) -> Result<Discovery, OidcError> { | |
| 165 | + | if let Some(hit) = cache(&DISCOVERY).lock().unwrap().get(issuer).cloned() { | |
| 166 | + | return Ok(hit); | |
| 167 | + | } | |
| 168 | + | let url = format!("{issuer}/.well-known/openid-configuration"); | |
| 169 | + | let doc: Discovery = http() | |
| 170 | + | .get(&url) | |
| 171 | + | .send() | |
| 172 | + | .await | |
| 173 | + | .and_then(|r| r.error_for_status()) | |
| 174 | + | .map_err(|e| OidcError::provider(format!("could not reach {url}: {e}")))? | |
| 175 | + | .json() | |
| 176 | + | .await | |
| 177 | + | .map_err(|e| OidcError::provider(format!("{url} is not a discovery document: {e}")))?; | |
| 178 | + | ||
| 179 | + | if doc.issuer.trim_end_matches('/') != issuer { | |
| 180 | + | return Err(OidcError::provider(format!( | |
| 181 | + | "{url} claims to be {}, not {issuer}", | |
| 182 | + | doc.issuer | |
| 183 | + | ))); | |
| 184 | + | } | |
| 185 | + | cache(&DISCOVERY) | |
| 186 | + | .lock() | |
| 187 | + | .unwrap() | |
| 188 | + | .insert(issuer.to_string(), doc.clone()); | |
| 189 | + | Ok(doc) | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | /// The provider's signing keys. `refresh` skips the cache, which is what a | |
| 193 | + | /// token under an unknown `kid` asks for. | |
| 194 | + | async fn jwks(uri: &str, refresh: bool) -> Result<Vec<Jwk>, OidcError> { | |
| 195 | + | if !refresh && let Some(hit) = cache(&JWKS).lock().unwrap().get(uri).cloned() { | |
| 196 | + | return Ok(hit); | |
| 197 | + | } | |
| 198 | + | #[derive(Deserialize)] | |
| 199 | + | struct KeySet { | |
| 200 | + | keys: Vec<Jwk>, | |
| 201 | + | } | |
| 202 | + | let set: KeySet = http() | |
| 203 | + | .get(uri) | |
| 204 | + | .send() | |
| 205 | + | .await | |
| 206 | + | .and_then(|r| r.error_for_status()) | |
| 207 | + | .map_err(|e| OidcError::provider(format!("could not reach {uri}: {e}")))? | |
| 208 | + | .json() | |
| 209 | + | .await | |
| 210 | + | .map_err(|e| OidcError::provider(format!("{uri} is not a JWK set: {e}")))?; | |
| 211 | + | cache(&JWKS) | |
| 212 | + | .lock() | |
| 213 | + | .unwrap() | |
| 214 | + | .insert(uri.to_string(), set.keys.clone()); | |
| 215 | + | Ok(set.keys) | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | // --- the in-flight login ---------------------------------------------------- | |
| 219 | + | ||
| 220 | + | /// What the callback needs to remember from the request that started it. | |
| 221 | + | #[derive(Debug, Deserialize, serde::Serialize)] | |
| 222 | + | struct Pending { | |
| 223 | + | state: String, | |
| 224 | + | nonce: String, | |
| 225 | + | verifier: String, | |
| 226 | + | next: String, | |
| 227 | + | expires_at: i64, | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | impl Pending { | |
| 231 | + | /// Serialize for the cookie. Base64 rather than raw JSON: cookie values | |
| 232 | + | /// have their own grammar, and this sidesteps every quoting question. | |
| 233 | + | fn encode(&self) -> String { | |
| 234 | + | URL_SAFE_NO_PAD.encode(serde_json::to_vec(self).expect("Pending serializes")) | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | fn decode(raw: &str) -> Option<Self> { | |
| 238 | + | let bytes = URL_SAFE_NO_PAD.decode(raw).ok()?; | |
| 239 | + | serde_json::from_slice(&bytes).ok() | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | /// A cookie carrying (or, when `value` is empty, clearing) the pending login. | |
| 244 | + | fn pending_cookie(app: &App, value: String) -> Cookie<'static> { | |
| 245 | + | let max_age = if value.is_empty() { | |
| 246 | + | time::Duration::ZERO | |
| 247 | + | } else { | |
| 248 | + | time::Duration::seconds(PENDING_TTL_SECS) | |
| 249 | + | }; | |
| 250 | + | Cookie::build((PENDING_COOKIE, value)) | |
| 251 | + | // Not `/`: the callback is the only route that ever reads this. | |
| 252 | + | .path("/-/oidc") | |
| 253 | + | .http_only(true) | |
| 254 | + | .secure(app.config.secure_cookies()) | |
| 255 | + | // Strict would be withheld on the redirect back from the provider, | |
| 256 | + | // which is precisely the hop this exists for. | |
| 257 | + | .same_site(SameSite::Lax) | |
| 258 | + | .max_age(max_age) | |
| 259 | + | .build() | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | /// `n` random bytes, base64url. Used for `state`, `nonce`, and the PKCE | |
| 263 | + | /// verifier — all of which only need to be unguessable. | |
| 264 | + | fn random_token(n: usize) -> String { | |
| 265 | + | let mut bytes = vec![0u8; n]; | |
| 266 | + | SystemRandom::new() | |
| 267 | + | .fill(&mut bytes) | |
| 268 | + | .expect("the system RNG works"); | |
| 269 | + | URL_SAFE_NO_PAD.encode(bytes) | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | /// Where to go after a successful login. Only a path on this site is allowed: | |
| 273 | + | /// `next` arrives as a query parameter, so anything absolute would make the | |
| 274 | + | /// callback an open redirect. | |
| 275 | + | fn safe_next(raw: Option<&str>) -> String { | |
| 276 | + | let raw = raw.unwrap_or("/"); | |
| 277 | + | // A leading `//` or `/\` is protocol-relative and leaves the site. | |
| 278 | + | let relative = raw.starts_with('/') && !raw[1..].starts_with(['/', '\\']); | |
| 279 | + | if relative { | |
| 280 | + | raw.to_string() | |
| 281 | + | } else { | |
| 282 | + | "/".to_string() | |
| 283 | + | } | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | fn now() -> i64 { | |
| 287 | + | std::time::SystemTime::now() | |
| 288 | + | .duration_since(std::time::UNIX_EPOCH) | |
| 289 | + | .map(|d| d.as_secs() as i64) | |
| 290 | + | .unwrap_or_default() | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | // --- handlers --------------------------------------------------------------- | |
| 294 | + | ||
| 295 | + | #[derive(Deserialize)] | |
| 296 | + | struct LoginQuery { | |
| 297 | + | next: Option<String>, | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | /// `GET /-/oidc/login` — send the browser to the provider. | |
| 301 | + | async fn login(State(app): State<App>, jar: CookieJar, Query(q): Query<LoginQuery>) -> Response { | |
| 302 | + | let cfg = &app.config.oidc; | |
| 303 | + | if !cfg.enabled() { | |
| 304 | + | return OidcError::disabled().into_response(); | |
| 305 | + | } | |
| 306 | + | let disco = match discovery(cfg.issuer()).await { | |
| 307 | + | Ok(d) => d, | |
| 308 | + | Err(e) => return e.into_response(), | |
| 309 | + | }; | |
| 310 | + | ||
| 311 | + | let verifier = random_token(32); | |
| 312 | + | let pending = Pending { | |
| 313 | + | state: random_token(16), | |
| 314 | + | nonce: random_token(16), | |
| 315 | + | // PKCE S256: the provider stores this hash with the code and only | |
| 316 | + | // honours an exchange that presents the preimage, so a code stolen in | |
| 317 | + | // transit is not redeemable. | |
| 318 | + | verifier: verifier.clone(), | |
| 319 | + | next: safe_next(q.next.as_deref()), | |
| 320 | + | expires_at: now() + PENDING_TTL_SECS, | |
| 321 | + | }; | |
| 322 | + | let challenge = URL_SAFE_NO_PAD.encode(digest::digest(&digest::SHA256, verifier.as_bytes())); | |
| 323 | + | ||
| 324 | + | let mut url = match reqwest::Url::parse(&disco.authorization_endpoint) { | |
| 325 | + | Ok(url) => url, | |
| 326 | + | Err(e) => { | |
| 327 | + | return OidcError::provider(format!("bad authorization endpoint: {e}")).into_response(); | |
| 328 | + | } | |
| 329 | + | }; | |
| 330 | + | url.query_pairs_mut() | |
| 331 | + | .append_pair("response_type", "code") | |
| 332 | + | .append_pair("client_id", &cfg.client_id) | |
| 333 | + | .append_pair("redirect_uri", &app.config.oidc_redirect_uri()) | |
| 334 | + | .append_pair("scope", "openid profile email") | |
| 335 | + | .append_pair("state", &pending.state) | |
| 336 | + | .append_pair("nonce", &pending.nonce) | |
| 337 | + | .append_pair("code_challenge", &challenge) | |
| 338 | + | .append_pair("code_challenge_method", "S256"); | |
| 339 | + | ||
| 340 | + | ( | |
| 341 | + | jar.add(pending_cookie(&app, pending.encode())), | |
| 342 | + | Redirect::to(url.as_str()), | |
| 343 | + | ) | |
| 344 | + | .into_response() | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | #[derive(Deserialize)] | |
| 348 | + | struct CallbackQuery { | |
| 349 | + | code: Option<String>, | |
| 350 | + | state: Option<String>, | |
| 351 | + | error: Option<String>, | |
| 352 | + | error_description: Option<String>, | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | /// `GET /-/oidc/callback` — finish the flow and sign the user in. | |
| 356 | + | async fn callback( | |
| 357 | + | State(app): State<App>, | |
| 358 | + | jar: CookieJar, | |
| 359 | + | Query(q): Query<CallbackQuery>, | |
| 360 | + | ) -> Response { | |
| 361 | + | let cfg = &app.config.oidc; | |
| 362 | + | if !cfg.enabled() { | |
| 363 | + | return OidcError::disabled().into_response(); | |
| 364 | + | } | |
| 365 | + | // Read it before clearing it: adding the removal cookie replaces the entry | |
| 366 | + | // in the jar, and the value would be gone by the time we looked. | |
| 367 | + | let pending = jar | |
| 368 | + | .get(PENDING_COOKIE) | |
| 369 | + | .and_then(|c| Pending::decode(c.value())); | |
| 370 | + | // Whatever happens next, this login is over. | |
| 371 | + | let jar = jar.add(pending_cookie(&app, String::new())); | |
| 372 | + | ||
| 373 | + | if let Some(error) = q.error { | |
| 374 | + | let detail = q.error_description.unwrap_or_else(|| error.clone()); | |
| 375 | + | let status = match error.as_str() { | |
| 376 | + | "access_denied" => StatusCode::FORBIDDEN, | |
| 377 | + | _ => StatusCode::BAD_REQUEST, | |
| 378 | + | }; | |
| 379 | + | return (jar, OidcError::new(status, detail)).into_response(); | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | let pending = match pending { | |
| 383 | + | Some(p) if p.expires_at > now() => p, | |
| 384 | + | _ => { | |
| 385 | + | return ( | |
| 386 | + | jar, | |
| 387 | + | OidcError::new( | |
| 388 | + | StatusCode::BAD_REQUEST, | |
| 389 | + | "This sign-in took too long, or was started in another browser. Try again.", | |
| 390 | + | ), | |
| 391 | + | ) | |
| 392 | + | .into_response(); | |
| 393 | + | } | |
| 394 | + | }; | |
| 395 | + | ||
| 396 | + | // Binds the response to the request we started. Constant-time because the | |
| 397 | + | // state is the one secret in the callback URL. | |
| 398 | + | if !crate::auth::constant_time_eq( | |
| 399 | + | pending.state.as_bytes(), | |
| 400 | + | q.state.unwrap_or_default().as_bytes(), | |
| 401 | + | ) { | |
| 402 | + | return ( | |
| 403 | + | jar, | |
| 404 | + | OidcError::new(StatusCode::BAD_REQUEST, "The sign-in state did not match."), | |
| 405 | + | ) | |
| 406 | + | .into_response(); | |
| 407 | + | } | |
| 408 | + | let Some(code) = q.code.filter(|c| !c.is_empty()) else { | |
| 409 | + | return ( | |
| 410 | + | jar, | |
| 411 | + | OidcError::new(StatusCode::BAD_REQUEST, "The provider returned no code."), | |
| 412 | + | ) | |
| 413 | + | .into_response(); | |
| 414 | + | }; | |
| 415 | + | ||
| 416 | + | let claims = match exchange(&app, &code, &pending).await { | |
| 417 | + | Ok(claims) => claims, | |
| 418 | + | Err(e) => return (jar, e).into_response(), | |
| 419 | + | }; | |
| 420 | + | let user = match resolve_local_user(&app, &claims).await { | |
| 421 | + | Ok(user) => user, | |
| 422 | + | Err(e) => return (jar, e).into_response(), | |
| 423 | + | }; | |
| 424 | + | ||
| 425 | + | match sessions::create(&app.db, user.id).await { | |
| 426 | + | Ok(session) => ( | |
| 427 | + | jar.add(session_cookie(&app, session.token)), | |
| 428 | + | Redirect::to(&pending.next), | |
| 429 | + | ) | |
| 430 | + | .into_response(), | |
| 431 | + | Err(e) => { | |
| 432 | + | tracing::error!("session create failed after sso login: {e}"); | |
| 433 | + | (jar, OidcError::provider("Could not start a session.")).into_response() | |
| 434 | + | } | |
| 435 | + | } | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | /// Trade the authorization code for an id token, and verify it. | |
| 439 | + | async fn exchange(app: &App, code: &str, pending: &Pending) -> Result<Claims, OidcError> { | |
| 440 | + | let cfg = &app.config.oidc; | |
| 441 | + | let disco = discovery(cfg.issuer()).await?; | |
| 442 | + | let redirect_uri = app.config.oidc_redirect_uri(); | |
| 443 | + | ||
| 444 | + | let mut form = vec![ | |
| 445 | + | ("grant_type", "authorization_code"), | |
| 446 | + | ("code", code), | |
| 447 | + | ("redirect_uri", redirect_uri.as_str()), | |
| 448 | + | ("code_verifier", pending.verifier.as_str()), | |
| 449 | + | ("client_id", cfg.client_id.as_str()), | |
| 450 | + | ]; | |
| 451 | + | if !cfg.client_secret.is_empty() { | |
| 452 | + | form.push(("client_secret", cfg.client_secret.as_str())); | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | let response = http() | |
| 456 | + | .post(&disco.token_endpoint) | |
| 457 | + | .form(&form) | |
| 458 | + | .send() | |
| 459 | + | .await | |
| 460 | + | .map_err(|e| OidcError::provider(format!("token endpoint unreachable: {e}")))?; | |
| 461 | + | ||
| 462 | + | let status = response.status(); | |
| 463 | + | let body = response.text().await.unwrap_or_default(); | |
| 464 | + | if !status.is_success() { | |
| 465 | + | // The body is the provider's own `{error, error_description}`; report | |
| 466 | + | // the description when there is one, the status otherwise. | |
| 467 | + | let detail = serde_json::from_str::<serde_json::Value>(&body) | |
| 468 | + | .ok() | |
| 469 | + | .and_then(|v| { | |
| 470 | + | v.get("error_description") | |
| 471 | + | .or_else(|| v.get("error")) | |
| 472 | + | .and_then(|d| d.as_str().map(str::to_string)) | |
| 473 | + | }) | |
| 474 | + | .unwrap_or_else(|| format!("token endpoint returned {status}")); | |
| 475 | + | return Err(OidcError::new(StatusCode::BAD_GATEWAY, detail)); | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | #[derive(Deserialize)] | |
| 479 | + | struct Tokens { | |
| 480 | + | id_token: String, | |
| 481 | + | } | |
| 482 | + | let tokens: Tokens = serde_json::from_str(&body) | |
| 483 | + | .map_err(|e| OidcError::provider(format!("token response has no id_token: {e}")))?; | |
| 484 | + | ||
| 485 | + | verify_id_token(&tokens.id_token, cfg, &disco, &pending.nonce).await | |
| 486 | + | } | |
| 487 | + | ||
| 488 | + | // --- id token verification -------------------------------------------------- | |
| 489 | + | ||
| 490 | + | /// The claims anvil acts on. `sub` identifies the account; `role` is this | |
| 491 | + | /// user's role *for this app*, as granted at the provider. | |
| 492 | + | #[derive(Debug, Deserialize)] | |
| 493 | + | struct Claims { | |
| 494 | + | sub: String, | |
| 495 | + | iss: String, | |
| 496 | + | aud: serde_json::Value, | |
| 497 | + | exp: i64, | |
| 498 | + | #[serde(default)] | |
| 499 | + | nonce: String, | |
| 500 | + | #[serde(default)] | |
| 501 | + | email: String, | |
| 502 | + | #[serde(default)] | |
| 503 | + | email_verified: bool, | |
| 504 | + | #[serde(default)] | |
| 505 | + | preferred_username: String, | |
| 506 | + | #[serde(default)] | |
| 507 | + | role: String, | |
| 508 | + | } | |
| 509 | + | ||
| 510 | + | impl Claims { | |
| 511 | + | /// Whether the provider says this account administers anvil. `None` when | |
| 512 | + | /// it says nothing, which leaves the local flag alone. | |
| 513 | + | fn is_admin(&self) -> Option<bool> { | |
| 514 | + | (!self.role.is_empty()).then(|| self.role == "admin") | |
| 515 | + | } | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | #[derive(Deserialize)] | |
| 519 | + | struct JwtHeader { | |
| 520 | + | alg: String, | |
| 521 | + | #[serde(default)] | |
| 522 | + | kid: String, | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | /// Verify an id token's signature and every claim that binds it to *this* | |
| 526 | + | /// login: the issuer, the audience, its expiry, and the nonce we generated. | |
| 527 | + | async fn verify_id_token( | |
| 528 | + | token: &str, | |
| 529 | + | cfg: &OidcConfig, | |
| 530 | + | disco: &Discovery, | |
| 531 | + | nonce: &str, | |
| 532 | + | ) -> Result<Claims, OidcError> { | |
| 533 | + | let bad = |msg: &str| OidcError::new(StatusCode::BAD_GATEWAY, format!("id token {msg}")); | |
| 534 | + | ||
| 535 | + | let mut parts = token.split('.'); | |
| 536 | + | let (Some(header_b64), Some(payload_b64), Some(sig_b64), None) = | |
| 537 | + | (parts.next(), parts.next(), parts.next(), parts.next()) | |
| 538 | + | else { | |
| 539 | + | return Err(bad("is not a three-part JWS")); | |
| 540 | + | }; | |
| 541 | + | let decode = |part: &str| { | |
| 542 | + | URL_SAFE_NO_PAD | |
| 543 | + | .decode(part) | |
| 544 | + | .map_err(|_| bad("is not base64url")) | |
| 545 | + | }; | |
| 546 | + | let header: JwtHeader = serde_json::from_slice(&decode(header_b64)?) | |
| 547 | + | .map_err(|_| bad("has an unreadable header"))?; | |
| 548 | + | // RS256 only. Accepting whatever `alg` says is how `none` and | |
| 549 | + | // algorithm-confusion attacks get in; the provider signs RS256 and that is | |
| 550 | + | // the only thing we verify. | |
| 551 | + | if header.alg != "RS256" { | |
| 552 | + | return Err(bad(&format!("is signed with {}, not RS256", header.alg))); | |
| 553 | + | } | |
| 554 | + | ||
| 555 | + | let signing_input = format!("{header_b64}.{payload_b64}"); | |
| 556 | + | let signature_bytes = decode(sig_b64)?; | |
| 557 | + | let mut keys = jwks(&disco.jwks_uri, false).await?; | |
| 558 | + | if !keys.iter().any(|k| matches(k, &header.kid)) { | |
| 559 | + | // An unknown key id means a rotation since we last looked. | |
| 560 | + | keys = jwks(&disco.jwks_uri, true).await?; | |
| 561 | + | } | |
| 562 | + | let key = keys | |
| 563 | + | .iter() | |
| 564 | + | .find(|k| matches(k, &header.kid)) | |
| 565 | + | .ok_or_else(|| bad("was signed by a key the provider does not publish"))?; | |
| 566 | + | ||
| 567 | + | let n = URL_SAFE_NO_PAD | |
| 568 | + | .decode(&key.n) | |
| 569 | + | .map_err(|_| bad("key modulus is not base64url"))?; | |
| 570 | + | let e = URL_SAFE_NO_PAD | |
| 571 | + | .decode(&key.e) | |
| 572 | + | .map_err(|_| bad("key exponent is not base64url"))?; | |
| 573 | + | signature::RsaPublicKeyComponents { n: &n, e: &e } | |
| 574 | + | .verify( | |
| 575 | + | &signature::RSA_PKCS1_2048_8192_SHA256, | |
| 576 | + | signing_input.as_bytes(), | |
| 577 | + | &signature_bytes, | |
| 578 | + | ) | |
| 579 | + | .map_err(|_| bad("signature does not verify"))?; | |
| 580 | + | ||
| 581 | + | let claims: Claims = | |
| 582 | + | serde_json::from_slice(&decode(payload_b64)?).map_err(|_| bad("has unreadable claims"))?; | |
| 583 | + | ||
| 584 | + | if claims.iss.trim_end_matches('/') != cfg.issuer() { | |
| 585 | + | return Err(bad("came from a different issuer")); | |
| 586 | + | } | |
| 587 | + | let audience_matches = match &claims.aud { | |
| 588 | + | serde_json::Value::String(one) => one == &cfg.client_id, | |
| 589 | + | serde_json::Value::Array(many) => many.iter().any(|a| a.as_str() == Some(&cfg.client_id)), | |
| 590 | + | _ => false, | |
| 591 | + | }; | |
| 592 | + | if !audience_matches { | |
| 593 | + | return Err(bad("was issued for a different client")); | |
| 594 | + | } | |
| 595 | + | if claims.exp + CLOCK_SKEW_SECS < now() { | |
| 596 | + | return Err(bad("has expired")); | |
| 597 | + | } | |
| 598 | + | // The nonce is what stops a token captured in one login from being | |
| 599 | + | // replayed into another. | |
| 600 | + | if !crate::auth::constant_time_eq(claims.nonce.as_bytes(), nonce.as_bytes()) { | |
| 601 | + | return Err(bad("nonce does not match this login")); | |
| 602 | + | } | |
| 603 | + | if claims.sub.is_empty() { | |
| 604 | + | return Err(bad("has no subject")); | |
| 605 | + | } | |
| 606 | + | Ok(claims) | |
| 607 | + | } | |
| 608 | + | ||
| 609 | + | /// Whether a JWK is the one a token's `kid` names. A key set with exactly one | |
| 610 | + | /// key needs no `kid` on either side to be unambiguous. | |
| 611 | + | fn matches(key: &Jwk, kid: &str) -> bool { | |
| 612 | + | key.kty == "RSA" && (key.kid == kid || (kid.is_empty() && key.kid.is_empty())) | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | // --- mapping an identity onto a local account ------------------------------- | |
| 616 | + | ||
| 617 | + | /// Find or create the local account for a verified identity. | |
| 618 | + | /// | |
| 619 | + | /// The provider has already decided this person may use anvil, so there is no | |
| 620 | + | /// invite list to consult here — only the question of *which* row is theirs. | |
| 621 | + | async fn resolve_local_user(app: &App, claims: &Claims) -> Result<User, OidcError> { | |
| 622 | + | let db = &app.db; | |
| 623 | + | let email = claims.email.trim().to_ascii_lowercase(); | |
| 624 | + | let failed = |e: anvil_core::Error| OidcError::provider(e.to_string()); | |
| 625 | + | ||
| 626 | + | // 1. Seen before. Every login after the first lands here. | |
| 627 | + | if let Some(user) = users::find_by_sso_sub(db, &claims.sub) | |
| 628 | + | .await | |
| 629 | + | .map_err(failed)? | |
| 630 | + | { | |
| 631 | + | return users::sync_from_sso(db, user.id, &email, claims.is_admin()) | |
| 632 | + | .await | |
| 633 | + | .map_err(failed); | |
| 634 | + | } | |
| 635 | + | ||
| 636 | + | // 2. An account that predates single sign-on. Adopt it by email once, and | |
| 637 | + | // only on an address the provider says it verified — linking on an | |
| 638 | + | // unverified one is how one account takes over another. | |
| 639 | + | if let Some(existing) = users::find_by_email(db, &email).await.map_err(failed)? { | |
| 640 | + | if !claims.email_verified { | |
| 641 | + | return Err(OidcError::new( | |
| 642 | + | StatusCode::FORBIDDEN, | |
| 643 | + | format!( | |
| 644 | + | "An anvil account already exists for {email}, but {} has not verified that \ | |
| 645 | + | address. Sign in with your password instead.", | |
| 646 | + | app.config.oidc.label() | |
| 647 | + | ), | |
| 648 | + | )); | |
| 649 | + | } | |
| 650 | + | let user = users::link_sso_sub(db, existing.id, &claims.sub) | |
| 651 | + | .await | |
| 652 | + | .map_err(failed)?; | |
| 653 | + | return users::sync_from_sso(db, user.id, &email, claims.is_admin()) | |
| 654 | + | .await | |
| 655 | + | .map_err(failed); | |
| 656 | + | } | |
| 657 | + | ||
| 658 | + | // 3. Brand new. | |
| 659 | + | users::create_from_sso( | |
| 660 | + | db, | |
| 661 | + | &claims.preferred_username, | |
| 662 | + | &email, | |
| 663 | + | claims.is_admin().unwrap_or(false), | |
| 664 | + | &claims.sub, | |
| 665 | + | ) | |
| 666 | + | .await | |
| 667 | + | .map_err(failed) | |
| 668 | + | } | |
| 669 | + | ||
| 670 | + | // --- logout ----------------------------------------------------------------- | |
| 671 | + | ||
| 672 | + | /// Where to send a browser that has just signed out locally, when the account | |
| 673 | + | /// came from the provider and `sso_logout` is on: the provider's own logout, | |
| 674 | + | /// which is what makes "sign out" mean everywhere rather than just here. | |
| 675 | + | /// | |
| 676 | + | /// `None` whenever that does not apply, or the provider advertises no | |
| 677 | + | /// `end_session_endpoint` — a local sign-out is still a sign-out. | |
| 678 | + | pub(crate) async fn end_session_url(app: &App, user: Option<&User>) -> Option<String> { | |
| 679 | + | let cfg = &app.config.oidc; | |
| 680 | + | if !cfg.enabled() || !cfg.sso_logout || user.is_none_or(|u| u.sso_sub.is_empty()) { | |
| 681 | + | return None; | |
| 682 | + | } | |
| 683 | + | let disco = discovery(cfg.issuer()).await.ok()?; | |
| 684 | + | if disco.end_session_endpoint.is_empty() { | |
| 685 | + | return None; | |
| 686 | + | } | |
| 687 | + | let mut url = reqwest::Url::parse(&disco.end_session_endpoint).ok()?; | |
| 688 | + | // Round-tripped through `Url` so a bare origin carries the trailing slash: | |
| 689 | + | // the provider stores the *normalized* form of what was registered and | |
| 690 | + | // compares it character for character, and an unmatched URI is ignored — | |
| 691 | + | // leaving the user on the provider's page instead of back here. | |
| 692 | + | let home = reqwest::Url::parse(&app.config.http.base_url).ok()?; | |
| 693 | + | url.query_pairs_mut() | |
| 694 | + | .append_pair("client_id", &cfg.client_id) | |
| 695 | + | .append_pair("post_logout_redirect_uri", home.as_str()); | |
| 696 | + | Some(url.to_string()) | |
| 697 | + | } | |
| 698 | + | ||
| 699 | + | // --- presentation ----------------------------------------------------------- | |
| 700 | + | ||
| 701 | + | /// The sign-in button for the login page. Empty when no provider is | |
| 702 | + | /// configured, which is what keeps the page unchanged for everyone else. | |
| 703 | + | pub(crate) fn sign_in_button(cfg: &OidcConfig, next: Option<&str>) -> Markup { | |
| 704 | + | if !cfg.enabled() { | |
| 705 | + | return html! {}; | |
| 706 | + | } | |
| 707 | + | let href = match safe_next(next) { | |
| 708 | + | next if next != "/" => format!("/-/oidc/login?next={}", percent_encode(&next)), | |
| 709 | + | _ => "/-/oidc/login".to_string(), | |
| 710 | + | }; | |
| 711 | + | html! { | |
| 712 | + | div style="max-width:320px" { | |
| 713 | + | a.btn href=(href) style="display:block;text-align:center" { | |
| 714 | + | "Sign in with " (cfg.label()) | |
| 715 | + | } | |
| 716 | + | p.muted style="margin:16px 0 4px;font-size:12px" { "or use an anvil password" } | |
| 717 | + | } | |
| 718 | + | } | |
| 719 | + | } | |
| 720 | + | ||
| 721 | + | /// Percent-encode a path for use in a query parameter, keeping `/` readable. | |
| 722 | + | /// `reqwest::Url` would want a base URL we do not have here. | |
| 723 | + | fn percent_encode(path: &str) -> String { | |
| 724 | + | path.bytes() | |
| 725 | + | .map(|b| match b { | |
| 726 | + | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => { | |
| 727 | + | (b as char).to_string() | |
| 728 | + | } | |
| 729 | + | _ => format!("%{b:02X}"), | |
| 730 | + | }) | |
| 731 | + | .collect() | |
| 732 | + | } | |
| 733 | + | ||
| 734 | + | /// A failed sign-in, rendered as a page rather than swallowed: every one of | |
| 735 | + | /// these is either a misconfiguration or an attack, and both want saying out | |
| 736 | + | /// loud. | |
| 737 | + | pub(crate) struct OidcError { | |
| 738 | + | status: StatusCode, | |
| 739 | + | message: String, | |
| 740 | + | } | |
| 741 | + | ||
| 742 | + | impl OidcError { | |
| 743 | + | fn new(status: StatusCode, message: impl Into<String>) -> Self { | |
| 744 | + | Self { | |
| 745 | + | status, | |
| 746 | + | message: message.into(), | |
| 747 | + | } | |
| 748 | + | } | |
| 749 | + | ||
| 750 | + | /// The provider is unreachable, misconfigured, or answering nonsense. | |
| 751 | + | fn provider(message: impl Into<String>) -> Self { | |
| 752 | + | Self::new(StatusCode::BAD_GATEWAY, message) | |
| 753 | + | } | |
| 754 | + | ||
| 755 | + | fn disabled() -> Self { | |
| 756 | + | Self::new( | |
| 757 | + | StatusCode::NOT_FOUND, | |
| 758 | + | "Single sign-on is not configured on this instance.", | |
| 759 | + | ) | |
| 760 | + | } | |
| 761 | + | } | |
| 762 | + | ||
| 763 | + | impl IntoResponse for OidcError { | |
| 764 | + | fn into_response(self) -> Response { | |
| 765 | + | tracing::warn!("sso sign-in failed: {}", self.message); | |
| 766 | + | ( | |
| 767 | + | self.status, | |
| 768 | + | layout( | |
| 769 | + | "Sign-in failed", | |
| 770 | + | None, | |
| 771 | + | html! { | |
| 772 | + | h1 { "Sign-in failed" } | |
| 773 | + | p.error-msg { (self.message) } | |
| 774 | + | p { a href="/-/login" { "Back to sign in" } } | |
| 775 | + | }, | |
| 776 | + | ), | |
| 777 | + | ) | |
| 778 | + | .into_response() | |
| 779 | + | } | |
| 780 | + | } | |
| 781 | + | ||
| 782 | + | #[cfg(test)] | |
| 783 | + | mod tests { | |
| 784 | + | use super::*; | |
| 785 | + | ||
| 786 | + | #[test] | |
| 787 | + | fn next_must_stay_on_this_site() { | |
| 788 | + | assert_eq!(safe_next(Some("/collin/anvil")), "/collin/anvil"); | |
| 789 | + | assert_eq!(safe_next(None), "/"); | |
| 790 | + | assert_eq!(safe_next(Some("")), "/"); | |
| 791 | + | // Protocol-relative and absolute URLs are the open redirect this guards. | |
| 792 | + | assert_eq!(safe_next(Some("//evil.example")), "/"); | |
| 793 | + | assert_eq!(safe_next(Some("/\\evil.example")), "/"); | |
| 794 | + | assert_eq!(safe_next(Some("https://evil.example")), "/"); | |
| 795 | + | } | |
| 796 | + | ||
| 797 | + | #[test] | |
| 798 | + | fn a_pending_login_survives_the_cookie_round_trip() { | |
| 799 | + | let pending = Pending { | |
| 800 | + | state: random_token(16), | |
| 801 | + | nonce: random_token(16), | |
| 802 | + | verifier: random_token(32), | |
| 803 | + | next: "/collin/anvil".into(), | |
| 804 | + | expires_at: 1234, | |
| 805 | + | }; | |
| 806 | + | let decoded = Pending::decode(&pending.encode()).expect("round trips"); | |
| 807 | + | assert_eq!(decoded.state, pending.state); | |
| 808 | + | assert_eq!(decoded.verifier, pending.verifier); | |
| 809 | + | assert_eq!(decoded.next, "/collin/anvil"); | |
| 810 | + | assert!(Pending::decode("not base64").is_none()); | |
| 811 | + | } | |
| 812 | + | ||
| 813 | + | /// The sign-in button is the one thing an unconfigured instance must not | |
| 814 | + | /// grow, and a `next` on it must survive into the query string. | |
| 815 | + | #[test] | |
| 816 | + | fn the_sign_in_button_appears_only_when_configured() { | |
| 817 | + | let off = OidcConfig::default(); | |
| 818 | + | assert_eq!(sign_in_button(&off, None).into_string(), ""); | |
| 819 | + | ||
| 820 | + | let on = OidcConfig { | |
| 821 | + | issuer: "https://login.localhost".into(), | |
| 822 | + | ..OidcConfig::default() | |
| 823 | + | }; | |
| 824 | + | let markup = sign_in_button(&on, Some("/collin/anvil?tab=ci")).into_string(); | |
| 825 | + | assert!(markup.contains("Sign in with login.localhost"), "{markup}"); | |
| 826 | + | assert!( | |
| 827 | + | markup.contains("/-/oidc/login?next=/collin/anvil%3Ftab%3Dci"), | |
| 828 | + | "{markup}" | |
| 829 | + | ); | |
| 830 | + | // An absolute `next` is dropped rather than carried into the redirect. | |
| 831 | + | let markup = sign_in_button(&on, Some("https://evil.example")).into_string(); | |
| 832 | + | assert!(markup.contains(r#"href="/-/oidc/login""#), "{markup}"); | |
| 833 | + | } | |
| 834 | + | } |
deletedcrates/anvil-web/src/passkeys.rs+0 −744
| 1 | - | //! Passkey sign-in: the two WebAuthn ceremonies, plus the browser glue. | |
| 2 | - | //! | |
| 3 | - | //! Registration (signed in) and authentication (signed out) each run as | |
| 4 | - | //! *begin* → *finish*. Begin mints a challenge, stashes the server half in | |
| 5 | - | //! [`anvil_core::passkeys::Ceremonies`], and returns the client half as JSON. | |
| 6 | - | //! Finish takes what `navigator.credentials` produced, verifies it against the | |
| 7 | - | //! stashed challenge, and either stores a credential or starts a session. | |
| 8 | - | //! | |
| 9 | - | //! Sign-in is usernameless: passkeys are discoverable credentials, so the | |
| 10 | - | //! authenticator hands back the credential id it used and we look the account | |
| 11 | - | //! up from that. | |
| 12 | - | ||
| 13 | - | use anvil_core::{ | |
| 14 | - | App, | |
| 15 | - | User, | |
| 16 | - | passkeys::{ | |
| 17 | - | self, | |
| 18 | - | Ceremony, | |
| 19 | - | }, | |
| 20 | - | sessions, | |
| 21 | - | users, | |
| 22 | - | }; | |
| 23 | - | use axum::{ | |
| 24 | - | Json, | |
| 25 | - | Router, | |
| 26 | - | extract::{ | |
| 27 | - | Path, | |
| 28 | - | State, | |
| 29 | - | }, | |
| 30 | - | http::{ | |
| 31 | - | HeaderMap, | |
| 32 | - | StatusCode, | |
| 33 | - | }, | |
| 34 | - | response::{ | |
| 35 | - | IntoResponse, | |
| 36 | - | Redirect, | |
| 37 | - | Response, | |
| 38 | - | }, | |
| 39 | - | routing::post, | |
| 40 | - | }; | |
| 41 | - | use base64::Engine; | |
| 42 | - | use maud::{ | |
| 43 | - | Markup, | |
| 44 | - | PreEscaped, | |
| 45 | - | html, | |
| 46 | - | }; | |
| 47 | - | use serde::{ | |
| 48 | - | Deserialize, | |
| 49 | - | Serialize, | |
| 50 | - | }; | |
| 51 | - | use webauthn_rp::{ | |
| 52 | - | AuthenticatedCredential, | |
| 53 | - | DiscoverableCredentialRequestOptions, | |
| 54 | - | PublicKeyCredentialCreationOptions, | |
| 55 | - | bin::{ | |
| 56 | - | Decode, | |
| 57 | - | Encode, | |
| 58 | - | }, | |
| 59 | - | request::{ | |
| 60 | - | ExtensionInfo, | |
| 61 | - | PublicKeyCredentialDescriptor, | |
| 62 | - | auth::AuthenticationVerificationOptions, | |
| 63 | - | register::{ | |
| 64 | - | CredProtect, | |
| 65 | - | Nickname, | |
| 66 | - | PublicKeyCredentialUserEntity, | |
| 67 | - | RegistrationVerificationOptions, | |
| 68 | - | UserHandle64, | |
| 69 | - | Username, | |
| 70 | - | }, | |
| 71 | - | }, | |
| 72 | - | response::{ | |
| 73 | - | AuthTransports, | |
| 74 | - | CredentialId, | |
| 75 | - | auth::ser_relaxed::AuthenticationRelaxed, | |
| 76 | - | register::{ | |
| 77 | - | CompressedPubKey, | |
| 78 | - | DynamicState, | |
| 79 | - | StaticState, | |
| 80 | - | ser_relaxed::RegistrationRelaxed, | |
| 81 | - | }, | |
| 82 | - | }, | |
| 83 | - | }; | |
| 84 | - | ||
| 85 | - | use crate::{ | |
| 86 | - | auth::{ | |
| 87 | - | Csrf, | |
| 88 | - | CurrentUser, | |
| 89 | - | verify_csrf, | |
| 90 | - | }, | |
| 91 | - | ui::{ | |
| 92 | - | csrf_input, | |
| 93 | - | fmt_relative, | |
| 94 | - | }, | |
| 95 | - | }; | |
| 96 | - | ||
| 97 | - | /// The stored public key, in the shape `webauthn_rp` decodes into. | |
| 98 | - | type StoredKey = CompressedPubKey<[u8; 32], [u8; 32], [u8; 48], Vec<u8>>; | |
| 99 | - | ||
| 100 | - | pub fn routes(router: Router<App>) -> Router<App> { | |
| 101 | - | router | |
| 102 | - | .route("/-/settings/passkeys/begin", post(register_begin)) | |
| 103 | - | .route("/-/settings/passkeys/finish", post(register_finish)) | |
| 104 | - | .route("/-/settings/passkeys/{id}/delete", post(delete_passkey)) | |
| 105 | - | .route("/-/login/passkey/begin", post(login_begin)) | |
| 106 | - | .route("/-/login/passkey/finish", post(login_finish)) | |
| 107 | - | } | |
| 108 | - | ||
| 109 | - | // --- registration ---------------------------------------------------------- | |
| 110 | - | ||
| 111 | - | #[derive(Serialize)] | |
| 112 | - | struct BeginResponse { | |
| 113 | - | ceremony: String, | |
| 114 | - | options: serde_json::Value, | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | /// `POST /-/settings/passkeys/begin` — issue a registration challenge. | |
| 118 | - | async fn register_begin( | |
| 119 | - | State(app): State<App>, | |
| 120 | - | CurrentUser(user): CurrentUser, | |
| 121 | - | csrf: Csrf, | |
| 122 | - | headers: HeaderMap, | |
| 123 | - | ) -> Response { | |
| 124 | - | let Some(user) = user else { | |
| 125 | - | return (StatusCode::UNAUTHORIZED, "sign in first").into_response(); | |
| 126 | - | }; | |
| 127 | - | if let Err(resp) = check_csrf(&csrf, &headers) { | |
| 128 | - | return resp; | |
| 129 | - | } | |
| 130 | - | let rp = match passkeys::rp_id(&app.config.http.base_url) { | |
| 131 | - | Ok(rp) => rp, | |
| 132 | - | Err(e) => return server_error(e), | |
| 133 | - | }; | |
| 134 | - | ||
| 135 | - | // Reuse this account's existing handle so the authenticator files a second | |
| 136 | - | // passkey under the same user rather than inventing a parallel identity. | |
| 137 | - | let existing = passkeys::list(&app.db, user.id).await.unwrap_or_default(); | |
| 138 | - | let handle = match existing.first() { | |
| 139 | - | Some(key) => match decode_handle(&key.user_handle) { | |
| 140 | - | Some(handle) => handle, | |
| 141 | - | None => return server_error("stored passkey handle is malformed"), | |
| 142 | - | }, | |
| 143 | - | None => passkeys::new_user_handle(), | |
| 144 | - | }; | |
| 145 | - | ||
| 146 | - | // Excluding what is already registered is what makes a second attempt on | |
| 147 | - | // the same authenticator say "already registered" instead of silently | |
| 148 | - | // creating a duplicate. | |
| 149 | - | let exclude = existing | |
| 150 | - | .iter() | |
| 151 | - | .filter_map(|key| { | |
| 152 | - | let id = b64url().decode(&key.credential_id).ok()?; | |
| 153 | - | Some(PublicKeyCredentialDescriptor { | |
| 154 | - | id: CredentialId::decode(id).ok()?, | |
| 155 | - | transports: decode_transports(key.transports), | |
| 156 | - | }) | |
| 157 | - | }) | |
| 158 | - | .collect(); | |
| 159 | - | ||
| 160 | - | let username = match Username::try_from(user.username.as_str()) { | |
| 161 | - | Ok(name) => name, | |
| 162 | - | Err(_) => return server_error("username is not usable as a WebAuthn name"), | |
| 163 | - | }; | |
| 164 | - | let display_name = Nickname::try_from(user.username.as_str()).ok(); | |
| 165 | - | let entity = PublicKeyCredentialUserEntity { | |
| 166 | - | name: username, | |
| 167 | - | id: &handle, | |
| 168 | - | display_name, | |
| 169 | - | }; | |
| 170 | - | ||
| 171 | - | let mut options = PublicKeyCredentialCreationOptions::passkey(&rp, entity, exclude); | |
| 172 | - | // Ask for credProtect, but never *enforce* it. The crate's passkey preset | |
| 173 | - | // enforces the policy, and an authenticator that does not implement the | |
| 174 | - | // extension — a phone over hybrid, many security keys — then fails the whole | |
| 175 | - | // ceremony rather than ignoring it ("Something went wrong" in Chrome's | |
| 176 | - | // dialog, with nothing reaching the server). Enforcement buys nothing here: | |
| 177 | - | // both ceremonies already require user verification, and the UV flag is | |
| 178 | - | // checked on every assertion, so a UV-less credential could not sign in | |
| 179 | - | // anyway. | |
| 180 | - | options.extensions.cred_protect = | |
| 181 | - | CredProtect::UserVerificationRequired(ExtensionInfo::AllowDontEnforceValue); | |
| 182 | - | let (server_state, client_state) = match options.start_ceremony() { | |
| 183 | - | Ok(pair) => pair, | |
| 184 | - | Err(e) => return server_error(format!("building registration options: {e}")), | |
| 185 | - | }; | |
| 186 | - | let options = match serde_json::to_value(&client_state) { | |
| 187 | - | Ok(value) => value, | |
| 188 | - | Err(e) => return server_error(e), | |
| 189 | - | }; | |
| 190 | - | let ceremony = app.ceremonies.insert(Ceremony::Register { | |
| 191 | - | state: Box::new(server_state), | |
| 192 | - | user_id: user.id, | |
| 193 | - | }); | |
| 194 | - | // The handle travels with the ceremony via the credential we are about to | |
| 195 | - | // store; keep it here so finish() writes the same bytes the browser saw. | |
| 196 | - | let handle_b64 = base64::engine::general_purpose::STANDARD.encode(handle.as_ref()); | |
| 197 | - | Json(serde_json::json!({ | |
| 198 | - | "ceremony": ceremony, | |
| 199 | - | "options": options, | |
| 200 | - | "handle": handle_b64, | |
| 201 | - | })) | |
| 202 | - | .into_response() | |
| 203 | - | } | |
| 204 | - | ||
| 205 | - | #[derive(Deserialize)] | |
| 206 | - | struct RegisterFinish { | |
| 207 | - | ceremony: String, | |
| 208 | - | #[serde(default)] | |
| 209 | - | name: String, | |
| 210 | - | handle: String, | |
| 211 | - | credential: serde_json::Value, | |
| 212 | - | } | |
| 213 | - | ||
| 214 | - | /// `POST /-/settings/passkeys/finish` — verify and store the new credential. | |
| 215 | - | async fn register_finish( | |
| 216 | - | State(app): State<App>, | |
| 217 | - | CurrentUser(user): CurrentUser, | |
| 218 | - | csrf: Csrf, | |
| 219 | - | headers: HeaderMap, | |
| 220 | - | Json(body): Json<RegisterFinish>, | |
| 221 | - | ) -> Response { | |
| 222 | - | let Some(user) = user else { | |
| 223 | - | return (StatusCode::UNAUTHORIZED, "sign in first").into_response(); | |
| 224 | - | }; | |
| 225 | - | if let Err(resp) = check_csrf(&csrf, &headers) { | |
| 226 | - | return resp; | |
| 227 | - | } | |
| 228 | - | let Some(Ceremony::Register { state, user_id }) = app.ceremonies.take(&body.ceremony) else { | |
| 229 | - | return bad_request("that registration expired — try again"); | |
| 230 | - | }; | |
| 231 | - | if user_id != user.id { | |
| 232 | - | return bad_request("that registration belongs to another session"); | |
| 233 | - | } | |
| 234 | - | let rp = match passkeys::rp_id(&app.config.http.base_url) { | |
| 235 | - | Ok(rp) => rp, | |
| 236 | - | Err(e) => return server_error(e), | |
| 237 | - | }; | |
| 238 | - | // The *relaxed* deserializer on purpose: the strict one additionally | |
| 239 | - | // requires `authenticatorData`, `publicKey` and `publicKeyAlgorithm`, which | |
| 240 | - | // only browsers implementing the newer WebAuthn-JSON serialization emit. | |
| 241 | - | // Nothing security-relevant rides on them — they are conveniences derived | |
| 242 | - | // from the attestation object, which is verified either way. | |
| 243 | - | let registration = match serde_json::from_value::<RegistrationRelaxed>(body.credential) { | |
| 244 | - | Ok(reg) => reg.0, | |
| 245 | - | Err(e) => return bad_request(format!("malformed credential: {e}")), | |
| 246 | - | }; | |
| 247 | - | ||
| 248 | - | let origin = passkeys::origin(&app.config.http.base_url); | |
| 249 | - | let options = RegistrationVerificationOptions::<&str, &str> { | |
| 250 | - | allowed_origins: &[origin.as_str()], | |
| 251 | - | ..Default::default() | |
| 252 | - | }; | |
| 253 | - | let credential = match state.verify(&rp, ®istration, &options) { | |
| 254 | - | Ok(credential) => credential, | |
| 255 | - | Err(e) => { | |
| 256 | - | tracing::warn!("passkey registration rejected: {e}"); | |
| 257 | - | return bad_request(format!("passkey rejected: {e}")); | |
| 258 | - | } | |
| 259 | - | }; | |
| 260 | - | ||
| 261 | - | let (id, transports, _handle, static_state, dynamic_state, _metadata) = credential.into_parts(); | |
| 262 | - | let credential_id = b64url().encode(id.as_ref()); | |
| 263 | - | let static_encoded = match static_state.encode() { | |
| 264 | - | Ok(bytes) => base64::engine::general_purpose::STANDARD.encode(bytes), | |
| 265 | - | Err(_) => return server_error("encoding credential public key"), | |
| 266 | - | }; | |
| 267 | - | let dynamic_encoded = match dynamic_state.encode() { | |
| 268 | - | Ok(bytes) => base64::engine::general_purpose::STANDARD.encode(bytes), | |
| 269 | - | Err(_) => return server_error("encoding credential state"), | |
| 270 | - | }; | |
| 271 | - | let transports = encode_transports(transports); | |
| 272 | - | ||
| 273 | - | match passkeys::add( | |
| 274 | - | &app.db, | |
| 275 | - | user.id, | |
| 276 | - | &body.name, | |
| 277 | - | &credential_id, | |
| 278 | - | &body.handle, | |
| 279 | - | &static_encoded, | |
| 280 | - | &dynamic_encoded, | |
| 281 | - | transports, | |
| 282 | - | ) | |
| 283 | - | .await | |
| 284 | - | { | |
| 285 | - | Ok(_) => { | |
| 286 | - | tracing::info!("passkey registered for {}", user.username); | |
| 287 | - | StatusCode::NO_CONTENT.into_response() | |
| 288 | - | } | |
| 289 | - | Err(anvil_core::Error::AlreadyExists(_)) => { | |
| 290 | - | bad_request("that passkey is already registered") | |
| 291 | - | } | |
| 292 | - | Err(e) => server_error(e), | |
| 293 | - | } | |
| 294 | - | } | |
| 295 | - | ||
| 296 | - | /// `POST /-/settings/passkeys/{id}/delete` — remove one of your passkeys. | |
| 297 | - | async fn delete_passkey( | |
| 298 | - | State(app): State<App>, | |
| 299 | - | CurrentUser(user): CurrentUser, | |
| 300 | - | csrf: Csrf, | |
| 301 | - | Path(id): Path<i64>, | |
| 302 | - | axum::Form(form): axum::Form<crate::auth::CsrfForm>, | |
| 303 | - | ) -> Response { | |
| 304 | - | let Some(user) = user else { | |
| 305 | - | return (StatusCode::UNAUTHORIZED, "sign in first").into_response(); | |
| 306 | - | }; | |
| 307 | - | if let Err(resp) = verify_csrf(&csrf, &form.csrf) { | |
| 308 | - | return resp; | |
| 309 | - | } | |
| 310 | - | if let Err(e) = passkeys::delete(&app.db, id, user.id).await { | |
| 311 | - | return server_error(e); | |
| 312 | - | } | |
| 313 | - | Redirect::to("/-/settings").into_response() | |
| 314 | - | } | |
| 315 | - | ||
| 316 | - | // --- sign-in --------------------------------------------------------------- | |
| 317 | - | ||
| 318 | - | /// `POST /-/login/passkey/begin` — issue an authentication challenge. | |
| 319 | - | /// | |
| 320 | - | /// Deliberately open to anyone: it reveals nothing (the challenge is random and | |
| 321 | - | /// no account is named), and requiring a session first would defeat the point. | |
| 322 | - | async fn login_begin(State(app): State<App>) -> Response { | |
| 323 | - | let rp = match passkeys::rp_id(&app.config.http.base_url) { | |
| 324 | - | Ok(rp) => rp, | |
| 325 | - | Err(e) => return server_error(e), | |
| 326 | - | }; | |
| 327 | - | let (server_state, client_state) = | |
| 328 | - | match DiscoverableCredentialRequestOptions::passkey(&rp).start_ceremony() { | |
| 329 | - | Ok(pair) => pair, | |
| 330 | - | Err(e) => return server_error(format!("building authentication options: {e}")), | |
| 331 | - | }; | |
| 332 | - | let options = match serde_json::to_value(&client_state) { | |
| 333 | - | Ok(value) => value, | |
| 334 | - | Err(e) => return server_error(e), | |
| 335 | - | }; | |
| 336 | - | let ceremony = app.ceremonies.insert(Ceremony::Authenticate { | |
| 337 | - | state: Box::new(server_state), | |
| 338 | - | }); | |
| 339 | - | Json(BeginResponse { ceremony, options }).into_response() | |
| 340 | - | } | |
| 341 | - | ||
| 342 | - | #[derive(Deserialize)] | |
| 343 | - | struct LoginFinish { | |
| 344 | - | ceremony: String, | |
| 345 | - | credential: serde_json::Value, | |
| 346 | - | } | |
| 347 | - | ||
| 348 | - | /// `POST /-/login/passkey/finish` — verify an assertion and start a session. | |
| 349 | - | async fn login_finish( | |
| 350 | - | State(app): State<App>, | |
| 351 | - | jar: axum_extra::extract::CookieJar, | |
| 352 | - | Json(body): Json<LoginFinish>, | |
| 353 | - | ) -> Response { | |
| 354 | - | let Some(Ceremony::Authenticate { state }) = app.ceremonies.take(&body.ceremony) else { | |
| 355 | - | return bad_request("that sign-in expired — try again"); | |
| 356 | - | }; | |
| 357 | - | let rp = match passkeys::rp_id(&app.config.http.base_url) { | |
| 358 | - | Ok(rp) => rp, | |
| 359 | - | Err(e) => return server_error(e), | |
| 360 | - | }; | |
| 361 | - | let authentication = | |
| 362 | - | match serde_json::from_value::<AuthenticationRelaxed<64, true>>(body.credential) { | |
| 363 | - | Ok(auth) => auth.0, | |
| 364 | - | Err(e) => return bad_request(format!("malformed assertion: {e}")), | |
| 365 | - | }; | |
| 366 | - | ||
| 367 | - | let credential_id = b64url().encode(authentication.raw_id().as_ref()); | |
| 368 | - | let stored = match passkeys::find_by_credential_id(&app.db, &credential_id).await { | |
| 369 | - | Ok(Some(stored)) => stored, | |
| 370 | - | Ok(None) => return unauthorized(), | |
| 371 | - | Err(e) => return server_error(e), | |
| 372 | - | }; | |
| 373 | - | let Some(handle) = decode_handle(&stored.user_handle) else { | |
| 374 | - | return server_error("stored passkey handle is malformed"); | |
| 375 | - | }; | |
| 376 | - | let (Some(static_state), Some(dynamic_state)) = ( | |
| 377 | - | decode_static_state(&stored.static_state), | |
| 378 | - | decode_dynamic_state(&stored.dynamic_state), | |
| 379 | - | ) else { | |
| 380 | - | return server_error("stored passkey state is malformed"); | |
| 381 | - | }; | |
| 382 | - | ||
| 383 | - | let raw_id = authentication.raw_id().as_ref().to_vec(); | |
| 384 | - | let credential_ref = match CredentialId::decode(raw_id.as_slice()) { | |
| 385 | - | Ok(id) => id, | |
| 386 | - | Err(_) => return unauthorized(), | |
| 387 | - | }; | |
| 388 | - | let mut credential = | |
| 389 | - | match AuthenticatedCredential::new(credential_ref, &handle, static_state, dynamic_state) { | |
| 390 | - | Ok(credential) => credential, | |
| 391 | - | Err(e) => return server_error(format!("rebuilding credential: {e}")), | |
| 392 | - | }; | |
| 393 | - | ||
| 394 | - | let origin = passkeys::origin(&app.config.http.base_url); | |
| 395 | - | let options = AuthenticationVerificationOptions::<&str, &str> { | |
| 396 | - | allowed_origins: &[origin.as_str()], | |
| 397 | - | ..Default::default() | |
| 398 | - | }; | |
| 399 | - | match state.verify(&rp, &authentication, &mut credential, &options) { | |
| 400 | - | Ok(_updated) => {} | |
| 401 | - | Err(e) => { | |
| 402 | - | tracing::warn!("passkey sign-in rejected: {e}"); | |
| 403 | - | return unauthorized(); | |
| 404 | - | } | |
| 405 | - | } | |
| 406 | - | ||
| 407 | - | let Ok(user) = users::find_by_id(&app.db, stored.user_id).await else { | |
| 408 | - | return server_error("looking up the passkey's account"); | |
| 409 | - | }; | |
| 410 | - | let Some(user) = user else { | |
| 411 | - | return unauthorized(); | |
| 412 | - | }; | |
| 413 | - | ||
| 414 | - | // Persist the counter/flags the authenticator just reported, so a cloned | |
| 415 | - | // credential replaying an older count is caught next time. | |
| 416 | - | let Ok(bytes) = credential.dynamic_state().encode(); | |
| 417 | - | let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); | |
| 418 | - | if let Err(e) = passkeys::record_use(&app.db, stored, &encoded).await { | |
| 419 | - | tracing::warn!("recording passkey use: {e}"); | |
| 420 | - | } | |
| 421 | - | ||
| 422 | - | let session = match sessions::create(&app.db, user.id).await { | |
| 423 | - | Ok(session) => session, | |
| 424 | - | Err(e) => return server_error(e), | |
| 425 | - | }; | |
| 426 | - | tracing::info!("passkey sign-in for {}", user.username); | |
| 427 | - | let jar = jar.add(crate::auth::session_cookie(&app, session.token)); | |
| 428 | - | (jar, Json(serde_json::json!({ "redirect": "/" }))).into_response() | |
| 429 | - | } | |
| 430 | - | ||
| 431 | - | // --- settings UI ----------------------------------------------------------- | |
| 432 | - | ||
| 433 | - | /// The passkeys section of account settings. | |
| 434 | - | pub fn settings_section(user: &User, keys: &[anvil_core::Passkey], csrf: &str) -> Markup { | |
| 435 | - | html! { | |
| 436 | - | h2 style="margin-top:28px" { "Passkeys" } | |
| 437 | - | p.muted style="font-size:13px" { | |
| 438 | - | "Sign in with Touch ID, Windows Hello, a phone, or a security key instead of " | |
| 439 | - | (user.username) "'s password. The key itself never leaves the device — anvil only " | |
| 440 | - | "stores its public half, and a passkey created here cannot be used on any other site." | |
| 441 | - | } | |
| 442 | - | @if keys.is_empty() { | |
| 443 | - | p.muted { "No passkeys yet." } | |
| 444 | - | } @else { | |
| 445 | - | div.box { | |
| 446 | - | @for key in keys { | |
| 447 | - | div.row { | |
| 448 | - | span { (key.name) } | |
| 449 | - | span.muted style="margin-left:auto;font-size:13px" { | |
| 450 | - | @if key.last_used_at == 0 { | |
| 451 | - | "never used" | |
| 452 | - | } @else { | |
| 453 | - | "last used " (fmt_relative(key.last_used_at)) | |
| 454 | - | } | |
| 455 | - | " · added " (fmt_relative(key.created_at)) | |
| 456 | - | } | |
| 457 | - | form method="post" style="margin-left:12px" | |
| 458 | - | action=(format!("/-/settings/passkeys/{}/delete", key.id)) { | |
| 459 | - | (csrf_input(csrf)) | |
| 460 | - | button.btn.btn-secondary type="submit" { "Remove" } | |
| 461 | - | } | |
| 462 | - | } | |
| 463 | - | } | |
| 464 | - | } | |
| 465 | - | } | |
| 466 | - | div #passkey-add.stack data-csrf=(csrf) style="margin-top:16px" { | |
| 467 | - | p { | |
| 468 | - | label { "Name this device" br; input #passkey-name type="text" placeholder="MacBook Touch ID" autocomplete="off"; } | |
| 469 | - | } | |
| 470 | - | p { | |
| 471 | - | button.btn #passkey-register type="button" { "Add passkey" } | |
| 472 | - | span #passkey-status.muted style="margin-left:10px;font-size:13px" {} | |
| 473 | - | } | |
| 474 | - | } | |
| 475 | - | script { (PreEscaped(REGISTER_JS)) } | |
| 476 | - | } | |
| 477 | - | } | |
| 478 | - | ||
| 479 | - | /// The "sign in with a passkey" control for the login page. | |
| 480 | - | pub fn login_button() -> Markup { | |
| 481 | - | html! { | |
| 482 | - | div #passkey-login style="margin-top:16px" { | |
| 483 | - | button.btn.btn-secondary #passkey-login-btn type="button" { "Sign in with a passkey" } | |
| 484 | - | span #passkey-login-status.muted style="margin-left:10px;font-size:13px" {} | |
| 485 | - | } | |
| 486 | - | script { (PreEscaped(LOGIN_JS)) } | |
| 487 | - | } | |
| 488 | - | } | |
| 489 | - | ||
| 490 | - | // --- helpers --------------------------------------------------------------- | |
| 491 | - | ||
| 492 | - | fn b64url() -> base64::engine::general_purpose::GeneralPurpose { | |
| 493 | - | base64::engine::general_purpose::URL_SAFE_NO_PAD | |
| 494 | - | } | |
| 495 | - | ||
| 496 | - | fn check_csrf(csrf: &Csrf, headers: &HeaderMap) -> Result<(), Response> { | |
| 497 | - | let submitted = headers | |
| 498 | - | .get("x-csrf-token") | |
| 499 | - | .and_then(|v| v.to_str().ok()) | |
| 500 | - | .unwrap_or_default(); | |
| 501 | - | verify_csrf(csrf, submitted) | |
| 502 | - | } | |
| 503 | - | ||
| 504 | - | fn decode_handle(encoded: &str) -> Option<UserHandle64> { | |
| 505 | - | let bytes = base64::engine::general_purpose::STANDARD | |
| 506 | - | .decode(encoded) | |
| 507 | - | .ok()?; | |
| 508 | - | let bytes: [u8; passkeys::USER_HANDLE_LEN] = bytes.try_into().ok()?; | |
| 509 | - | UserHandle64::decode(bytes).ok() | |
| 510 | - | } | |
| 511 | - | ||
| 512 | - | fn decode_static_state(encoded: &str) -> Option<StaticState<StoredKey>> { | |
| 513 | - | let bytes = base64::engine::general_purpose::STANDARD | |
| 514 | - | .decode(encoded) | |
| 515 | - | .ok()?; | |
| 516 | - | StaticState::decode(bytes.as_slice()).ok() | |
| 517 | - | } | |
| 518 | - | ||
| 519 | - | fn decode_dynamic_state(encoded: &str) -> Option<DynamicState> { | |
| 520 | - | let bytes = base64::engine::general_purpose::STANDARD | |
| 521 | - | .decode(encoded) | |
| 522 | - | .ok()?; | |
| 523 | - | let bytes: [u8; 7] = bytes.try_into().ok()?; | |
| 524 | - | DynamicState::decode(bytes).ok() | |
| 525 | - | } | |
| 526 | - | ||
| 527 | - | /// Transports are stored as the crate's own compact encoding, widened to the | |
| 528 | - | /// integer column SQLite gives us. | |
| 529 | - | fn encode_transports(transports: AuthTransports) -> i64 { | |
| 530 | - | transports.encode().map(i64::from).unwrap_or_default() | |
| 531 | - | } | |
| 532 | - | ||
| 533 | - | fn decode_transports(stored: i64) -> AuthTransports { | |
| 534 | - | // An unreadable value costs a transport *hint*, nothing more: the browser | |
| 535 | - | // falls back to asking about every transport it supports. | |
| 536 | - | u8::try_from(stored) | |
| 537 | - | .ok() | |
| 538 | - | .and_then(|byte| AuthTransports::decode(byte).ok()) | |
| 539 | - | .unwrap_or_else(|| { | |
| 540 | - | AuthTransports::decode(0).unwrap_or_else(|_| unreachable!("0 is a valid transport set")) | |
| 541 | - | }) | |
| 542 | - | } | |
| 543 | - | ||
| 544 | - | fn unauthorized() -> Response { | |
| 545 | - | // Deliberately uniform: never distinguish "no such credential" from "bad | |
| 546 | - | // signature", or the endpoint becomes a credential-enumeration oracle. | |
| 547 | - | ( | |
| 548 | - | StatusCode::UNAUTHORIZED, | |
| 549 | - | "that passkey is not registered here", | |
| 550 | - | ) | |
| 551 | - | .into_response() | |
| 552 | - | } | |
| 553 | - | ||
| 554 | - | fn server_error(e: impl std::fmt::Display) -> Response { | |
| 555 | - | tracing::error!("passkeys: {e}"); | |
| 556 | - | (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response() | |
| 557 | - | } | |
| 558 | - | ||
| 559 | - | fn bad_request(e: impl std::fmt::Display) -> Response { | |
| 560 | - | (StatusCode::BAD_REQUEST, e.to_string()).into_response() | |
| 561 | - | } | |
| 562 | - | ||
| 563 | - | /// Shared browser helpers: WebAuthn speaks ArrayBuffers, JSON speaks base64url. | |
| 564 | - | /// | |
| 565 | - | /// `PublicKeyCredential.parseCreationOptionsFromJSON`/`toJSON` would do this, | |
| 566 | - | /// but they are recent enough that a hand-rolled conversion is the difference | |
| 567 | - | /// between working everywhere and working on new Chrome. | |
| 568 | - | const WEBAUTHN_JS: &str = r#" | |
| 569 | - | globalThis.anvilWebAuthn = (function () { | |
| 570 | - | function decode(value) { | |
| 571 | - | var pad = value.replace(/-/g, '+').replace(/_/g, '/'); | |
| 572 | - | var bin = atob(pad + '='.repeat((4 - pad.length % 4) % 4)); | |
| 573 | - | var out = new Uint8Array(bin.length); | |
| 574 | - | for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); | |
| 575 | - | return out; | |
| 576 | - | } | |
| 577 | - | function encode(buffer) { | |
| 578 | - | var bytes = new Uint8Array(buffer), s = ''; | |
| 579 | - | for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); | |
| 580 | - | return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); | |
| 581 | - | } | |
| 582 | - | return { | |
| 583 | - | decode: decode, | |
| 584 | - | encode: encode, | |
| 585 | - | // The server sends the same JSON shape browsers standardized on; the | |
| 586 | - | // binary fields just have to become buffers again. | |
| 587 | - | toCreationOptions: function (options) { | |
| 588 | - | options.challenge = decode(options.challenge); | |
| 589 | - | options.user.id = decode(options.user.id); | |
| 590 | - | (options.excludeCredentials || []).forEach(function (c) { c.id = decode(c.id); }); | |
| 591 | - | return options; | |
| 592 | - | }, | |
| 593 | - | toRequestOptions: function (options) { | |
| 594 | - | options.challenge = decode(options.challenge); | |
| 595 | - | (options.allowCredentials || []).forEach(function (c) { c.id = decode(c.id); }); | |
| 596 | - | return options; | |
| 597 | - | }, | |
| 598 | - | registrationJson: function (credential) { | |
| 599 | - | return { | |
| 600 | - | id: credential.id, | |
| 601 | - | rawId: encode(credential.rawId), | |
| 602 | - | type: credential.type, | |
| 603 | - | clientExtensionResults: credential.getClientExtensionResults(), | |
| 604 | - | response: { | |
| 605 | - | clientDataJSON: encode(credential.response.clientDataJSON), | |
| 606 | - | attestationObject: encode(credential.response.attestationObject), | |
| 607 | - | transports: credential.response.getTransports ? credential.response.getTransports() : [], | |
| 608 | - | // Derived views of the attestation object. The server verifies the | |
| 609 | - | // object itself, so these are optional — sent when the browser can. | |
| 610 | - | authenticatorData: credential.response.getAuthenticatorData | |
| 611 | - | ? encode(credential.response.getAuthenticatorData()) : undefined, | |
| 612 | - | publicKey: credential.response.getPublicKey && credential.response.getPublicKey() | |
| 613 | - | ? encode(credential.response.getPublicKey()) : undefined, | |
| 614 | - | publicKeyAlgorithm: credential.response.getPublicKeyAlgorithm | |
| 615 | - | ? credential.response.getPublicKeyAlgorithm() : undefined, | |
| 616 | - | }, | |
| 617 | - | }; | |
| 618 | - | }, | |
| 619 | - | assertionJson: function (credential) { | |
| 620 | - | return { | |
| 621 | - | id: credential.id, | |
| 622 | - | rawId: encode(credential.rawId), | |
| 623 | - | type: credential.type, | |
| 624 | - | clientExtensionResults: credential.getClientExtensionResults(), | |
| 625 | - | response: { | |
| 626 | - | clientDataJSON: encode(credential.response.clientDataJSON), | |
| 627 | - | authenticatorData: encode(credential.response.authenticatorData), | |
| 628 | - | signature: encode(credential.response.signature), | |
| 629 | - | userHandle: credential.response.userHandle ? encode(credential.response.userHandle) : null, | |
| 630 | - | }, | |
| 631 | - | }; | |
| 632 | - | }, | |
| 633 | - | }; | |
| 634 | - | })(); | |
| 635 | - | "#; | |
| 636 | - | ||
| 637 | - | /// Registration, driven from account settings. | |
| 638 | - | const REGISTER_JS: &str = r#" | |
| 639 | - | (function () { | |
| 640 | - | var root = document.getElementById('passkey-add'); | |
| 641 | - | if (!root) return; | |
| 642 | - | var button = document.getElementById('passkey-register'); | |
| 643 | - | var statusEl = document.getElementById('passkey-status'); | |
| 644 | - | var nameEl = document.getElementById('passkey-name'); | |
| 645 | - | ||
| 646 | - | function fail(message) { | |
| 647 | - | statusEl.textContent = message; | |
| 648 | - | statusEl.style.color = 'var(--error)'; | |
| 649 | - | button.disabled = false; | |
| 650 | - | } | |
| 651 | - | ||
| 652 | - | if (!window.PublicKeyCredential) { | |
| 653 | - | button.disabled = true; | |
| 654 | - | statusEl.textContent = 'This browser does not support passkeys.'; | |
| 655 | - | return; | |
| 656 | - | } | |
| 657 | - | ||
| 658 | - | button.addEventListener('click', async function () { | |
| 659 | - | button.disabled = true; | |
| 660 | - | statusEl.style.color = ''; | |
| 661 | - | statusEl.textContent = 'Waiting for your authenticator…'; | |
| 662 | - | var headers = { 'Content-Type': 'application/json', 'X-CSRF-Token': root.dataset.csrf }; | |
| 663 | - | try { | |
| 664 | - | var res = await fetch('/-/settings/passkeys/begin', { method: 'POST', headers: headers }); | |
| 665 | - | if (!res.ok) return fail(await res.text()); | |
| 666 | - | var begin = await res.json(); | |
| 667 | - | var credential = await navigator.credentials.create({ | |
| 668 | - | publicKey: anvilWebAuthn.toCreationOptions(begin.options.publicKey || begin.options), | |
| 669 | - | }); | |
| 670 | - | if (!credential) return fail('No passkey was created.'); | |
| 671 | - | statusEl.textContent = 'Saving…'; | |
| 672 | - | var save = await fetch('/-/settings/passkeys/finish', { | |
| 673 | - | method: 'POST', | |
| 674 | - | headers: headers, | |
| 675 | - | body: JSON.stringify({ | |
| 676 | - | ceremony: begin.ceremony, | |
| 677 | - | handle: begin.handle, | |
| 678 | - | name: nameEl.value, | |
| 679 | - | credential: anvilWebAuthn.registrationJson(credential), | |
| 680 | - | }), | |
| 681 | - | }); | |
| 682 | - | if (!save.ok) return fail(await save.text()); | |
| 683 | - | location.reload(); | |
| 684 | - | } catch (e) { | |
| 685 | - | // NotAllowedError is the user cancelling or letting the prompt time out. | |
| 686 | - | fail(e.name === 'NotAllowedError' ? 'Cancelled.' : (e.message || String(e))); | |
| 687 | - | } | |
| 688 | - | }); | |
| 689 | - | })(); | |
| 690 | - | "#; | |
| 691 | - | ||
| 692 | - | /// Sign-in, driven from the login page. | |
| 693 | - | const LOGIN_JS: &str = r#" | |
| 694 | - | (function () { | |
| 695 | - | var button = document.getElementById('passkey-login-btn'); | |
| 696 | - | if (!button) return; | |
| 697 | - | var statusEl = document.getElementById('passkey-login-status'); | |
| 698 | - | ||
| 699 | - | function fail(message) { | |
| 700 | - | statusEl.textContent = message; | |
| 701 | - | statusEl.style.color = 'var(--error)'; | |
| 702 | - | button.disabled = false; | |
| 703 | - | } | |
| 704 | - | ||
| 705 | - | if (!window.PublicKeyCredential) { | |
| 706 | - | document.getElementById('passkey-login').style.display = 'none'; | |
| 707 | - | return; | |
| 708 | - | } | |
| 709 | - | ||
| 710 | - | button.addEventListener('click', async function () { | |
| 711 | - | button.disabled = true; | |
| 712 | - | statusEl.style.color = ''; | |
| 713 | - | statusEl.textContent = 'Waiting for your authenticator…'; | |
| 714 | - | try { | |
| 715 | - | var res = await fetch('/-/login/passkey/begin', { method: 'POST' }); | |
| 716 | - | if (!res.ok) return fail(await res.text()); | |
| 717 | - | var begin = await res.json(); | |
| 718 | - | var credential = await navigator.credentials.get({ | |
| 719 | - | publicKey: anvilWebAuthn.toRequestOptions(begin.options.publicKey || begin.options), | |
| 720 | - | }); | |
| 721 | - | if (!credential) return fail('No passkey was used.'); | |
| 722 | - | statusEl.textContent = 'Signing in…'; | |
| 723 | - | var done = await fetch('/-/login/passkey/finish', { | |
| 724 | - | method: 'POST', | |
| 725 | - | headers: { 'Content-Type': 'application/json' }, | |
| 726 | - | body: JSON.stringify({ | |
| 727 | - | ceremony: begin.ceremony, | |
| 728 | - | credential: anvilWebAuthn.assertionJson(credential), | |
| 729 | - | }), | |
| 730 | - | }); | |
| 731 | - | if (!done.ok) return fail(await done.text()); | |
| 732 | - | var result = await done.json(); | |
| 733 | - | location.href = result.redirect || '/'; | |
| 734 | - | } catch (e) { | |
| 735 | - | fail(e.name === 'NotAllowedError' ? 'Cancelled.' : (e.message || String(e))); | |
| 736 | - | } | |
| 737 | - | }); | |
| 738 | - | })(); | |
| 739 | - | "#; | |
| 740 | - | ||
| 741 | - | /// Emitted once per page that uses either ceremony. | |
| 742 | - | pub fn shared_script() -> Markup { | |
| 743 | - | html! { script { (PreEscaped(WEBAUTHN_JS)) } } | |
| 744 | - | } |
modifiedcrates/anvil-web/src/ui.rs+4 −33
| ⋯ 678 unchanged lines | |||
| 679 | 679 | Err(e) => return server_error(e), | |
| 680 | 680 | }; | |
| 681 | 681 | let tokens = api_tokens::list(&app.db, user.id).await.unwrap_or_default(); | |
| 682 | - | let passkeys = anvil_core::passkeys::list(&app.db, user.id) | |
| 683 | - | .await | |
| 684 | - | .unwrap_or_default(); | |
| 685 | - | account_page(&user, &keys, &tokens, &passkeys, None, None, &csrf.0).into_response() | |
| 682 | + | account_page(&user, &keys, &tokens, None, None, &csrf.0).into_response() | |
| 686 | 683 | } | |
| 687 | 684 | ||
| 688 | 685 | /// `POST /settings/keys` — register an SSH public key for the current user. | |
| ⋯ 24 unchanged lines | |||
| 713 | 710 | .await | |
| 714 | 711 | .unwrap_or_default(); | |
| 715 | 712 | let tokens = api_tokens::list(&app.db, user.id).await.unwrap_or_default(); | |
| 716 | - | let passkeys = anvil_core::passkeys::list(&app.db, user.id) | |
| 717 | - | .await | |
| 718 | - | .unwrap_or_default(); | |
| 719 | 713 | ( | |
| 720 | 714 | StatusCode::BAD_REQUEST, | |
| 721 | - | account_page( | |
| 722 | - | &user, | |
| 723 | - | &keys, | |
| 724 | - | &tokens, | |
| 725 | - | &passkeys, | |
| 726 | - | None, | |
| 727 | - | Some(&e.to_string()), | |
| 728 | - | &csrf.0, | |
| 729 | - | ), | |
| 715 | + | account_page(&user, &keys, &tokens, None, Some(&e.to_string()), &csrf.0), | |
| 730 | 716 | ) | |
| 731 | 717 | .into_response() | |
| 732 | 718 | } | |
| ⋯ 34 unchanged lines | |||
| 767 | 753 | .await | |
| 768 | 754 | .unwrap_or_default(); | |
| 769 | 755 | let tokens = api_tokens::list(&app.db, user.id).await.unwrap_or_default(); | |
| 770 | - | let passkeys = anvil_core::passkeys::list(&app.db, user.id) | |
| 771 | - | .await | |
| 772 | - | .unwrap_or_default(); | |
| 773 | - | account_page( | |
| 774 | - | &user, | |
| 775 | - | &keys, | |
| 776 | - | &tokens, | |
| 777 | - | &passkeys, | |
| 778 | - | Some(&plaintext), | |
| 779 | - | None, | |
| 780 | - | &csrf.0, | |
| 781 | - | ) | |
| 782 | - | .into_response() | |
| 756 | + | account_page(&user, &keys, &tokens, Some(&plaintext), None, &csrf.0).into_response() | |
| 783 | 757 | } | |
| 784 | 758 | ||
| 785 | 759 | /// `POST /settings/tokens/{id}/delete` — revoke one of the current user's | |
| ⋯ 40 unchanged lines | |||
| 826 | 800 | Redirect::to("/-/settings").into_response() | |
| 827 | 801 | } | |
| 828 | 802 | ||
| 829 | - | #[allow(clippy::too_many_arguments)] | |
| 830 | 803 | fn account_page( | |
| 831 | 804 | user: &User, | |
| 832 | 805 | keys: &[SshKey], | |
| 833 | 806 | tokens: &[ApiToken], | |
| 834 | - | passkeys: &[anvil_core::Passkey], | |
| 835 | 807 | new_token: Option<&str>, | |
| 836 | 808 | error: Option<&str>, | |
| 837 | 809 | csrf: &str, | |
| ⋯ 6 unchanged lines | |||
| 844 | 816 | p.muted { | |
| 845 | 817 | "Signed in as " strong { (user.username) } | |
| 846 | 818 | @if !user.email.is_empty() { " · " (user.email) } | |
| 819 | + | @if !user.sso_sub.is_empty() { " · " span.pill { "single sign-on" } } | |
| 847 | 820 | } | |
| 848 | 821 | ||
| 849 | 822 | h2 { "SSH keys" } | |
| ⋯ 57 unchanged lines | |||
| 907 | 880 | p { label { "Name" br; input type="text" name="name" placeholder="claude"; } } | |
| 908 | 881 | p { button.btn type="submit" { "Create token" } } | |
| 909 | 882 | } | |
| 910 | - | (crate::passkeys::shared_script()) | |
| 911 | - | (crate::passkeys::settings_section(user, passkeys, csrf)) | |
| 912 | 883 | }, | |
| 913 | 884 | ) | |
| 914 | 885 | } | |
| ⋯ 1946 unchanged lines | |||
addedcrates/anvil-web/tests/oidc_flow.rs+630 −0
| 1 | + | //! The single sign-on hand-off, end to end, against a stand-in provider. | |
| 2 | + | //! | |
| 3 | + | //! No test can hold a passkey up to a real identity provider, so this file *is* | |
| 4 | + | //! the provider: a small axum server that publishes a discovery document and a | |
| 5 | + | //! JWK set, and mints id tokens with a real RS256 signature over the claims the | |
| 6 | + | //! test asks for. Everything on anvil's side of the wire is the real thing — | |
| 7 | + | //! the actual router, the actual handlers, the actual verification. | |
| 8 | + | //! | |
| 9 | + | //! That makes it a genuine test of the flow (start a login, come back with a | |
| 10 | + | //! code, get a session and an account), plus the failures that matter: a forged | |
| 11 | + | //! signature, a swapped state, a replayed nonce, a token for someone else's | |
| 12 | + | //! client, and an unverified address that would otherwise adopt an account. | |
| 13 | + | ||
| 14 | + | use std::{ | |
| 15 | + | collections::HashMap, | |
| 16 | + | sync::{ | |
| 17 | + | Arc, | |
| 18 | + | Mutex, | |
| 19 | + | OnceLock, | |
| 20 | + | }, | |
| 21 | + | }; | |
| 22 | + | ||
| 23 | + | use anvil_core::{ | |
| 24 | + | App, | |
| 25 | + | Config, | |
| 26 | + | users, | |
| 27 | + | }; | |
| 28 | + | use axum::{ | |
| 29 | + | Json, | |
| 30 | + | Router, | |
| 31 | + | body::Body, | |
| 32 | + | extract::{ | |
| 33 | + | Form, | |
| 34 | + | State, | |
| 35 | + | }, | |
| 36 | + | http::{ | |
| 37 | + | Request, | |
| 38 | + | StatusCode, | |
| 39 | + | header, | |
| 40 | + | }, | |
| 41 | + | routing::{ | |
| 42 | + | get, | |
| 43 | + | post, | |
| 44 | + | }, | |
| 45 | + | }; | |
| 46 | + | use base64::{ | |
| 47 | + | Engine, | |
| 48 | + | engine::general_purpose::URL_SAFE_NO_PAD, | |
| 49 | + | }; | |
| 50 | + | use rsa::{ | |
| 51 | + | RsaPrivateKey, | |
| 52 | + | pkcs1v15::SigningKey, | |
| 53 | + | rand_core::OsRng, | |
| 54 | + | signature::{ | |
| 55 | + | SignatureEncoding, | |
| 56 | + | Signer, | |
| 57 | + | }, | |
| 58 | + | traits::PublicKeyParts, | |
| 59 | + | }; | |
| 60 | + | use serde_json::{ | |
| 61 | + | Value, | |
| 62 | + | json, | |
| 63 | + | }; | |
| 64 | + | use sha2::Sha256; | |
| 65 | + | use tower::ServiceExt; | |
| 66 | + | ||
| 67 | + | /// The provider's signing key, generated once for the whole test binary rather | |
| 68 | + | /// than per test — 2048 bits costs a couple of seconds in a debug build, and | |
| 69 | + | /// every test here wants the same provider. Generated rather than checked in: | |
| 70 | + | /// a PEM private key in the repository is a thing to explain forever, and this | |
| 71 | + | /// one signs nothing outside this process. | |
| 72 | + | fn signing_key() -> &'static RsaPrivateKey { | |
| 73 | + | static KEY: OnceLock<RsaPrivateKey> = OnceLock::new(); | |
| 74 | + | KEY.get_or_init(|| RsaPrivateKey::new(&mut OsRng, 2048).expect("the system RNG yields a key")) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | const CLIENT_ID: &str = "anvil-test"; | |
| 78 | + | const CLIENT_SECRET: &str = "s3cret"; | |
| 79 | + | const ANVIL_URL: &str = "https://anvil.localhost"; | |
| 80 | + | ||
| 81 | + | // --- the stand-in provider -------------------------------------------------- | |
| 82 | + | ||
| 83 | + | /// What the provider will hand back for one authorization code. | |
| 84 | + | #[derive(Clone)] | |
| 85 | + | struct Grant { | |
| 86 | + | claims: Value, | |
| 87 | + | /// Return this token verbatim instead of signing `claims` — how the test | |
| 88 | + | /// serves something the provider never would. | |
| 89 | + | raw: Option<String>, | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | struct Idp { | |
| 93 | + | issuer: String, | |
| 94 | + | key: RsaPrivateKey, | |
| 95 | + | grants: Mutex<HashMap<String, Grant>>, | |
| 96 | + | /// Every form the token endpoint received, for asserting on PKCE. | |
| 97 | + | token_requests: Mutex<Vec<HashMap<String, String>>>, | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | impl Idp { | |
| 101 | + | /// Start the provider on a loopback port and return it with its issuer URL. | |
| 102 | + | async fn start() -> Arc<Self> { | |
| 103 | + | let key = signing_key().clone(); | |
| 104 | + | ||
| 105 | + | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); | |
| 106 | + | let port = listener.local_addr().unwrap().port(); | |
| 107 | + | let idp = Arc::new(Self { | |
| 108 | + | issuer: format!("http://127.0.0.1:{port}"), | |
| 109 | + | key, | |
| 110 | + | grants: Mutex::new(HashMap::new()), | |
| 111 | + | token_requests: Mutex::new(Vec::new()), | |
| 112 | + | }); | |
| 113 | + | ||
| 114 | + | let router = Router::new() | |
| 115 | + | .route( | |
| 116 | + | "/.well-known/openid-configuration", | |
| 117 | + | get(|State(idp): State<Arc<Idp>>| async move { | |
| 118 | + | Json(json!({ | |
| 119 | + | "issuer": idp.issuer, | |
| 120 | + | "authorization_endpoint": format!("{}/authorize", idp.issuer), | |
| 121 | + | "token_endpoint": format!("{}/token", idp.issuer), | |
| 122 | + | "jwks_uri": format!("{}/.well-known/jwks.json", idp.issuer), | |
| 123 | + | "end_session_endpoint": format!("{}/logout", idp.issuer), | |
| 124 | + | })) | |
| 125 | + | }), | |
| 126 | + | ) | |
| 127 | + | .route( | |
| 128 | + | "/.well-known/jwks.json", | |
| 129 | + | get(|State(idp): State<Arc<Idp>>| async move { Json(idp.jwks()) }), | |
| 130 | + | ) | |
| 131 | + | .route("/token", post(token)) | |
| 132 | + | .with_state(idp.clone()); | |
| 133 | + | ||
| 134 | + | tokio::spawn(async move { | |
| 135 | + | let _ = axum::serve(listener, router).await; | |
| 136 | + | }); | |
| 137 | + | idp | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | fn jwks(&self) -> Value { | |
| 141 | + | let n = URL_SAFE_NO_PAD.encode(self.key.n().to_bytes_be()); | |
| 142 | + | let e = URL_SAFE_NO_PAD.encode(self.key.e().to_bytes_be()); | |
| 143 | + | json!({"keys": [{"kty": "RSA", "alg": "RS256", "use": "sig", "kid": "test-1", "n": n, "e": e}]}) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | /// Register `code` as redeemable for an id token carrying `claims`. | |
| 147 | + | fn grant(&self, code: &str, claims: Value) { | |
| 148 | + | self.grants | |
| 149 | + | .lock() | |
| 150 | + | .unwrap() | |
| 151 | + | .insert(code.to_string(), Grant { claims, raw: None }); | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | /// Register `code` as redeemable for exactly this token, whatever it is. | |
| 155 | + | fn grant_raw(&self, code: &str, token: String) { | |
| 156 | + | self.grants.lock().unwrap().insert( | |
| 157 | + | code.to_string(), | |
| 158 | + | Grant { | |
| 159 | + | claims: Value::Null, | |
| 160 | + | raw: Some(token), | |
| 161 | + | }, | |
| 162 | + | ); | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | /// The claims a happy-path login produces, before the test edits them. | |
| 166 | + | fn claims(&self, nonce: &str) -> Value { | |
| 167 | + | json!({ | |
| 168 | + | "iss": self.issuer, | |
| 169 | + | "aud": CLIENT_ID, | |
| 170 | + | "sub": "sso-user-1", | |
| 171 | + | "exp": now() + 300, | |
| 172 | + | "iat": now(), | |
| 173 | + | "nonce": nonce, | |
| 174 | + | "email": "collin@example.com", | |
| 175 | + | "email_verified": true, | |
| 176 | + | "name": "Collin", | |
| 177 | + | "preferred_username": "collin", | |
| 178 | + | "role": "admin", | |
| 179 | + | }) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | /// Sign `claims` into a compact RS256 JWS. | |
| 183 | + | fn id_token(&self, claims: &Value) -> String { | |
| 184 | + | let header = json!({"alg": "RS256", "typ": "JWT", "kid": "test-1"}); | |
| 185 | + | let signing_input = format!( | |
| 186 | + | "{}.{}", | |
| 187 | + | URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()), | |
| 188 | + | URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap()) | |
| 189 | + | ); | |
| 190 | + | let signature = SigningKey::<Sha256>::new(self.key.clone()).sign(signing_input.as_bytes()); | |
| 191 | + | format!( | |
| 192 | + | "{signing_input}.{}", | |
| 193 | + | URL_SAFE_NO_PAD.encode(signature.to_bytes()) | |
| 194 | + | ) | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | /// `POST /token` — the provider's code exchange. | |
| 199 | + | async fn token( | |
| 200 | + | State(idp): State<Arc<Idp>>, | |
| 201 | + | Form(form): Form<HashMap<String, String>>, | |
| 202 | + | ) -> Result<Json<Value>, (StatusCode, Json<Value>)> { | |
| 203 | + | idp.token_requests.lock().unwrap().push(form.clone()); | |
| 204 | + | ||
| 205 | + | let deny = |msg: &str| { | |
| 206 | + | Err(( | |
| 207 | + | StatusCode::BAD_REQUEST, | |
| 208 | + | Json(json!({"error": "invalid_grant", "error_description": msg})), | |
| 209 | + | )) | |
| 210 | + | }; | |
| 211 | + | if form.get("client_id").map(String::as_str) != Some(CLIENT_ID) | |
| 212 | + | || form.get("client_secret").map(String::as_str) != Some(CLIENT_SECRET) | |
| 213 | + | { | |
| 214 | + | return deny("bad client credentials"); | |
| 215 | + | } | |
| 216 | + | if form.get("code_verifier").is_none_or(String::is_empty) { | |
| 217 | + | return deny("no PKCE verifier"); | |
| 218 | + | } | |
| 219 | + | let Some(grant) = form | |
| 220 | + | .get("code") | |
| 221 | + | .and_then(|c| idp.grants.lock().unwrap().get(c).cloned()) | |
| 222 | + | else { | |
| 223 | + | return deny("unknown code"); | |
| 224 | + | }; | |
| 225 | + | let id_token = grant.raw.unwrap_or_else(|| idp.id_token(&grant.claims)); | |
| 226 | + | Ok(Json(json!({ | |
| 227 | + | "access_token": "at", | |
| 228 | + | "token_type": "Bearer", | |
| 229 | + | "id_token": id_token, | |
| 230 | + | }))) | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | fn now() -> i64 { | |
| 234 | + | std::time::SystemTime::now() | |
| 235 | + | .duration_since(std::time::UNIX_EPOCH) | |
| 236 | + | .unwrap() | |
| 237 | + | .as_secs() as i64 | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // --- anvil's side ----------------------------------------------------------- | |
| 241 | + | ||
| 242 | + | struct Harness { | |
| 243 | + | app: App, | |
| 244 | + | router: Router, | |
| 245 | + | _dir: tempfile::TempDir, | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | /// An anvil pointed at `issuer`, or at nothing when it is empty. | |
| 249 | + | async fn harness(issuer: &str) -> Harness { | |
| 250 | + | let dir = tempfile::tempdir().unwrap(); | |
| 251 | + | let config = Config { | |
| 252 | + | data_dir: dir.path().to_path_buf(), | |
| 253 | + | http: anvil_core::config::HttpConfig { | |
| 254 | + | base_url: ANVIL_URL.to_string(), | |
| 255 | + | ..Default::default() | |
| 256 | + | }, | |
| 257 | + | oidc: anvil_core::config::OidcConfig { | |
| 258 | + | issuer: issuer.to_string(), | |
| 259 | + | client_id: CLIENT_ID.to_string(), | |
| 260 | + | client_secret: CLIENT_SECRET.to_string(), | |
| 261 | + | ..Default::default() | |
| 262 | + | }, | |
| 263 | + | ..Default::default() | |
| 264 | + | }; | |
| 265 | + | let app = App::bootstrap(config).await.unwrap(); | |
| 266 | + | Harness { | |
| 267 | + | router: anvil_web::router(app.clone()), | |
| 268 | + | app, | |
| 269 | + | _dir: dir, | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | impl Harness { | |
| 274 | + | async fn get(&self, path: &str, cookie: Option<&str>) -> (StatusCode, HashMap<String, String>) { | |
| 275 | + | let mut req = Request::get(path); | |
| 276 | + | if let Some(cookie) = cookie { | |
| 277 | + | req = req.header(header::COOKIE, cookie); | |
| 278 | + | } | |
| 279 | + | let response = self | |
| 280 | + | .router | |
| 281 | + | .clone() | |
| 282 | + | .oneshot(req.body(Body::empty()).unwrap()) | |
| 283 | + | .await | |
| 284 | + | .unwrap(); | |
| 285 | + | let status = response.status(); | |
| 286 | + | let mut headers = HashMap::new(); | |
| 287 | + | if let Some(location) = response.headers().get(header::LOCATION) { | |
| 288 | + | headers.insert("location".into(), location.to_str().unwrap().to_string()); | |
| 289 | + | } | |
| 290 | + | // Only ever one cookie per response here, but keep them all by name. | |
| 291 | + | for value in response.headers().get_all(header::SET_COOKIE) { | |
| 292 | + | let raw = value.to_str().unwrap(); | |
| 293 | + | let (name, _) = raw.split_once('=').unwrap(); | |
| 294 | + | headers.insert(format!("cookie:{name}"), raw.to_string()); | |
| 295 | + | } | |
| 296 | + | (status, headers) | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | /// Start a login and pull out what the provider would have been sent, plus the | |
| 301 | + | /// cookie the callback must present. | |
| 302 | + | struct Started { | |
| 303 | + | state: String, | |
| 304 | + | nonce: String, | |
| 305 | + | challenge: String, | |
| 306 | + | cookie: String, | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | async fn start_login(h: &Harness, next: Option<&str>) -> Started { | |
| 310 | + | let path = match next { | |
| 311 | + | Some(next) => format!("/-/oidc/login?next={next}"), | |
| 312 | + | None => "/-/oidc/login".to_string(), | |
| 313 | + | }; | |
| 314 | + | let (status, headers) = h.get(&path, None).await; | |
| 315 | + | assert_eq!(status, StatusCode::SEE_OTHER, "login redirects"); | |
| 316 | + | ||
| 317 | + | let location = headers.get("location").expect("redirects to the provider"); | |
| 318 | + | let url = reqwest::Url::parse(location).unwrap(); | |
| 319 | + | let param = |key: &str| { | |
| 320 | + | url.query_pairs() | |
| 321 | + | .find(|(k, _)| k == key) | |
| 322 | + | .map(|(_, v)| v.to_string()) | |
| 323 | + | .unwrap_or_default() | |
| 324 | + | }; | |
| 325 | + | assert_eq!(param("response_type"), "code"); | |
| 326 | + | assert_eq!(param("client_id"), CLIENT_ID); | |
| 327 | + | assert_eq!( | |
| 328 | + | param("redirect_uri"), | |
| 329 | + | format!("{ANVIL_URL}/-/oidc/callback"), | |
| 330 | + | "the redirect URI must match what is registered at the provider" | |
| 331 | + | ); | |
| 332 | + | assert_eq!(param("code_challenge_method"), "S256"); | |
| 333 | + | ||
| 334 | + | let cookie = headers | |
| 335 | + | .get("cookie:anvil_oidc") | |
| 336 | + | .expect("stashes the pending login") | |
| 337 | + | .split(';') | |
| 338 | + | .next() | |
| 339 | + | .unwrap() | |
| 340 | + | .to_string(); | |
| 341 | + | Started { | |
| 342 | + | state: param("state"), | |
| 343 | + | nonce: param("nonce"), | |
| 344 | + | challenge: param("code_challenge"), | |
| 345 | + | cookie, | |
| 346 | + | } | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | /// Come back from the provider with `code`, carrying the pending cookie. | |
| 350 | + | async fn callback( | |
| 351 | + | h: &Harness, | |
| 352 | + | started: &Started, | |
| 353 | + | code: &str, | |
| 354 | + | state: &str, | |
| 355 | + | ) -> (StatusCode, HashMap<String, String>) { | |
| 356 | + | h.get( | |
| 357 | + | &format!("/-/oidc/callback?code={code}&state={state}"), | |
| 358 | + | Some(&started.cookie), | |
| 359 | + | ) | |
| 360 | + | .await | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | // --- the tests -------------------------------------------------------------- | |
| 364 | + | ||
| 365 | + | /// The whole hand-off: a login that ends with a session cookie and an account | |
| 366 | + | /// that did not exist before. | |
| 367 | + | #[tokio::test] | |
| 368 | + | async fn a_first_sign_in_provisions_an_account_and_a_session() { | |
| 369 | + | let idp = Idp::start().await; | |
| 370 | + | let h = harness(&idp.issuer).await; | |
| 371 | + | ||
| 372 | + | let started = start_login(&h, Some("/collin/anvil")).await; | |
| 373 | + | idp.grant("code-1", idp.claims(&started.nonce)); | |
| 374 | + | let (status, headers) = callback(&h, &started, "code-1", &started.state).await; | |
| 375 | + | ||
| 376 | + | assert_eq!(status, StatusCode::SEE_OTHER); | |
| 377 | + | assert_eq!( | |
| 378 | + | headers.get("location").map(String::as_str), | |
| 379 | + | Some("/collin/anvil"), | |
| 380 | + | "returns to where the login started" | |
| 381 | + | ); | |
| 382 | + | let session = headers | |
| 383 | + | .get("cookie:anvil_session") | |
| 384 | + | .expect("sets a session cookie"); | |
| 385 | + | assert!(session.contains("HttpOnly"), "{session}"); | |
| 386 | + | ||
| 387 | + | // PKCE: the verifier the token endpoint saw must hash to the challenge the | |
| 388 | + | // authorization request carried. | |
| 389 | + | let form = idp.token_requests.lock().unwrap().last().cloned().unwrap(); | |
| 390 | + | let verifier = form.get("code_verifier").unwrap(); | |
| 391 | + | let hashed = URL_SAFE_NO_PAD.encode(ring::digest::digest( | |
| 392 | + | &ring::digest::SHA256, | |
| 393 | + | verifier.as_bytes(), | |
| 394 | + | )); | |
| 395 | + | assert_eq!(hashed, started.challenge); | |
| 396 | + | assert_eq!(form.get("grant_type").unwrap(), "authorization_code"); | |
| 397 | + | ||
| 398 | + | let user = users::find_by_sso_sub(&h.app.db, "sso-user-1") | |
| 399 | + | .await | |
| 400 | + | .unwrap() | |
| 401 | + | .expect("the account was provisioned"); | |
| 402 | + | assert_eq!(user.username, "collin"); | |
| 403 | + | assert_eq!(user.email, "collin@example.com"); | |
| 404 | + | assert!(user.is_admin, "the role claim makes an admin"); | |
| 405 | + | assert!( | |
| 406 | + | user.password_hash.is_empty(), | |
| 407 | + | "no password is invented for an SSO account" | |
| 408 | + | ); | |
| 409 | + | ||
| 410 | + | // Signing in again reuses that account rather than making a second one. | |
| 411 | + | let started = start_login(&h, None).await; | |
| 412 | + | idp.grant("code-2", idp.claims(&started.nonce)); | |
| 413 | + | let (status, _) = callback(&h, &started, "code-2", &started.state).await; | |
| 414 | + | assert_eq!(status, StatusCode::SEE_OTHER); | |
| 415 | + | let again = users::find_by_sso_sub(&h.app.db, "sso-user-1") | |
| 416 | + | .await | |
| 417 | + | .unwrap() | |
| 418 | + | .unwrap(); | |
| 419 | + | assert_eq!(again.id, user.id); | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | /// An account that predates single sign-on is adopted on a *verified* address, | |
| 423 | + | /// and only then. | |
| 424 | + | #[tokio::test] | |
| 425 | + | async fn an_existing_account_is_adopted_only_on_a_verified_address() { | |
| 426 | + | let idp = Idp::start().await; | |
| 427 | + | let h = harness(&idp.issuer).await; | |
| 428 | + | let existing = users::create(&h.app.db, "collin", "collin@example.com", "pw", false) | |
| 429 | + | .await | |
| 430 | + | .unwrap(); | |
| 431 | + | ||
| 432 | + | // Unverified: refused, with the password left as the way in. | |
| 433 | + | let started = start_login(&h, None).await; | |
| 434 | + | let mut claims = idp.claims(&started.nonce); | |
| 435 | + | claims["email_verified"] = json!(false); | |
| 436 | + | idp.grant("code-1", claims); | |
| 437 | + | let (status, headers) = callback(&h, &started, "code-1", &started.state).await; | |
| 438 | + | assert_eq!(status, StatusCode::FORBIDDEN); | |
| 439 | + | assert!(!headers.contains_key("cookie:anvil_session")); | |
| 440 | + | let untouched = users::find_by_id(&h.app.db, existing.id) | |
| 441 | + | .await | |
| 442 | + | .unwrap() | |
| 443 | + | .unwrap(); | |
| 444 | + | assert!(untouched.sso_sub.is_empty(), "not linked"); | |
| 445 | + | ||
| 446 | + | // Verified: the same row is adopted, not duplicated. | |
| 447 | + | let started = start_login(&h, None).await; | |
| 448 | + | idp.grant("code-2", idp.claims(&started.nonce)); | |
| 449 | + | let (status, headers) = callback(&h, &started, "code-2", &started.state).await; | |
| 450 | + | assert_eq!(status, StatusCode::SEE_OTHER); | |
| 451 | + | assert!(headers.contains_key("cookie:anvil_session")); | |
| 452 | + | ||
| 453 | + | let linked = users::find_by_id(&h.app.db, existing.id) | |
| 454 | + | .await | |
| 455 | + | .unwrap() | |
| 456 | + | .unwrap(); | |
| 457 | + | assert_eq!(linked.sso_sub, "sso-user-1"); | |
| 458 | + | assert!(linked.is_admin, "the role claim is applied on adoption"); | |
| 459 | + | assert!( | |
| 460 | + | !linked.password_hash.is_empty(), | |
| 461 | + | "the existing password still works" | |
| 462 | + | ); | |
| 463 | + | } | |
| 464 | + | ||
| 465 | + | /// A `preferred_username` somebody already holds does not collide, and one that | |
| 466 | + | /// could not be a username at all is replaced rather than rejected. | |
| 467 | + | #[tokio::test] | |
| 468 | + | async fn a_taken_or_unusable_username_is_allocated_around() { | |
| 469 | + | let idp = Idp::start().await; | |
| 470 | + | let h = harness(&idp.issuer).await; | |
| 471 | + | users::create(&h.app.db, "collin", "someone@example.com", "pw", false) | |
| 472 | + | .await | |
| 473 | + | .unwrap(); | |
| 474 | + | ||
| 475 | + | let started = start_login(&h, None).await; | |
| 476 | + | let mut claims = idp.claims(&started.nonce); | |
| 477 | + | // A different person, whose preferred name is taken and whose address is | |
| 478 | + | // theirs alone. | |
| 479 | + | claims["sub"] = json!("sso-user-2"); | |
| 480 | + | claims["email"] = json!("other@example.com"); | |
| 481 | + | idp.grant("code-1", claims); | |
| 482 | + | let (status, _) = callback(&h, &started, "code-1", &started.state).await; | |
| 483 | + | assert_eq!(status, StatusCode::SEE_OTHER); | |
| 484 | + | let user = users::find_by_sso_sub(&h.app.db, "sso-user-2") | |
| 485 | + | .await | |
| 486 | + | .unwrap() | |
| 487 | + | .unwrap(); | |
| 488 | + | assert_eq!(user.username, "collin-2"); | |
| 489 | + | ||
| 490 | + | // A reserved name, and one full of characters a URL path cannot carry. | |
| 491 | + | let started = start_login(&h, None).await; | |
| 492 | + | let mut claims = idp.claims(&started.nonce); | |
| 493 | + | claims["sub"] = json!("sso-user-3"); | |
| 494 | + | claims["preferred_username"] = json!("settings"); | |
| 495 | + | claims["email"] = json!("third@example.com"); | |
| 496 | + | idp.grant("code-2", claims); | |
| 497 | + | let (status, _) = callback(&h, &started, "code-2", &started.state).await; | |
| 498 | + | assert_eq!(status, StatusCode::SEE_OTHER); | |
| 499 | + | let user = users::find_by_sso_sub(&h.app.db, "sso-user-3") | |
| 500 | + | .await | |
| 501 | + | .unwrap() | |
| 502 | + | .unwrap(); | |
| 503 | + | assert_eq!( | |
| 504 | + | user.username, "third", | |
| 505 | + | "a reserved name falls back to the address" | |
| 506 | + | ); | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | /// Every way a callback can be wrong must end without a session. | |
| 510 | + | #[tokio::test] | |
| 511 | + | async fn a_tampered_callback_never_yields_a_session() { | |
| 512 | + | let idp = Idp::start().await; | |
| 513 | + | let h = harness(&idp.issuer).await; | |
| 514 | + | ||
| 515 | + | // A state that is not the one we issued. | |
| 516 | + | let started = start_login(&h, None).await; | |
| 517 | + | idp.grant("code-1", idp.claims(&started.nonce)); | |
| 518 | + | let (status, headers) = callback(&h, &started, "code-1", "not-the-state").await; | |
| 519 | + | assert_eq!(status, StatusCode::BAD_REQUEST); | |
| 520 | + | assert!(!headers.contains_key("cookie:anvil_session")); | |
| 521 | + | ||
| 522 | + | // No pending cookie at all (a callback arriving out of nowhere). | |
| 523 | + | let (status, _) = h | |
| 524 | + | .get( | |
| 525 | + | &format!("/-/oidc/callback?code=code-1&state={}", started.state), | |
| 526 | + | None, | |
| 527 | + | ) | |
| 528 | + | .await; | |
| 529 | + | assert_eq!(status, StatusCode::BAD_REQUEST); | |
| 530 | + | ||
| 531 | + | // A token minted for a different client. | |
| 532 | + | let started = start_login(&h, None).await; | |
| 533 | + | let mut claims = idp.claims(&started.nonce); | |
| 534 | + | claims["aud"] = json!("some-other-app"); | |
| 535 | + | idp.grant("code-2", claims); | |
| 536 | + | let (status, headers) = callback(&h, &started, "code-2", &started.state).await; | |
| 537 | + | assert_eq!(status, StatusCode::BAD_GATEWAY); | |
| 538 | + | assert!(!headers.contains_key("cookie:anvil_session")); | |
| 539 | + | ||
| 540 | + | // A token carrying another login's nonce — the replay the nonce exists for. | |
| 541 | + | let started = start_login(&h, None).await; | |
| 542 | + | let mut claims = idp.claims(&started.nonce); | |
| 543 | + | claims["nonce"] = json!("a-nonce-from-some-other-login"); | |
| 544 | + | idp.grant("code-3", claims); | |
| 545 | + | let (status, headers) = callback(&h, &started, "code-3", &started.state).await; | |
| 546 | + | assert_eq!(status, StatusCode::BAD_GATEWAY); | |
| 547 | + | assert!(!headers.contains_key("cookie:anvil_session")); | |
| 548 | + | ||
| 549 | + | // An expired token. | |
| 550 | + | let started = start_login(&h, None).await; | |
| 551 | + | let mut claims = idp.claims(&started.nonce); | |
| 552 | + | claims["exp"] = json!(now() - 3600); | |
| 553 | + | idp.grant("code-4", claims); | |
| 554 | + | let (status, headers) = callback(&h, &started, "code-4", &started.state).await; | |
| 555 | + | assert_eq!(status, StatusCode::BAD_GATEWAY); | |
| 556 | + | assert!(!headers.contains_key("cookie:anvil_session")); | |
| 557 | + | ||
| 558 | + | // The provider refusing outright. | |
| 559 | + | let started = start_login(&h, None).await; | |
| 560 | + | let (status, _) = h | |
| 561 | + | .get( | |
| 562 | + | "/-/oidc/callback?error=access_denied&error_description=no+grant+for+this+app", | |
| 563 | + | Some(&started.cookie), | |
| 564 | + | ) | |
| 565 | + | .await; | |
| 566 | + | assert_eq!(status, StatusCode::FORBIDDEN); | |
| 567 | + | ||
| 568 | + | assert!( | |
| 569 | + | users::find_by_sso_sub(&h.app.db, "sso-user-1") | |
| 570 | + | .await | |
| 571 | + | .unwrap() | |
| 572 | + | .is_none(), | |
| 573 | + | "no account was provisioned by any of it" | |
| 574 | + | ); | |
| 575 | + | } | |
| 576 | + | ||
| 577 | + | /// A token whose signature does not verify is refused, however well-formed and | |
| 578 | + | /// truthful the claims inside it are. This is the check that makes every other | |
| 579 | + | /// claim worth reading. | |
| 580 | + | #[tokio::test] | |
| 581 | + | async fn a_bad_signature_is_refused() { | |
| 582 | + | let idp = Idp::start().await; | |
| 583 | + | let h = harness(&idp.issuer).await; | |
| 584 | + | ||
| 585 | + | // The real claims for this very login, with one bit flipped in the | |
| 586 | + | // signature — what an attacker who could mint claims but not sign them | |
| 587 | + | // would produce. | |
| 588 | + | let started = start_login(&h, None).await; | |
| 589 | + | let token = idp.id_token(&idp.claims(&started.nonce)); | |
| 590 | + | let (rest, signature) = token.rsplit_once('.').unwrap(); | |
| 591 | + | let mut bytes = URL_SAFE_NO_PAD.decode(signature).unwrap(); | |
| 592 | + | bytes[0] ^= 0xff; | |
| 593 | + | idp.grant_raw( | |
| 594 | + | "code-1", | |
| 595 | + | format!("{rest}.{}", URL_SAFE_NO_PAD.encode(&bytes)), | |
| 596 | + | ); | |
| 597 | + | ||
| 598 | + | let (status, headers) = callback(&h, &started, "code-1", &started.state).await; | |
| 599 | + | assert_eq!(status, StatusCode::BAD_GATEWAY); | |
| 600 | + | assert!(!headers.contains_key("cookie:anvil_session")); | |
| 601 | + | ||
| 602 | + | // And an unsigned token that asks to be trusted on the strength of its | |
| 603 | + | // `alg` header, which is the attack that check exists for. | |
| 604 | + | let started = start_login(&h, None).await; | |
| 605 | + | let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT"}"#); | |
| 606 | + | let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&idp.claims(&started.nonce)).unwrap()); | |
| 607 | + | idp.grant_raw("code-2", format!("{header}.{payload}.")); | |
| 608 | + | ||
| 609 | + | let (status, headers) = callback(&h, &started, "code-2", &started.state).await; | |
| 610 | + | assert_eq!(status, StatusCode::BAD_GATEWAY); | |
| 611 | + | assert!(!headers.contains_key("cookie:anvil_session")); | |
| 612 | + | ||
| 613 | + | assert!( | |
| 614 | + | users::find_by_sso_sub(&h.app.db, "sso-user-1") | |
| 615 | + | .await | |
| 616 | + | .unwrap() | |
| 617 | + | .is_none() | |
| 618 | + | ); | |
| 619 | + | } | |
| 620 | + | ||
| 621 | + | /// With no issuer configured, the routes are simply not a way in. | |
| 622 | + | #[tokio::test] | |
| 623 | + | async fn an_unconfigured_instance_offers_nothing() { | |
| 624 | + | let h = harness("").await; | |
| 625 | + | ||
| 626 | + | let (status, _) = h.get("/-/oidc/login", None).await; | |
| 627 | + | assert_eq!(status, StatusCode::NOT_FOUND); | |
| 628 | + | let (status, _) = h.get("/-/oidc/callback?code=x&state=y", None).await; | |
| 629 | + | assert_eq!(status, StatusCode::NOT_FOUND); | |
| 630 | + | } |
deletedcrates/anvil-web/tests/passkey_flow.rs+0 −546
| 1 | - | //! End-to-end passkey ceremonies, driven by a software authenticator. | |
| 2 | - | //! | |
| 3 | - | //! A real passkey needs hardware and a human fingerprint, which no test can | |
| 4 | - | //! supply — so this file *is* the authenticator: it holds a P-256 key, builds | |
| 5 | - | //! the `authenticatorData` and `clientDataJSON` the spec describes, and signs | |
| 6 | - | //! exactly what a security key would. Everything on the other side of the wire | |
| 7 | - | //! is the real thing: the actual router, the actual handlers, the actual | |
| 8 | - | //! verification. | |
| 9 | - | //! | |
| 10 | - | //! That makes it a genuine test of the flow — register a credential, then sign | |
| 11 | - | //! in with it and get a session — plus the failures that matter: a forged | |
| 12 | - | //! signature, a replayed challenge, someone else's credential. | |
| 13 | - | ||
| 14 | - | use anvil_core::{ | |
| 15 | - | App, | |
| 16 | - | Config, | |
| 17 | - | sessions, | |
| 18 | - | users, | |
| 19 | - | }; | |
| 20 | - | use axum::{ | |
| 21 | - | Router, | |
| 22 | - | body::Body, | |
| 23 | - | http::{ | |
| 24 | - | Request, | |
| 25 | - | StatusCode, | |
| 26 | - | header, | |
| 27 | - | }, | |
| 28 | - | }; | |
| 29 | - | use base64::Engine; | |
| 30 | - | use p256::ecdsa::{ | |
| 31 | - | Signature, | |
| 32 | - | SigningKey, | |
| 33 | - | signature::Signer, | |
| 34 | - | }; | |
| 35 | - | use sha2::{ | |
| 36 | - | Digest, | |
| 37 | - | Sha256, | |
| 38 | - | }; | |
| 39 | - | use tower::ServiceExt; | |
| 40 | - | ||
| 41 | - | const ORIGIN: &str = "https://anvil.localhost"; | |
| 42 | - | const RP_ID: &str = "anvil.localhost"; | |
| 43 | - | ||
| 44 | - | // --- the authenticator ----------------------------------------------------- | |
| 45 | - | ||
| 46 | - | /// A software stand-in for a security key: one credential, one P-256 key. | |
| 47 | - | struct Authenticator { | |
| 48 | - | key: SigningKey, | |
| 49 | - | credential_id: Vec<u8>, | |
| 50 | - | sign_count: u32, | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | impl Authenticator { | |
| 54 | - | fn new() -> Self { | |
| 55 | - | let mut seed = [0u8; 32]; | |
| 56 | - | getrandom(&mut seed); | |
| 57 | - | let mut credential_id = vec![0u8; 32]; | |
| 58 | - | getrandom(&mut credential_id); | |
| 59 | - | Self { | |
| 60 | - | key: SigningKey::from_bytes(&seed.into()).expect("random scalar is a valid key"), | |
| 61 | - | credential_id, | |
| 62 | - | sign_count: 0, | |
| 63 | - | } | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | /// `navigator.credentials.create()`: a `none`-attestation registration | |
| 67 | - | /// response carrying the new credential's public key. | |
| 68 | - | fn register(&self, challenge: &str) -> serde_json::Value { | |
| 69 | - | let client_data = client_data("webauthn.create", challenge); | |
| 70 | - | let auth_data = self.auth_data(true); | |
| 71 | - | let attestation = cbor_map(vec![ | |
| 72 | - | ( | |
| 73 | - | ciborium::Value::Text("fmt".into()), | |
| 74 | - | ciborium::Value::Text("none".into()), | |
| 75 | - | ), | |
| 76 | - | ( | |
| 77 | - | ciborium::Value::Text("attStmt".into()), | |
| 78 | - | ciborium::Value::Map(vec![]), | |
| 79 | - | ), | |
| 80 | - | ( | |
| 81 | - | ciborium::Value::Text("authData".into()), | |
| 82 | - | ciborium::Value::Bytes(auth_data), | |
| 83 | - | ), | |
| 84 | - | ]); | |
| 85 | - | serde_json::json!({ | |
| 86 | - | "id": b64url(&self.credential_id), | |
| 87 | - | "rawId": b64url(&self.credential_id), | |
| 88 | - | "type": "public-key", | |
| 89 | - | "clientExtensionResults": {}, | |
| 90 | - | "response": { | |
| 91 | - | "clientDataJSON": b64url(client_data.as_bytes()), | |
| 92 | - | "attestationObject": b64url(&attestation), | |
| 93 | - | "transports": ["internal"], | |
| 94 | - | }, | |
| 95 | - | }) | |
| 96 | - | } | |
| 97 | - | ||
| 98 | - | /// `navigator.credentials.get()`: an assertion over this challenge. | |
| 99 | - | fn assert(&mut self, challenge: &str, user_handle: &[u8]) -> serde_json::Value { | |
| 100 | - | self.sign_count += 1; | |
| 101 | - | let client_data = client_data("webauthn.get", challenge); | |
| 102 | - | let auth_data = self.auth_data(false); | |
| 103 | - | ||
| 104 | - | // What the authenticator actually signs: its own data, then the hash | |
| 105 | - | // of what the browser told it about this request. | |
| 106 | - | let mut signed = auth_data.clone(); | |
| 107 | - | signed.extend_from_slice(&Sha256::digest(client_data.as_bytes())); | |
| 108 | - | let signature: Signature = self.key.sign(&signed); | |
| 109 | - | ||
| 110 | - | serde_json::json!({ | |
| 111 | - | "id": b64url(&self.credential_id), | |
| 112 | - | "rawId": b64url(&self.credential_id), | |
| 113 | - | "type": "public-key", | |
| 114 | - | "clientExtensionResults": {}, | |
| 115 | - | "response": { | |
| 116 | - | "clientDataJSON": b64url(client_data.as_bytes()), | |
| 117 | - | "authenticatorData": b64url(&auth_data), | |
| 118 | - | "signature": b64url(signature.to_der().as_bytes()), | |
| 119 | - | "userHandle": b64url(user_handle), | |
| 120 | - | }, | |
| 121 | - | }) | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | /// `authenticatorData`: rpIdHash ‖ flags ‖ signCount, plus the attested | |
| 125 | - | /// credential (and the credProtect extension anvil asks for) at | |
| 126 | - | /// registration time. | |
| 127 | - | fn auth_data(&self, registering: bool) -> Vec<u8> { | |
| 128 | - | // UP (touched) | UV (verified) — anvil requires both. | |
| 129 | - | let mut flags = 0x01 | 0x04; | |
| 130 | - | if registering { | |
| 131 | - | flags |= 0x40; // AT: attested credential data present | |
| 132 | - | flags |= 0x80; // ED: extension data present | |
| 133 | - | } | |
| 134 | - | let mut data = Sha256::digest(RP_ID.as_bytes()).to_vec(); | |
| 135 | - | data.push(flags); | |
| 136 | - | data.extend_from_slice(&self.sign_count.to_be_bytes()); | |
| 137 | - | if registering { | |
| 138 | - | data.extend_from_slice(&[0u8; 16]); // AAGUID: zeroes, as privacy-preserving authenticators report | |
| 139 | - | data.extend_from_slice(&(self.credential_id.len() as u16).to_be_bytes()); | |
| 140 | - | data.extend_from_slice(&self.credential_id); | |
| 141 | - | data.extend_from_slice(&self.cose_key()); | |
| 142 | - | data.extend_from_slice(&cbor_map(vec![( | |
| 143 | - | ciborium::Value::Text("credProtect".into()), | |
| 144 | - | ciborium::Value::Integer(3.into()), // userVerificationRequired | |
| 145 | - | )])); | |
| 146 | - | } | |
| 147 | - | data | |
| 148 | - | } | |
| 149 | - | ||
| 150 | - | /// The public key as a COSE_Key: EC2 / P-256 / ES256. | |
| 151 | - | fn cose_key(&self) -> Vec<u8> { | |
| 152 | - | let point = self.key.verifying_key().to_encoded_point(false); | |
| 153 | - | cbor_map(vec![ | |
| 154 | - | ( | |
| 155 | - | ciborium::Value::Integer(1.into()), // kty | |
| 156 | - | ciborium::Value::Integer(2.into()), // EC2 | |
| 157 | - | ), | |
| 158 | - | ( | |
| 159 | - | ciborium::Value::Integer(3.into()), // alg | |
| 160 | - | ciborium::Value::Integer((-7).into()), // ES256 | |
| 161 | - | ), | |
| 162 | - | ( | |
| 163 | - | ciborium::Value::Integer((-1).into()), // crv | |
| 164 | - | ciborium::Value::Integer(1.into()), // P-256 | |
| 165 | - | ), | |
| 166 | - | ( | |
| 167 | - | ciborium::Value::Integer((-2).into()), | |
| 168 | - | ciborium::Value::Bytes(point.x().expect("uncompressed point has x").to_vec()), | |
| 169 | - | ), | |
| 170 | - | ( | |
| 171 | - | ciborium::Value::Integer((-3).into()), | |
| 172 | - | ciborium::Value::Bytes(point.y().expect("uncompressed point has y").to_vec()), | |
| 173 | - | ), | |
| 174 | - | ]) | |
| 175 | - | } | |
| 176 | - | } | |
| 177 | - | ||
| 178 | - | fn client_data(ceremony: &str, challenge: &str) -> String { | |
| 179 | - | serde_json::json!({ | |
| 180 | - | "type": ceremony, | |
| 181 | - | "challenge": challenge, | |
| 182 | - | "origin": ORIGIN, | |
| 183 | - | "crossOrigin": false, | |
| 184 | - | }) | |
| 185 | - | .to_string() | |
| 186 | - | } | |
| 187 | - | ||
| 188 | - | fn cbor_map(entries: Vec<(ciborium::Value, ciborium::Value)>) -> Vec<u8> { | |
| 189 | - | let mut out = Vec::new(); | |
| 190 | - | ciborium::into_writer(&ciborium::Value::Map(entries), &mut out).expect("CBOR encoding"); | |
| 191 | - | out | |
| 192 | - | } | |
| 193 | - | ||
| 194 | - | fn b64url(bytes: &[u8]) -> String { | |
| 195 | - | base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) | |
| 196 | - | } | |
| 197 | - | ||
| 198 | - | fn getrandom(buf: &mut [u8]) { | |
| 199 | - | use argon2::password_hash::rand_core::{ | |
| 200 | - | OsRng, | |
| 201 | - | RngCore, | |
| 202 | - | }; | |
| 203 | - | OsRng.fill_bytes(buf); | |
| 204 | - | } | |
| 205 | - | ||
| 206 | - | // --- harness --------------------------------------------------------------- | |
| 207 | - | ||
| 208 | - | struct Harness { | |
| 209 | - | router: Router, | |
| 210 | - | app: App, | |
| 211 | - | cookie: String, | |
| 212 | - | csrf: String, | |
| 213 | - | user_id: i64, | |
| 214 | - | _dir: tempfile::TempDir, | |
| 215 | - | } | |
| 216 | - | ||
| 217 | - | async fn harness() -> Harness { | |
| 218 | - | let dir = tempfile::tempdir().unwrap(); | |
| 219 | - | let mut config = Config::default(); | |
| 220 | - | config.data_dir = dir.path().to_path_buf(); | |
| 221 | - | config.http.base_url = ORIGIN.to_string(); | |
| 222 | - | let app = App::bootstrap(config).await.unwrap(); | |
| 223 | - | let user = users::create(&app.db, "collin", "", "password", true) | |
| 224 | - | .await | |
| 225 | - | .unwrap(); | |
| 226 | - | let session = sessions::create(&app.db, user.id).await.unwrap(); | |
| 227 | - | let csrf = app.csrf_token(&session.token); | |
| 228 | - | Harness { | |
| 229 | - | router: anvil_web::router(app.clone()), | |
| 230 | - | app, | |
| 231 | - | cookie: format!("anvil_session={}", session.token), | |
| 232 | - | csrf, | |
| 233 | - | user_id: user.id, | |
| 234 | - | _dir: dir, | |
| 235 | - | } | |
| 236 | - | } | |
| 237 | - | ||
| 238 | - | impl Harness { | |
| 239 | - | /// POST JSON as the signed-in user (cookie + CSRF header). | |
| 240 | - | async fn post_json(&self, path: &str, body: serde_json::Value) -> (StatusCode, String) { | |
| 241 | - | self.send( | |
| 242 | - | Request::post(path) | |
| 243 | - | .header(header::COOKIE, &self.cookie) | |
| 244 | - | .header("X-CSRF-Token", &self.csrf) | |
| 245 | - | .header(header::CONTENT_TYPE, "application/json") | |
| 246 | - | .body(Body::from(body.to_string())) | |
| 247 | - | .unwrap(), | |
| 248 | - | ) | |
| 249 | - | .await | |
| 250 | - | } | |
| 251 | - | ||
| 252 | - | /// POST JSON with no session at all, the way the login page does. | |
| 253 | - | async fn post_anonymous(&self, path: &str, body: serde_json::Value) -> (StatusCode, String) { | |
| 254 | - | self.send( | |
| 255 | - | Request::post(path) | |
| 256 | - | .header(header::CONTENT_TYPE, "application/json") | |
| 257 | - | .body(Body::from(body.to_string())) | |
| 258 | - | .unwrap(), | |
| 259 | - | ) | |
| 260 | - | .await | |
| 261 | - | } | |
| 262 | - | ||
| 263 | - | async fn send(&self, request: Request<Body>) -> (StatusCode, String) { | |
| 264 | - | let response = self.router.clone().oneshot(request).await.unwrap(); | |
| 265 | - | let status = response.status(); | |
| 266 | - | let body = axum::body::to_bytes(response.into_body(), 1 << 20) | |
| 267 | - | .await | |
| 268 | - | .unwrap(); | |
| 269 | - | (status, String::from_utf8_lossy(&body).into_owned()) | |
| 270 | - | } | |
| 271 | - | ||
| 272 | - | /// The login ceremony, returning the raw response so cookies can be read. | |
| 273 | - | async fn login(&self, body: serde_json::Value) -> axum::response::Response { | |
| 274 | - | self.router | |
| 275 | - | .clone() | |
| 276 | - | .oneshot( | |
| 277 | - | Request::post("/-/login/passkey/finish") | |
| 278 | - | .header(header::CONTENT_TYPE, "application/json") | |
| 279 | - | .body(Body::from(body.to_string())) | |
| 280 | - | .unwrap(), | |
| 281 | - | ) | |
| 282 | - | .await | |
| 283 | - | .unwrap() | |
| 284 | - | } | |
| 285 | - | ||
| 286 | - | /// Begin registration, returning (ceremony id, handle, challenge). | |
| 287 | - | async fn begin_registration(&self) -> (String, String, String) { | |
| 288 | - | let (status, body) = self | |
| 289 | - | .post_json("/-/settings/passkeys/begin", serde_json::json!({})) | |
| 290 | - | .await; | |
| 291 | - | assert_eq!(status, StatusCode::OK, "begin failed: {body}"); | |
| 292 | - | let json: serde_json::Value = serde_json::from_str(&body).unwrap(); | |
| 293 | - | ( | |
| 294 | - | json["ceremony"].as_str().unwrap().to_string(), | |
| 295 | - | json["handle"].as_str().unwrap().to_string(), | |
| 296 | - | json["options"]["challenge"].as_str().unwrap().to_string(), | |
| 297 | - | ) | |
| 298 | - | } | |
| 299 | - | ||
| 300 | - | /// Begin sign-in, returning (ceremony id, challenge). | |
| 301 | - | async fn begin_login(&self) -> (String, String) { | |
| 302 | - | let (status, body) = self | |
| 303 | - | .post_anonymous("/-/login/passkey/begin", serde_json::json!({})) | |
| 304 | - | .await; | |
| 305 | - | assert_eq!(status, StatusCode::OK, "begin failed: {body}"); | |
| 306 | - | let json: serde_json::Value = serde_json::from_str(&body).unwrap(); | |
| 307 | - | ( | |
| 308 | - | json["ceremony"].as_str().unwrap().to_string(), | |
| 309 | - | json["options"]["challenge"].as_str().unwrap().to_string(), | |
| 310 | - | ) | |
| 311 | - | } | |
| 312 | - | ||
| 313 | - | /// Register `authenticator` and return the account's WebAuthn handle. | |
| 314 | - | async fn register(&self, authenticator: &Authenticator, name: &str) -> Vec<u8> { | |
| 315 | - | let (ceremony, handle, challenge) = self.begin_registration().await; | |
| 316 | - | let (status, body) = self | |
| 317 | - | .post_json( | |
| 318 | - | "/-/settings/passkeys/finish", | |
| 319 | - | serde_json::json!({ | |
| 320 | - | "ceremony": ceremony, | |
| 321 | - | "handle": handle, | |
| 322 | - | "name": name, | |
| 323 | - | "credential": authenticator.register(&challenge), | |
| 324 | - | }), | |
| 325 | - | ) | |
| 326 | - | .await; | |
| 327 | - | assert_eq!( | |
| 328 | - | status, | |
| 329 | - | StatusCode::NO_CONTENT, | |
| 330 | - | "registration failed: {body}" | |
| 331 | - | ); | |
| 332 | - | base64::engine::general_purpose::STANDARD | |
| 333 | - | .decode(&handle) | |
| 334 | - | .unwrap() | |
| 335 | - | } | |
| 336 | - | } | |
| 337 | - | ||
| 338 | - | // --- the tests ------------------------------------------------------------- | |
| 339 | - | ||
| 340 | - | #[tokio::test] | |
| 341 | - | async fn a_registered_passkey_signs_in() { | |
| 342 | - | let harness = harness().await; | |
| 343 | - | let mut authenticator = Authenticator::new(); | |
| 344 | - | let handle = harness.register(&authenticator, "MacBook Touch ID").await; | |
| 345 | - | ||
| 346 | - | // The credential is stored against the account, with the label we gave it. | |
| 347 | - | let stored = anvil_core::passkeys::list(&harness.app.db, harness.user_id) | |
| 348 | - | .await | |
| 349 | - | .unwrap(); | |
| 350 | - | assert_eq!(stored.len(), 1); | |
| 351 | - | assert_eq!(stored[0].name, "MacBook Touch ID"); | |
| 352 | - | assert_eq!(stored[0].last_used_at, 0, "not used yet"); | |
| 353 | - | ||
| 354 | - | // Sign in with it: no username anywhere in this exchange. | |
| 355 | - | let (ceremony, challenge) = harness.begin_login().await; | |
| 356 | - | let response = harness | |
| 357 | - | .login(serde_json::json!({ | |
| 358 | - | "ceremony": ceremony, | |
| 359 | - | "credential": authenticator.assert(&challenge, &handle), | |
| 360 | - | })) | |
| 361 | - | .await; | |
| 362 | - | assert_eq!(response.status(), StatusCode::OK); | |
| 363 | - | ||
| 364 | - | // A session cookie comes back, and it belongs to the right account. | |
| 365 | - | let cookie = response | |
| 366 | - | .headers() | |
| 367 | - | .get(header::SET_COOKIE) | |
| 368 | - | .expect("session cookie") | |
| 369 | - | .to_str() | |
| 370 | - | .unwrap() | |
| 371 | - | .to_string(); | |
| 372 | - | let token = cookie | |
| 373 | - | .split(';') | |
| 374 | - | .next() | |
| 375 | - | .unwrap() | |
| 376 | - | .trim_start_matches("anvil_session=") | |
| 377 | - | .to_string(); | |
| 378 | - | let signed_in = sessions::lookup_user(&harness.app.db, &token) | |
| 379 | - | .await | |
| 380 | - | .unwrap() | |
| 381 | - | .expect("the cookie names a live session"); | |
| 382 | - | assert_eq!(signed_in.id, harness.user_id); | |
| 383 | - | ||
| 384 | - | // The sign-in is recorded against the credential. | |
| 385 | - | let stored = anvil_core::passkeys::list(&harness.app.db, harness.user_id) | |
| 386 | - | .await | |
| 387 | - | .unwrap(); | |
| 388 | - | assert!(stored[0].last_used_at > 0, "last use should be stamped"); | |
| 389 | - | } | |
| 390 | - | ||
| 391 | - | #[tokio::test] | |
| 392 | - | async fn a_second_passkey_shares_the_account_handle_and_is_excluded() { | |
| 393 | - | let harness = harness().await; | |
| 394 | - | let first = Authenticator::new(); | |
| 395 | - | let handle = harness.register(&first, "laptop").await; | |
| 396 | - | ||
| 397 | - | // Registering another authenticator reuses the same user handle, so the | |
| 398 | - | // account does not fork into two identities. | |
| 399 | - | let (_, second_handle, _) = harness.begin_registration().await; | |
| 400 | - | assert_eq!( | |
| 401 | - | base64::engine::general_purpose::STANDARD | |
| 402 | - | .decode(&second_handle) | |
| 403 | - | .unwrap(), | |
| 404 | - | handle | |
| 405 | - | ); | |
| 406 | - | ||
| 407 | - | // …and the browser is told to refuse the already-registered credential. | |
| 408 | - | let (status, body) = harness | |
| 409 | - | .post_json("/-/settings/passkeys/begin", serde_json::json!({})) | |
| 410 | - | .await; | |
| 411 | - | assert_eq!(status, StatusCode::OK); | |
| 412 | - | let json: serde_json::Value = serde_json::from_str(&body).unwrap(); | |
| 413 | - | let excluded = json["options"]["excludeCredentials"].as_array().unwrap(); | |
| 414 | - | assert_eq!(excluded.len(), 1); | |
| 415 | - | assert_eq!( | |
| 416 | - | excluded[0]["id"].as_str().unwrap(), | |
| 417 | - | b64url(&first.credential_id) | |
| 418 | - | ); | |
| 419 | - | } | |
| 420 | - | ||
| 421 | - | /// Registration options must stay compatible with authenticators that do not | |
| 422 | - | /// implement credProtect — phones over hybrid, plenty of security keys. With | |
| 423 | - | /// enforcement on, those fail the whole ceremony inside the browser, before | |
| 424 | - | /// anything reaches the server. User verification is still required, which is | |
| 425 | - | /// what actually gates a sign-in. | |
| 426 | - | #[tokio::test] | |
| 427 | - | async fn registration_asks_for_credprotect_without_enforcing_it() { | |
| 428 | - | let harness = harness().await; | |
| 429 | - | let (status, body) = harness | |
| 430 | - | .post_json("/-/settings/passkeys/begin", serde_json::json!({})) | |
| 431 | - | .await; | |
| 432 | - | assert_eq!(status, StatusCode::OK); | |
| 433 | - | let options: serde_json::Value = serde_json::from_str(&body).unwrap(); | |
| 434 | - | let options = &options["options"]; | |
| 435 | - | ||
| 436 | - | assert_eq!( | |
| 437 | - | options["extensions"]["enforceCredentialProtectionPolicy"], | |
| 438 | - | serde_json::json!(false), | |
| 439 | - | ); | |
| 440 | - | assert_eq!( | |
| 441 | - | options["authenticatorSelection"]["userVerification"], | |
| 442 | - | serde_json::json!("required"), | |
| 443 | - | ); | |
| 444 | - | assert_eq!( | |
| 445 | - | options["authenticatorSelection"]["residentKey"], | |
| 446 | - | serde_json::json!("required"), | |
| 447 | - | ); | |
| 448 | - | } | |
| 449 | - | ||
| 450 | - | #[tokio::test] | |
| 451 | - | async fn a_forged_signature_is_refused() { | |
| 452 | - | let harness = harness().await; | |
| 453 | - | let mut authenticator = Authenticator::new(); | |
| 454 | - | let handle = harness.register(&authenticator, "laptop").await; | |
| 455 | - | ||
| 456 | - | // Same credential id, a different key: what a stolen database plus a | |
| 457 | - | // home-made authenticator would produce. | |
| 458 | - | let (ceremony, challenge) = harness.begin_login().await; | |
| 459 | - | let mut impostor = Authenticator::new(); | |
| 460 | - | impostor.credential_id = authenticator.credential_id.clone(); | |
| 461 | - | let response = harness | |
| 462 | - | .login(serde_json::json!({ | |
| 463 | - | "ceremony": ceremony, | |
| 464 | - | "credential": impostor.assert(&challenge, &handle), | |
| 465 | - | })) | |
| 466 | - | .await; | |
| 467 | - | assert_eq!(response.status(), StatusCode::UNAUTHORIZED); | |
| 468 | - | assert!( | |
| 469 | - | response.headers().get(header::SET_COOKIE).is_none(), | |
| 470 | - | "a rejected sign-in must not set a session" | |
| 471 | - | ); | |
| 472 | - | ||
| 473 | - | // The real authenticator still works afterwards. | |
| 474 | - | let (ceremony, challenge) = harness.begin_login().await; | |
| 475 | - | let response = harness | |
| 476 | - | .login(serde_json::json!({ | |
| 477 | - | "ceremony": ceremony, | |
| 478 | - | "credential": authenticator.assert(&challenge, &handle), | |
| 479 | - | })) | |
| 480 | - | .await; | |
| 481 | - | assert_eq!(response.status(), StatusCode::OK); | |
| 482 | - | } | |
| 483 | - | ||
| 484 | - | #[tokio::test] | |
| 485 | - | async fn a_captured_assertion_cannot_be_replayed() { | |
| 486 | - | let harness = harness().await; | |
| 487 | - | let mut authenticator = Authenticator::new(); | |
| 488 | - | let handle = harness.register(&authenticator, "laptop").await; | |
| 489 | - | ||
| 490 | - | let (ceremony, challenge) = harness.begin_login().await; | |
| 491 | - | let assertion = authenticator.assert(&challenge, &handle); | |
| 492 | - | let first = harness | |
| 493 | - | .login(serde_json::json!({ "ceremony": ceremony.clone(), "credential": assertion.clone() })) | |
| 494 | - | .await; | |
| 495 | - | assert_eq!(first.status(), StatusCode::OK); | |
| 496 | - | ||
| 497 | - | // Replaying the identical exchange fails: the challenge is spent. | |
| 498 | - | let second = harness | |
| 499 | - | .login(serde_json::json!({ "ceremony": ceremony, "credential": assertion })) | |
| 500 | - | .await; | |
| 501 | - | assert_eq!(second.status(), StatusCode::BAD_REQUEST); | |
| 502 | - | } | |
| 503 | - | ||
| 504 | - | #[tokio::test] | |
| 505 | - | async fn an_unregistered_passkey_cannot_sign_in() { | |
| 506 | - | let harness = harness().await; | |
| 507 | - | let mut stranger = Authenticator::new(); | |
| 508 | - | let (ceremony, challenge) = harness.begin_login().await; | |
| 509 | - | let response = harness | |
| 510 | - | .login(serde_json::json!({ | |
| 511 | - | "ceremony": ceremony, | |
| 512 | - | "credential": stranger.assert(&challenge, &[7u8; 64]), | |
| 513 | - | })) | |
| 514 | - | .await; | |
| 515 | - | assert_eq!(response.status(), StatusCode::UNAUTHORIZED); | |
| 516 | - | } | |
| 517 | - | ||
| 518 | - | #[tokio::test] | |
| 519 | - | async fn removing_a_passkey_revokes_it() { | |
| 520 | - | let harness = harness().await; | |
| 521 | - | let mut authenticator = Authenticator::new(); | |
| 522 | - | let handle = harness.register(&authenticator, "laptop").await; | |
| 523 | - | let stored = anvil_core::passkeys::list(&harness.app.db, harness.user_id) | |
| 524 | - | .await | |
| 525 | - | .unwrap(); | |
| 526 | - | ||
| 527 | - | let (status, _) = harness | |
| 528 | - | .send( | |
| 529 | - | Request::post(format!("/-/settings/passkeys/{}/delete", stored[0].id)) | |
| 530 | - | .header(header::COOKIE, &harness.cookie) | |
| 531 | - | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") | |
| 532 | - | .body(Body::from(format!("csrf={}", harness.csrf))) | |
| 533 | - | .unwrap(), | |
| 534 | - | ) | |
| 535 | - | .await; | |
| 536 | - | assert_eq!(status, StatusCode::SEE_OTHER); | |
| 537 | - | ||
| 538 | - | let (ceremony, challenge) = harness.begin_login().await; | |
| 539 | - | let response = harness | |
| 540 | - | .login(serde_json::json!({ | |
| 541 | - | "ceremony": ceremony, | |
| 542 | - | "credential": authenticator.assert(&challenge, &handle), | |
| 543 | - | })) | |
| 544 | - | .await; | |
| 545 | - | assert_eq!(response.status(), StatusCode::UNAUTHORIZED); | |
| 546 | - | } |
modifieddeploy/anvil.dev.toml+9 −0
| ⋯ 12 unchanged lines | |||
| 13 | 13 | # differently named instance (ANVIL_DEV_NAME=anvil2) still gets correct links. | |
| 14 | 14 | base_url = "https://anvil.localhost" | |
| 15 | 15 | ||
| 16 | + | # Single sign-on against the local instance of login.richardscollin.com | |
| 17 | + | # (../login-richardscollin, `portless` → https://login.localhost). Register the | |
| 18 | + | # client there first and pass ANVIL_OIDC_CLIENT_SECRET to deploy/dev.sh — see | |
| 19 | + | # docs/oidc.md. Until then the sign-in button is there but the provider is not, | |
| 20 | + | # so use a password. | |
| 21 | + | [oidc] | |
| 22 | + | issuer = "https://login.localhost" | |
| 23 | + | client_id = "anvil" | |
| 24 | + | ||
| 16 | 25 | [ssh] | |
| 17 | 26 | enabled = true | |
| 18 | 27 | listen = "0.0.0.0:2222" | |
| ⋯ 18 unchanged lines | |||
modifieddeploy/dev.sh+25 −0
| ⋯ 64 unchanged lines | |||
| 65 | 65 | ||
| 66 | 66 | echo "==> (re)starting container $NAME" | |
| 67 | 67 | docker rm -f "$NAME" >/dev/null 2>&1 || true | |
| 68 | + | ||
| 69 | + | # Single sign-on against a provider on https://login.localhost (docs/oidc.md). | |
| 70 | + | # Two things the container does not get for free: the name resolves to its own | |
| 71 | + | # loopback rather than the host's portless proxy, and portless's CA — trusted | |
| 72 | + | # on the host by `portless trust` — is not in the image's root store. So point | |
| 73 | + | # the name at the host gateway, and hand the binary a bundle that is the host's | |
| 74 | + | # roots plus that CA (rustls reads SSL_CERT_FILE). | |
| 75 | + | SSO_ARGS=() | |
| 76 | + | if [[ -f "$HOME/.portless/ca.pem" ]]; then | |
| 77 | + | HOST_ROOTS="$(ls /etc/ssl/certs/ca-bundle.crt /etc/ssl/certs/ca-certificates.crt 2>/dev/null | head -n1)" | |
| 78 | + | cat "$HOST_ROOTS" "$HOME/.portless/ca.pem" >deploy/dev-ca.crt 2>/dev/null || true | |
| 79 | + | if [[ -s deploy/dev-ca.crt ]]; then | |
| 80 | + | SSO_ARGS+=( | |
| 81 | + | --add-host "login.localhost:host-gateway" | |
| 82 | + | -v "$PWD/deploy/dev-ca.crt:/etc/ssl/certs/anvil-dev-ca.crt:ro,z" | |
| 83 | + | -e "SSL_CERT_FILE=/etc/ssl/certs/anvil-dev-ca.crt" | |
| 84 | + | ) | |
| 85 | + | fi | |
| 86 | + | fi | |
| 87 | + | # The secret for the client registered at that provider, when there is one. | |
| 88 | + | if [[ -n "${ANVIL_OIDC_CLIENT_SECRET:-}" ]]; then | |
| 89 | + | SSO_ARGS+=(-e "ANVIL_OIDC_CLIENT_SECRET=${ANVIL_OIDC_CLIENT_SECRET}") | |
| 90 | + | fi | |
| 91 | + | ||
| 68 | 92 | # The CI runner is a Docker client, so it needs the socket and the group that | |
| 69 | 93 | # owns it. `label=disable` rather than a `:z` relabel: :z would rewrite the | |
| 70 | 94 | # label on the *host's* socket, which every other container also uses. | |
| ⋯ 5 unchanged lines | |||
| 76 | 100 | --security-opt label=disable \ | |
| 77 | 101 | --group-add "$(stat -c '%g' /var/run/docker.sock)" \ | |
| 78 | 102 | -e "ANVIL_BASE_URL=https://$NAME.localhost" \ | |
| 103 | + | "${SSO_ARGS[@]}" \ | |
| 79 | 104 | "$IMAGE" >/dev/null | |
| 80 | 105 | ||
| 81 | 106 | # Wait for the server to answer before handing over a URL that would 502. | |
| ⋯ 33 unchanged lines | |||
modifieddeploy/run.sh+9 −0
| ⋯ 17 unchanged lines | |||
| 18 | 18 | # pushed. Do not expose this instance to untrusted users. | |
| 19 | 19 | SOCK_GID="$(stat -c '%g' "$DOCKER_SOCK")" | |
| 20 | 20 | ||
| 21 | + | # Single sign-on's client secret, if this instance uses one (docs/oidc.md). | |
| 22 | + | # Passed only when set: an empty value would override the config file with | |
| 23 | + | # "no secret" and turn a confidential client into a public one. | |
| 24 | + | OIDC_ENV=() | |
| 25 | + | if [[ -n "${ANVIL_OIDC_CLIENT_SECRET:-}" ]]; then | |
| 26 | + | OIDC_ENV=(-e "ANVIL_OIDC_CLIENT_SECRET=${ANVIL_OIDC_CLIENT_SECRET}") | |
| 27 | + | fi | |
| 28 | + | ||
| 21 | 29 | docker rm -f anvil 2>/dev/null || true | |
| 22 | 30 | docker run -d \ | |
| 23 | 31 | --name anvil \ | |
| ⋯ 3 unchanged lines | |||
| 27 | 35 | -v anvil-data:/data \ | |
| 28 | 36 | -v "${DOCKER_SOCK}:/var/run/docker.sock" \ | |
| 29 | 37 | --group-add "$SOCK_GID" \ | |
| 38 | + | "${OIDC_ENV[@]}" \ | |
| 30 | 39 | "$IMAGE" | |
| 31 | 40 | ||
| 32 | 41 | echo "==> anvil (re)started from $IMAGE (web: anvil:3000 via Caddy, ssh: host :${SSH_PORT}, docker.sock gid ${SOCK_GID})" | |
addeddocs/oidc.md+143 −0
| 1 | + | # Single sign-on (OIDC) | |
| 2 | + | ||
| 3 | + | Sign in to anvil with an account at an OpenID Connect provider — | |
| 4 | + | [login.richardscollin.com](https://login.richardscollin.com) for this instance, | |
| 5 | + | where one passkey covers every app on the domain. | |
| 6 | + | ||
| 7 | + | It is **additive**. Password sign-in keeps working, remains the way in if the | |
| 8 | + | provider is down, and is the only way in on an instance with no `[oidc] issuer` | |
| 9 | + | configured. The two are reconciled on the `sub` claim, which the provider | |
| 10 | + | promises never changes, rather than on email, which does. | |
| 11 | + | ||
| 12 | + | ## Using it | |
| 13 | + | ||
| 14 | + | The login page grows a *Sign in with …* button. Pressing it hands you to the | |
| 15 | + | provider and back; anvil then finds your account, or makes one. | |
| 16 | + | ||
| 17 | + | Which account you land on: | |
| 18 | + | ||
| 19 | + | 1. **Linked already** — the `sub` claim matches an account. Its email and admin | |
| 20 | + | flag are refreshed from the token, and you are in. | |
| 21 | + | 2. **An account that predates single sign-on** — same email, not yet linked. | |
| 22 | + | Adopted, once, and *only* on an address the provider says it verified. | |
| 23 | + | Linking on an unverified address is how one account takes over another, so | |
| 24 | + | anvil refuses and tells you to use your password. | |
| 25 | + | 3. **Nobody** — a fresh account, named from `preferred_username` (sanitized, | |
| 26 | + | and suffixed `-2`, `-3`, … if taken; falling back to the email's local part | |
| 27 | + | when the name is reserved or unusable). It has **no password**: the stored | |
| 28 | + | hash is empty, which no password can match. `anvild user password` sets one | |
| 29 | + | if you ever want a local fallback. | |
| 30 | + | ||
| 31 | + | The provider decides *who may sign in at all* — access lives in its | |
| 32 | + | `client_grants`, not in an invite list here. Its per-app `role` claim decides | |
| 33 | + | who administers anvil: `admin` grants the flag, anything else removes it, and a | |
| 34 | + | token carrying no role at all leaves the local flag alone rather than quietly | |
| 35 | + | demoting somebody. | |
| 36 | + | ||
| 37 | + | Signing out ends the provider's session too (`[oidc] sso_logout`, on by | |
| 38 | + | default), so "sign out" means everywhere rather than just here. | |
| 39 | + | ||
| 40 | + | ## Configuring it | |
| 41 | + | ||
| 42 | + | ```toml | |
| 43 | + | [oidc] | |
| 44 | + | issuer = "https://login.richardscollin.com" # empty disables SSO entirely | |
| 45 | + | client_id = "anvil" | |
| 46 | + | client_secret = "" # prefer ANVIL_OIDC_CLIENT_SECRET; see below | |
| 47 | + | redirect_uri = "" # default: base_url + /-/oidc/callback | |
| 48 | + | label = "" # default: the issuer's host | |
| 49 | + | sso_logout = true | |
| 50 | + | ``` | |
| 51 | + | ||
| 52 | + | `ANVIL_OIDC_ISSUER`, `ANVIL_OIDC_CLIENT_ID`, `ANVIL_OIDC_CLIENT_SECRET` and | |
| 53 | + | `ANVIL_OIDC_REDIRECT_URI` override the file. **Keep the secret in the | |
| 54 | + | environment**: config files get committed, and `deploy/run.sh` (production) and | |
| 55 | + | `deploy/dev.sh` (local) both pass `ANVIL_OIDC_CLIENT_SECRET` through when it is | |
| 56 | + | set. A client registered as public needs no secret at all — PKCE protects the | |
| 57 | + | code either way. | |
| 58 | + | ||
| 59 | + | `redirect_uri` must match what is registered at the provider **exactly**; there | |
| 60 | + | are no wildcards. It defaults to `base_url` + `/-/oidc/callback`, so getting | |
| 61 | + | `http.base_url` right (as `PORTLESS_URL`/`ANVIL_BASE_URL` do behind a proxy) is | |
| 62 | + | usually all it takes. | |
| 63 | + | ||
| 64 | + | ## Registering anvil at the provider | |
| 65 | + | ||
| 66 | + | Admin panel → Apps → Register, or from a checkout of the provider | |
| 67 | + | ([../login-richardscollin](https://github.com/richardscollin)): | |
| 68 | + | ||
| 69 | + | ```sh | |
| 70 | + | npm run register-client -- \ | |
| 71 | + | --id anvil --name anvil \ | |
| 72 | + | --redirect https://anvil.localhost/-/oidc/callback \ | |
| 73 | + | --post-logout https://anvil.localhost/ \ | |
| 74 | + | --grant you@example.com:admin | |
| 75 | + | ``` | |
| 76 | + | ||
| 77 | + | That prints the client secret once. Against the deployed provider the same | |
| 78 | + | script runs inside the container, which is where production's database lives: | |
| 79 | + | ||
| 80 | + | ```sh | |
| 81 | + | ssh collin@hagrid 'docker exec login node --experimental-strip-types \ | |
| 82 | + | scripts/register-client.ts --id anvil --name anvil \ | |
| 83 | + | --redirect https://anvil.richardscollin.com/-/oidc/callback \ | |
| 84 | + | --post-logout https://anvil.richardscollin.com/ \ | |
| 85 | + | --grant you@example.com:admin' | |
| 86 | + | ``` | |
| 87 | + | ||
| 88 | + | Redirect URIs are matched exactly, so development and production need separate | |
| 89 | + | entries (pass `--redirect` twice) or separate clients. Production's client here | |
| 90 | + | carries production URIs only. | |
| 91 | + | ||
| 92 | + | ## Local development | |
| 93 | + | ||
| 94 | + | The provider runs on `https://login.localhost` (`portless` in its checkout); | |
| 95 | + | `deploy/anvil.dev.toml` points at it. | |
| 96 | + | ||
| 97 | + | Running anvil natively (`cargo run`) needs nothing more, as long as | |
| 98 | + | `portless trust` has put its CA in the system store — anvil's HTTP client uses | |
| 99 | + | the *system* roots, not a bundled set, precisely so a locally-issued | |
| 100 | + | certificate works. | |
| 101 | + | ||
| 102 | + | Running it in Docker (`deploy/dev.sh`) needs two things the container does not | |
| 103 | + | get for free, and the script arranges both: `login.localhost` resolves to the | |
| 104 | + | container's own loopback rather than the host's proxy (fixed with | |
| 105 | + | `--add-host login.localhost:host-gateway`), and portless's CA is not in the | |
| 106 | + | image's root store (fixed by mounting the host's roots plus that CA and | |
| 107 | + | pointing `SSL_CERT_FILE` at the result). | |
| 108 | + | ||
| 109 | + | ## What is checked, and why | |
| 110 | + | ||
| 111 | + | The code flow is only as good as its verification, so everything the provider | |
| 112 | + | sends back is checked before it becomes a session: | |
| 113 | + | ||
| 114 | + | - **PKCE (S256), always.** The verifier never leaves this server, so a code | |
| 115 | + | captured in transit cannot be redeemed. Cheap, and it removes the entire | |
| 116 | + | stolen-code class. | |
| 117 | + | - **`state`**, compared in constant time against a value held in a ten-minute, | |
| 118 | + | `HttpOnly`, `SameSite=Lax` cookie scoped to `/-/oidc`. Strict would be | |
| 119 | + | withheld on the redirect back, which is the one hop that matters. | |
| 120 | + | - **The id token's signature**, RS256 against the provider's published JWKS. The | |
| 121 | + | `alg` header is not consulted for *which* algorithm to use — accepting that is | |
| 122 | + | how `none` and algorithm-confusion attacks get in. An unknown `kid` triggers | |
| 123 | + | one refetch, which is how a key rotation propagates. | |
| 124 | + | - **`iss`, `aud`, `exp`**, against the configured issuer and client id. | |
| 125 | + | - **`nonce`**, against this login's own — what stops a token minted for one | |
| 126 | + | sign-in being replayed into another. | |
| 127 | + | - **The discovery document's own `issuer`**, which must equal the configured | |
| 128 | + | one. Otherwise a hijacked discovery URL could point anvil at somebody else's | |
| 129 | + | token endpoint while every later `iss` check still passed. | |
| 130 | + | ||
| 131 | + | A failure at any of these renders an error page and sets no session. | |
| 132 | + | ||
| 133 | + | ## Implementation | |
| 134 | + | ||
| 135 | + | `crates/anvil-web/src/oidc.rs` is the whole client: discovery, the two routes, | |
| 136 | + | the id token verification, and the mapping onto a local account. RS256 | |
| 137 | + | verification uses `ring` (already in the tree under rustls) rather than a JWT | |
| 138 | + | crate, because the maintained ones default to `aws-lc-rs`, which needs cmake and | |
| 139 | + | will not cross-compile to the static musl the deploy image is built from. | |
| 140 | + | ||
| 141 | + | `crates/anvil-web/tests/oidc_flow.rs` drives the whole hand-off against a | |
| 142 | + | stand-in provider that signs real RS256 tokens, covering the happy path, | |
| 143 | + | adoption of an existing account, username collisions, and the failures above. |
deleteddocs/passkeys.md+0 −78
| 1 | - | # Passkeys | |
| 2 | - | ||
| 3 | - | Sign in with Touch ID, Windows Hello, a phone, or a security key instead of an | |
| 4 | - | account password. Passkeys are for *login only* — repository secrets | |
| 5 | - | ([secrets.md](secrets.md)) stay keyed to your ssh keys, because CI needs to | |
| 6 | - | unlock them from a terminal where no authenticator is present. | |
| 7 | - | ||
| 8 | - | ## Using them | |
| 9 | - | ||
| 10 | - | **Register** (account settings → Passkeys): name the device, press *Add | |
| 11 | - | passkey*, approve the prompt. Registering a second passkey on the same | |
| 12 | - | authenticator is refused by the browser rather than silently duplicated — anvil | |
| 13 | - | sends the existing credential ids as `excludeCredentials`. | |
| 14 | - | ||
| 15 | - | **Sign in**: the login page's *Sign in with a passkey* button. No username: a | |
| 16 | - | passkey is a discoverable credential, so the authenticator tells anvil which | |
| 17 | - | credential it used and that identifies the account. | |
| 18 | - | ||
| 19 | - | Password sign-in keeps working, and remains the way in if you lose every | |
| 20 | - | authenticator. Removing your last passkey is allowed for the same reason. | |
| 21 | - | ||
| 22 | - | ## What anvil stores, and what it means if the database leaks | |
| 23 | - | ||
| 24 | - | Only public material: the credential id, the credential's public key, and the | |
| 25 | - | counters WebAuthn asks a relying party to track. The private key stays in the | |
| 26 | - | authenticator and is never transmitted, so — unlike a password hash — nothing in | |
| 27 | - | the `passkeys` table can be turned into a login, offline or otherwise. A leak | |
| 28 | - | costs users their registrations, not their accounts. | |
| 29 | - | ||
| 30 | - | Two properties come from the protocol rather than from anvil's code: | |
| 31 | - | ||
| 32 | - | - **Phishing resistance.** The authenticator binds every signature to anvil's | |
| 33 | - | relying-party id. A look-alike site cannot get a usable signature, even with a | |
| 34 | - | perfect replica of this UI. | |
| 35 | - | - **Replay resistance.** Every ceremony is a fresh random challenge, held in | |
| 36 | - | memory, valid for five minutes, and accepted exactly once. | |
| 37 | - | ||
| 38 | - | ## The relying-party id is your `base_url` host | |
| 39 | - | ||
| 40 | - | WebAuthn scopes a credential to one host, taken here from `http.base_url`: | |
| 41 | - | ||
| 42 | - | | `base_url` | RP id | | |
| 43 | - | |-----------------------------------|---------------------------| | |
| 44 | - | | `https://anvil.richardscollin.com` | `anvil.richardscollin.com` | | |
| 45 | - | | `https://anvil.localhost` | `anvil.localhost` | | |
| 46 | - | | `http://localhost:3000` | `localhost` | | |
| 47 | - | ||
| 48 | - | Consequences worth knowing before you move an instance: | |
| 49 | - | ||
| 50 | - | - **Change the host and existing passkeys stop working.** They are not deleted, | |
| 51 | - | they simply belong to a different site now; users re-register (password login | |
| 52 | - | is the way back in). | |
| 53 | - | - **Passkeys do not travel between instances.** One created against the local | |
| 54 | - | Docker instance (`deploy/dev.sh`) is not usable on production, by design. | |
| 55 | - | - **WebAuthn requires a secure context**: HTTPS, or plain `localhost`. A LAN IP | |
| 56 | - | over HTTP will not offer passkeys at all. `deploy/dev.sh` + portless gives | |
| 57 | - | local development real HTTPS, which is why passkeys can be tested there. | |
| 58 | - | ||
| 59 | - | ## Implementation | |
| 60 | - | ||
| 61 | - | `crates/anvil-core/src/passkeys.rs` holds the credential storage and the | |
| 62 | - | in-memory challenge registry; `crates/anvil-web/src/passkeys.rs` holds the two | |
| 63 | - | ceremonies, the JSON, and the browser glue. | |
| 64 | - | ||
| 65 | - | Verification is [`webauthn_rp`](https://crates.io/crates/webauthn_rp), chosen | |
| 66 | - | over the better-known `webauthn-rs` for one hard reason: `webauthn-rs` depends | |
| 67 | - | on OpenSSL, and anvil ships as a statically linked musl binary built by | |
| 68 | - | `deploy/build.sh` with no C toolchain in the picture. `webauthn_rp` is pure Rust | |
| 69 | - | and implements the spec's ceremony steps explicitly. | |
| 70 | - | ||
| 71 | - | Only passkeys are supported — discoverable credentials with user verification | |
| 72 | - | required. No attestation is requested (`none`), which is the norm for consumer | |
| 73 | - | authenticators and avoids collecting hardware identifiers we have no use for. | |
| 74 | - | ||
| 75 | - | The browser side hand-rolls the base64url ↔ ArrayBuffer conversions rather than | |
| 76 | - | using `PublicKeyCredential.parseCreationOptionsFromJSON()` / `toJSON()`: those | |
| 77 | - | are recent enough that relying on them would narrow support to new browsers for | |
| 78 | - | no gain. |