anvilsign in

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
55 *.db-shm
66 anvil.toml
77 /deploy/anvild
8+# CA bundle deploy/dev.sh builds so the dev container trusts portless (docs/oidc.md).
9+/deploy/dev-ca.crt
810 # Local-only API credentials for fetching attachments (never committed).
911 /.anvil-credentials
modifiedCargo.lock+50 −382
⋯ 165 unchanged lines
166166 "argon2 0.5.3",
167167 "async-trait",
168168 "base64",
169- "curve25519-dalek 5.0.0-rc.0",
169+ "curve25519-dalek",
170170 "gix",
171171 "hmac 0.12.1",
172172 "pulldown-cmark",
⋯ 9 unchanged lines
182182 "tokio",
183183 "toml",
184184 "tracing",
185- "webauthn_rp",
186185 ]
187186
188187 [[package]]
⋯ 34 unchanged lines
223222 "axum",
224223 "axum-extra",
225224 "base64",
226- "ciborium",
227225 "lru",
228226 "maud",
229- "p256 0.13.2",
230227 "pulldown-cmark",
228+ "reqwest",
229+ "ring",
230+ "rsa 0.9.10",
231+ "rustls",
231232 "serde",
232233 "serde_json",
233234 "sha2 0.10.9",
⋯ 7 unchanged lines
241242 "tower",
242243 "tower-http",
243244 "tracing",
244- "webauthn_rp",
245245 ]
246246
247247 [[package]]
⋯ 141 unchanged lines
389389
390390 [[package]]
391391 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"
398392 version = "1.0.0"
399393 source = "registry+https://github.com/rust-lang/crates.io-index"
400394 checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
⋯ 257 unchanged lines
658652 ]
659653
660654 [[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]]
688655 name = "cipher"
689656 version = "0.5.2"
690657 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 154 unchanged lines
845812 checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
846813
847814 [[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]]
866815 name = "crypto-bigint"
867816 version = "0.7.3"
868817 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 37 unchanged lines
906855 source = "registry+https://github.com/rust-lang/crates.io-index"
907856 checksum = "21f41f23de7d24cdbda7f0c4d9c0351f99a4ceb258ef30e5c1927af8987ffe5a"
908857 dependencies = [
909- "crypto-bigint 0.7.3",
858+ "crypto-bigint",
910859 "libm",
911860 "rand_core 0.10.1",
912861 ]
⋯ 19 unchanged lines
932881
933882 [[package]]
934883 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"
950884 version = "5.0.0-rc.0"
951885 source = "registry+https://github.com/rust-lang/crates.io-index"
952886 checksum = "4f359e08ca85e7bd759e1fd933ff2bccd81864c60a8fba0e259c7f822b0924bf"
⋯ 2 unchanged lines
955889 "cpufeatures 0.3.0",
956890 "curve25519-dalek-derive",
957891 "digest 0.11.3",
958- "fiat-crypto 0.3.0",
892+ "fiat-crypto",
959893 "rustc_version",
960894 "subtle",
961895 "zeroize",
⋯ 151 unchanged lines
11131047
11141048 [[package]]
11151049 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"
11301050 version = "0.17.0-rc.18"
11311051 source = "registry+https://github.com/rust-lang/crates.io-index"
11321052 checksum = "54fb064faabbee66e1fc8e5c5a9458d4269dc2d8b638fe86a425adb2510d1a96"
11331053 dependencies = [
11341054 "der 0.8.0",
11351055 "digest 0.11.3",
1136- "elliptic-curve 0.14.0-rc.33",
1137- "rfc6979 0.5.0",
1056+ "elliptic-curve",
1057+ "rfc6979",
11381058 "signature 3.0.0",
11391059 "spki 0.8.0",
11401060 "zeroize",
⋯ 1 unchanged line
11421062
11431063 [[package]]
11441064 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"
11541065 version = "3.0.0"
11551066 source = "registry+https://github.com/rust-lang/crates.io-index"
11561067 checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
⋯ 4 unchanged lines
11611072
11621073 [[package]]
11631074 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"
11761075 version = "3.0.0-rc.0"
11771076 source = "registry+https://github.com/rust-lang/crates.io-index"
11781077 checksum = "b011170fe4f04665565b4110afef66774fe9ffff278f3eb5b81cc73d26e27d60"
11791078 dependencies = [
1180- "curve25519-dalek 5.0.0-rc.0",
1181- "ed25519 3.0.0",
1079+ "curve25519-dalek",
1080+ "ed25519",
11821081 "rand_core 0.10.1",
11831082 "serde",
11841083 "sha2 0.11.0",
⋯ 4 unchanged lines
11891088
11901089 [[package]]
11911090 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"
12121091 version = "0.14.0-rc.33"
12131092 source = "registry+https://github.com/rust-lang/crates.io-index"
12141093 checksum = "102d3643d30dd8b559613c5cced68317199597fffb278cdc88daa2ef7fafc935"
12151094 dependencies = [
1216- "base16ct 1.0.0",
1217- "crypto-bigint 0.7.3",
1095+ "base16ct",
1096+ "crypto-bigint",
12181097 "crypto-common 0.2.2",
12191098 "digest 0.11.3",
1220- "ff 0.14.0",
1221- "group 0.14.0",
1099+ "ff",
1100+ "group",
12221101 "hkdf",
12231102 "hybrid-array",
12241103 "once_cell",
12251104 "pem-rfc7468 1.0.0",
12261105 "pkcs8 0.11.0",
12271106 "rand_core 0.10.1",
1228- "sec1 0.8.1",
1107+ "sec1",
12291108 "subtle",
12301109 "zeroize",
12311110 ]
⋯ 65 unchanged lines
12971176
12981177 [[package]]
12991178 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"
13101179 version = "0.14.0"
13111180 source = "registry+https://github.com/rust-lang/crates.io-index"
13121181 checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f"
⋯ 4 unchanged lines
13171186
13181187 [[package]]
13191188 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"
13261189 version = "0.3.0"
13271190 source = "registry+https://github.com/rust-lang/crates.io-index"
13281191 checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
⋯ 147 unchanged lines
14761339 dependencies = [
14771340 "typenum",
14781341 "version_check",
1479- "zeroize",
14801342 ]
14811343
14821344 [[package]]
⋯ 20 unchanged lines
15031365
15041366 [[package]]
15051367 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"
15181368 version = "0.4.2"
15191369 source = "registry+https://github.com/rust-lang/crates.io-index"
15201370 checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
⋯ 1 unchanged line
15221372 "cfg-if",
15231373 "js-sys",
15241374 "libc",
1525- "r-efi 6.0.0",
1375+ "r-efi",
15261376 "rand_core 0.10.1",
15271377 "wasip2",
15281378 "wasip3",
⋯ 756 unchanged lines
22852135
22862136 [[package]]
22872137 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"
22992138 version = "0.14.0"
23002139 source = "registry+https://github.com/rust-lang/crates.io-index"
23012140 checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4"
23022141 dependencies = [
2303- "ff 0.14.0",
2142+ "ff",
23042143 "rand_core 0.10.1",
23052144 "subtle",
23062145 ]
23072146
23082147 [[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]]
23202148 name = "hash32"
23212149 version = "0.3.1"
23222150 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 915 unchanged lines
32383066
32393067 [[package]]
32403068 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"
32533069 version = "0.14.0-rc.10"
32543070 source = "registry+https://github.com/rust-lang/crates.io-index"
32553071 checksum = "41adc63effe99d48837a8cc0e6d7a77e32ae6a07f6000df466178dbc2193093e"
32563072 dependencies = [
3257- "ecdsa 0.17.0-rc.18",
3258- "elliptic-curve 0.14.0-rc.33",
3073+ "ecdsa",
3074+ "elliptic-curve",
32593075 "primefield",
3260- "primeorder 0.14.0-rc.10",
3076+ "primeorder",
32613077 "sha2 0.11.0",
32623078 ]
32633079
32643080 [[package]]
32653081 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"
32783082 version = "0.14.0-rc.10"
32793083 source = "registry+https://github.com/rust-lang/crates.io-index"
32803084 checksum = "9bd5333afa5ae0347f39e6a0f2c9c155da431583fd71fe5555bd0521b4ccaf02"
32813085 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",
32853089 "primefield",
3286- "primeorder 0.14.0-rc.10",
3090+ "primeorder",
32873091 "sha2 0.11.0",
32883092 ]
32893093
⋯ 3 unchanged lines
32933097 source = "registry+https://github.com/rust-lang/crates.io-index"
32943098 checksum = "a3a5297f53dc16d35909060ba3032cff7867e8809f01e273ff325579d5f0ceae"
32953099 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",
32993103 "primefield",
3300- "primeorder 0.14.0-rc.10",
3104+ "primeorder",
33013105 "sha2 0.11.0",
33023106 ]
33033107
⋯ 3 unchanged lines
33073111 source = "registry+https://github.com/rust-lang/crates.io-index"
33083112 checksum = "4f3a5ae18f65a85c67a77d18d42d3606c07948e3c17c1e5f74852b26589e88a5"
33093113 dependencies = [
3310- "base16ct 1.0.0",
3114+ "base16ct",
33113115 "byteorder",
33123116 "bytes",
33133117 "delegate",
⋯ 250 unchanged lines
35643368 ]
35653369
35663370 [[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]]
36013371 name = "prettyplease"
36023372 version = "0.2.37"
36033373 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 9 unchanged lines
36133383 source = "registry+https://github.com/rust-lang/crates.io-index"
36143384 checksum = "f845ec3240cd5ed5e1e31cf3ff633a5bf47c698dc4092ba9e767415b3d393406"
36153385 dependencies = [
3616- "crypto-bigint 0.7.3",
3386+ "crypto-bigint",
36173387 "crypto-common 0.2.2",
3618- "ff 0.14.0",
3388+ "ff",
36193389 "rand_core 0.10.1",
36203390 "subtle",
36213391 "zeroize",
⋯ 1 unchanged line
36233393
36243394 [[package]]
36253395 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"
36353396 version = "0.14.0-rc.10"
36363397 source = "registry+https://github.com/rust-lang/crates.io-index"
36373398 checksum = "7d2793f22b9b6fd11ef3ac1d59bf003c2573593e4968702341605c2748fd90bf"
36383399 dependencies = [
3639- "elliptic-curve 0.14.0-rc.33",
3400+ "elliptic-curve",
36403401 ]
36413402
36423403 [[package]]
⋯ 64 unchanged lines
37073468
37083469 [[package]]
37093470 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"
37163471 version = "6.0.0"
37173472 source = "registry+https://github.com/rust-lang/crates.io-index"
37183473 checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
⋯ 4 unchanged lines
37233478 source = "registry+https://github.com/rust-lang/crates.io-index"
37243479 checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
37253480 dependencies = [
3726- "rand_chacha 0.3.1",
3481+ "rand_chacha",
37273482 "rand_core 0.6.4",
37283483 ]
37293484
37303485 [[package]]
37313486 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"
37423487 version = "0.10.1"
37433488 source = "registry+https://github.com/rust-lang/crates.io-index"
37443489 checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
⋯ 14 unchanged lines
37593504 ]
37603505
37613506 [[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]]
37723507 name = "rand_core"
37733508 version = "0.6.4"
37743509 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 4 unchanged lines
37793514
37803515 [[package]]
37813516 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"
37913517 version = "0.10.1"
37923518 source = "registry+https://github.com/rust-lang/crates.io-index"
37933519 checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
⋯ 49 unchanged lines
38433569 "memchr",
38443570 "regex-syntax",
38453571 ]
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"
38523572
38533573 [[package]]
38543574 name = "regex-syntax"
⋯ 40 unchanged lines
38953615
38963616 [[package]]
38973617 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"
39083618 version = "0.5.0"
39093619 source = "registry+https://github.com/rust-lang/crates.io-index"
39103620 checksum = "5236ce872cac07e0fb3969b0cbf468c7d2f37d432f1b627dcb7b8d34563fb0c3"
⋯ 41 unchanged lines
39523662 "pkcs1 0.7.5",
39533663 "pkcs8 0.10.2",
39543664 "rand_core 0.6.4",
3955- "sha2 0.10.9",
39563665 "signature 2.2.0",
39573666 "spki 0.7.3",
39583667 "subtle",
⋯ 7 unchanged lines
39663675 checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf"
39673676 dependencies = [
39683677 "const-oid 0.10.2",
3969- "crypto-bigint 0.7.3",
3678+ "crypto-bigint",
39703679 "crypto-primes",
39713680 "digest 0.11.3",
39723681 "pkcs1 0.8.0-rc.4",
⋯ 53 unchanged lines
40263735 "bytes",
40273736 "cbc",
40283737 "cipher",
4029- "crypto-bigint 0.7.3",
3738+ "crypto-bigint",
40303739 "ctr",
4031- "curve25519-dalek 5.0.0-rc.0",
3740+ "curve25519-dalek",
40323741 "data-encoding",
40333742 "delegate",
40343743 "der 0.8.0",
40353744 "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",
40393748 "enum_dispatch",
40403749 "flate2",
40413750 "futures",
⋯ 10 unchanged lines
40523761 "ml-kem",
40533762 "module-lattice",
40543763 "num-bigint",
4055- "p256 0.14.0-rc.10",
4056- "p384 0.14.0-rc.10",
3764+ "p256",
3765+ "p384",
40573766 "p521",
40583767 "pageant",
40593768 "pbkdf2",
⋯ 9 unchanged lines
40693778 "russh-util",
40703779 "salsa20",
40713780 "scrypt",
4072- "sec1 0.8.1",
3781+ "sec1",
40733782 "sha1 0.11.0",
40743783 "sha2 0.11.0",
40753784 "sha3",
⋯ 186 unchanged lines
42623971
42633972 [[package]]
42643973 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"
42793974 version = "0.8.1"
42803975 source = "registry+https://github.com/rust-lang/crates.io-index"
42813976 checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d"
42823977 dependencies = [
4283- "base16ct 1.0.0",
3978+ "base16ct",
42843979 "ctutils",
42853980 "der 0.8.0",
42863981 "hybrid-array",
⋯ 154 unchanged lines
44414136 source = "registry+https://github.com/rust-lang/crates.io-index"
44424137 checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e"
44434138 dependencies = [
4444- "base16ct 1.0.0",
4139+ "base16ct",
44454140 "serde",
44464141 ]
44474142
⋯ 212 unchanged lines
46604355 dependencies = [
46614356 "base64ct",
46624357 "bytes",
4663- "crypto-bigint 0.7.3",
4358+ "crypto-bigint",
46644359 "ctutils",
46654360 "digest 0.11.3",
46664361 "pem-rfc7468 1.0.0",
⋯ 9 unchanged lines
46764371 "argon2 0.6.0-rc.8",
46774372 "bcrypt-pbkdf",
46784373 "ctutils",
4679- "ed25519-dalek 3.0.0-rc.0",
4374+ "ed25519-dalek",
46804375 "hex",
46814376 "hmac 0.13.0",
4682- "p256 0.14.0-rc.10",
4683- "p384 0.14.0-rc.10",
4377+ "p256",
4378+ "p384",
46844379 "p521",
46854380 "rand_core 0.10.1",
46864381 "rsa 0.10.0-rc.18",
4687- "sec1 0.8.1",
4382+ "sec1",
46884383 "sha1 0.11.0",
46894384 "sha2 0.11.0",
46904385 "signature 3.0.0",
⋯ 503 unchanged lines
51944889 checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
51954890
51964891 [[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]]
52064892 name = "uluru"
52074893 version = "3.1.0"
52084894 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 251 unchanged lines
54605146 dependencies = [
54615147 "js-sys",
54625148 "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",
54815149 ]
54825150
54835151 [[package]]
⋯ 473 unchanged lines
modifiedCargo.toml+5 −4
⋯ 67 unchanged lines
6868 rusqlite = "0.39"
6969 # `anvild secret` prompts for an ssh key passphrase / an account password.
7070 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"
7576 serde = { version = "1", features = ["derive"] }
7677 serde_json = "1"
7778 serde_yaml = "0.9"
⋯ 25 unchanged lines
modifiedREADME.md+1 −1
⋯ 36 unchanged lines
3737 ## Docs
3838
3939 - [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
4141 - [Repository secrets](docs/secrets.md) — encrypted to your ssh keys in the
4242 browser; anvil stores ciphertext it cannot open
4343 - [Threat model for untrusted users](docs/untrusted-mode.md)
modifiedTODO.md+4 −3
⋯ 29 unchanged lines
3030 repo listings for visual browsing
3131 - [ ] API tokens: a `write` scope (would need CSRF-exempt write paths) and
3232 `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
3637 - [ ] secrets follow-ups (docs/secrets.md): authenticate `anvild secret` with an
3738 ssh signature instead of the account password; per-step rather than per-
3839 pipeline scoping; `ssh-rsa` recipients (needs an RSA-OAEP branch in both the
⋯ 1 unchanged line
modifiedanvil.example.toml+20 −0
⋯ 18 unchanged lines
1919 # 0 means unlimited.
2020 attachment_quota_mb = 0
2121
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+
2242 [ssh]
2343 enabled = false
2444 # 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
186186 Box::new(anvil_core::periodic::SecretVaultSweepJob)
187187 as Box<dyn anvil_core::periodic::PeriodicJob>,
188188 ),
189- (
190- std::time::Duration::from_secs(300),
191- Box::new(anvil_core::periodic::PasskeyCeremonySweepJob)
192- as Box<dyn anvil_core::periodic::PeriodicJob>,
193- ),
194189 ];
195190 anvil_core::periodic::spawn_runner(app.clone(), periodic_jobs).await;
196191
⋯ 140 unchanged lines
modifiedcrates/anvil-core/Cargo.toml+0 −1
⋯ 18 unchanged lines
1919 hmac.workspace = true
2020 sha2.workspace = true
2121 ssh-key.workspace = true
22-webauthn_rp.workspace = true
2322 serde.workspace = true
2423 serde_json.workspace = true
2524 serde_yaml.workspace = true
⋯ 13 unchanged lines
modifiedcrates/anvil-core/src/config.rs+113 −0
⋯ 30 unchanged lines
3131 pub ci: CiConfig,
3232 /// Periodic background job settings.
3333 pub periodic: PeriodicConfig,
34+ /// Single sign-on against an OpenID Connect provider.
35+ pub oidc: OidcConfig,
3436 }
3537
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+
3674 /// CI configuration: job sandbox limits and the single-repo redeploy webhook.
3775 ///
3876 /// On a successful CI run of [`deploy_branch`](CiConfig::deploy_branch) in the
⋯ 117 unchanged lines
156194 ssh: SshConfig::default(),
157195 ci: CiConfig::default(),
158196 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,
159211 }
160212 }
161213 }
162214
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+
163250 impl Default for CiConfig {
164251 fn default() -> Self {
165252 Self {
⋯ 122 unchanged lines
288375 if let Some(dir) = env("ANVIL_DATA_DIR") {
289376 self.data_dir = dir.into();
290377 }
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+ )
291404 }
292405
293406 /// Filesystem path to the SQLite database file.
⋯ 144 unchanged lines
modifiedcrates/anvil-core/src/db.rs+23 −25
⋯ 11 unchanged lines
1212 CiRun,
1313 Issue,
1414 IssueComment,
15- Passkey,
1615 RepoSecret,
1716 Repository,
1817 Session,
⋯ 29 unchanged lines
4847 Attachment,
4948 ApiToken,
5049 AdminCache,
51- RepoSecret,
52- Passkey
50+ RepoSecret
5351 ))
5452 .connect(&url)
5553 .await?;
⋯ 26 unchanged lines
8280 ADMIN_CACHE_DDL,
8381 REPO_SECRETS_DDL,
8482 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")"#,
8883 ];
8984
9085 const CI_ARTIFACTS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "ci_artifacts" (
⋯ 51 unchanged lines
142137 "recipients" TEXT NOT NULL,
143138 "created_at" BIGINT NOT NULL,
144139 "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 )"#;
157140
158141 const ADMIN_CACHE_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "admin_cache" (
159142 "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
⋯ 28 unchanged lines
188171 "languages_json",
189172 r#"ALTER TABLE "repositories" ADD COLUMN "languages_json" TEXT NOT NULL DEFAULT ''"#,
190173 ),
174+ (
175+ "users",
176+ "sso_sub",
177+ r#"ALTER TABLE "users" ADD COLUMN "sso_sub" TEXT NOT NULL DEFAULT ''"#,
178+ ),
191179 ];
192180
193181 /// Apply [`SCHEMA_SHIMS`] and [`COLUMN_SHIMS`] to an existing database. Uses
⋯ 33 unchanged lines
227215 "attachments",
228216 "api_tokens",
229217 "repo_secrets",
230- "passkeys",
231218 ];
232219
233220 /// Every schema object (table + indexes) for `table`, normalized.
⋯ 66 unchanged lines
300287 /// A column shim must converge an old table to the fresh schema's columns
301288 /// (same names, order, types, nullability — the DDL text itself differs
302289 /// 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`.
303296 #[tokio::test]
304297 async fn column_shim_matches_push_schema() {
305298 let dir = tempfile::tempdir().unwrap();
⋯ 4 unchanged lines
310303 let migrated = dir.path().join("migrated.db");
311304 connect(&migrated).await.unwrap();
312305 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+ }
315310 drop(conn);
316311 connect(&migrated).await.unwrap();
317312 connect(&migrated).await.unwrap(); // idempotent
318313
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+ }
323321 }
324322
325323 /// 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
1616 pub mod issues;
1717 pub mod language;
1818 pub mod models;
19-pub mod passkeys;
2019 pub mod periodic;
2120 pub mod preview_images;
2221 pub mod repos;
⋯ 16 unchanged lines
3938 CiRun,
4039 Issue,
4140 IssueComment,
42- Passkey,
4341 RepoSecret,
4442 Repository,
4543 Session,
⋯ 23 unchanged lines
6967 /// Plaintext repo secrets for CI, held in memory only and lost on
7068 /// restart — see [`secrets::Vault`].
7169 pub vault: secrets::Vault,
72- /// WebAuthn challenges awaiting an answer — see [`passkeys::Ceremonies`].
73- pub ceremonies: passkeys::Ceremonies,
7470 /// Server-wide secret keying CSRF tokens. Persisted in the data dir so
7571 /// tokens survive restarts. Wrapped in `Arc` to keep `App: Clone` cheap.
7672 csrf_secret: std::sync::Arc<[u8; 32]>,
⋯ 14 unchanged lines
9187 db,
9288 ci_tx: None,
9389 vault: secrets::Vault::default(),
94- ceremonies: passkeys::Ceremonies::default(),
9590 csrf_secret,
9691 })
9792 }
⋯ 52 unchanged lines
modifiedcrates/anvil-core/src/models.rs+9 −35
⋯ 13 unchanged lines
1414 #[unique]
1515 pub username: String,
1616 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.
1820 pub password_hash: String,
1921 pub is_admin: bool,
2022 /// Unix timestamp (seconds) of account creation.
2123 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,
2230 }
2331
2432 /// A hosted repository, owned by a [`User`].
⋯ 188 unchanged lines
213221 /// Normalized OpenSSH public-key line.
214222 pub content: String,
215223 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,
250224 }
251225
252226 /// 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
2323 };
2424
2525 /// 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)]
2632 #[async_trait]
2733 pub trait PeriodicJob: Send + Sync {
2834 /// Run the job once. Errors are logged but do not stop the runner.
⋯ 150 unchanged lines
179185
180186 fn name(&self) -> &str {
181187 "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"
197188 }
198189 }
199190
⋯ 24 unchanged lines
modifiedcrates/anvil-core/src/users.rs+180 −5
⋯ 47 unchanged lines
4848 }
4949
5050 /// 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.
5155 pub fn verify_password(hash: &str, password: &str) -> Result<bool> {
56+ if hash.is_empty() {
57+ return Ok(false);
58+ }
5259 let parsed = PasswordHash::new(hash).map_err(|e| Error::Password(e.to_string()))?;
5360 Ok(Argon2::default()
5461 .verify_password(password.as_bytes(), &parsed)
⋯ 10 unchanged lines
6572 password: &str,
6673 is_admin: bool,
6774 ) -> 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<()> {
6885 if username.trim().is_empty() {
6986 return Err(Error::Invalid("username must not be empty".into()));
7087 }
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).
7588 if username.contains('/') || username.contains('\\') || username.contains("..") {
7689 return Err(Error::Invalid(format!("invalid username: {username:?}")));
7790 }
7891 if RESERVED_USERNAMES.contains(&username.to_ascii_lowercase().as_str()) {
7992 return Err(Error::Invalid(format!("username '{username}' is reserved")));
8093 }
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)?;
81108 if find_by_username(db, username).await?.is_some() {
82109 return Err(Error::AlreadyExists(format!("user {username}")));
83110 }
⋯ 2 unchanged lines
86113 let user = toasty::create!(User {
87114 username: username,
88115 email: email,
89- password_hash: hash_password(password)?,
116+ password_hash: password_hash,
90117 is_admin: is_admin,
91118 created_at: crate::now(),
119+ sso_sub: sso_sub,
92120 })
93121 .exec(&mut db)
94122 .await?;
⋯ 42 unchanged lines
137165 Ok(user)
138166 }
139167
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+
140315 #[cfg(test)]
141316 mod tests {
142317 use super::*;
⋯ 32 unchanged lines
modifiedcrates/anvil-web/Cargo.toml+9 −6
⋯ 9 unchanged lines
1010 [dependencies]
1111 anvil-core.workspace = true
1212 anvil-git.workspace = true
13-webauthn_rp.workspace = true
1413 axum.workspace = true
1514 axum-extra.workspace = true
1615 tokio.workspace = true
⋯ 3 unchanged lines
2019 serde.workspace = true
2120 serde_json.workspace = true
2221 base64.workspace = true
22+reqwest.workspace = true
23+ring.workspace = true
24+rustls.workspace = true
2325 lru.workspace = true
2426 maud.workspace = true
2527 pulldown-cmark.workspace = true
⋯ 8 unchanged lines
3436 ssh-key = { workspace = true, features = ["ed25519"] }
3537 tokio = { workspace = true }
3638 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
149149 }
150150
151151 /// 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 {
153153 if a.len() != b.len() {
154154 return false;
155155 }
⋯ 18 unchanged lines
174174 pub csrf: String,
175175 }
176176
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+
177185 /// `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 {
179191 if user.is_some() {
180192 return Redirect::to("/").into_response();
181193 }
182- login_page(None).into_response()
194+ login_page(&app, query.next.as_deref(), None).into_response()
183195 }
184196
185197 /// `POST /login` — verify credentials, create a session, set the cookie.
⋯ 12 unchanged lines
198210 let Some(user) = ok else {
199211 return (
200212 axum::http::StatusCode::UNAUTHORIZED,
201- login_page(Some("Invalid username or password.")),
213+ login_page(&app, None, Some("Invalid username or password.")),
202214 )
203215 .into_response();
204216 };
⋯ 8 unchanged lines
213225 tracing::error!("session create failed: {e}");
214226 (
215227 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.")),
217229 )
218230 .into_response()
219231 }
⋯ 5 unchanged lines
225237 /// from cross-site POSTs (so a forced logout can't identify the session), and
226238 /// the impact of a forced logout is trivial. The high-value mutating forms
227239 /// (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 {
229245 if let Some(cookie) = jar.get(SESSION_COOKIE) {
230246 let _ = sessions::delete(&app.db, cookie.value()).await;
231247 }
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()
233258 }
234259
235-fn login_page(error: Option<&str>) -> Markup {
260+fn login_page(app: &App, next: Option<&str>, error: Option<&str>) -> Markup {
236261 layout(
237262 "Sign in",
238263 None,
⋯ 2 unchanged lines
241266 @if let Some(error) = error {
242267 p.error-msg { (error) }
243268 }
269+ (crate::oidc::sign_in_button(&app.config.oidc, next))
244270 form method="post" action="/-/login" style="max-width:320px" {
245271 p { label { "Username" br; input name="username" autofocus; } }
246272 p { label { "Password" br; input name="password" type="password"; } }
247273 button type="submit" { "Sign in" }
248274 }
249- (crate::passkeys::shared_script())
250- (crate::passkeys::login_button())
251275 },
252276 )
253277 }
⋯ 30 unchanged lines
modifiedcrates/anvil-web/src/lib.rs+2 −2
⋯ 24 unchanged lines
2525 pub mod attachments;
2626 pub mod auth;
2727 pub mod git_http;
28+pub mod oidc;
2829 pub mod pages;
29-pub mod passkeys;
3030 pub mod secrets;
3131 pub mod todomd;
3232 pub mod ui;
⋯ 12 unchanged lines
4545 router,
4646 app.config.http.attachment_max_mb.saturating_mul(1 << 20),
4747 ); // uploaded image attachments
48- router = passkeys::routes(router); // WebAuthn sign-in
48+ router = oidc::routes(router); // single sign-on, when configured
4949 router = secrets::routes(router); // sealed per-repo secrets + unlock API
5050 router = git_http::routes(router); // smart-HTTP git endpoints
5151 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, &registration, &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
679679 Err(e) => return server_error(e),
680680 };
681681 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()
686683 }
687684
688685 /// `POST /settings/keys` — register an SSH public key for the current user.
⋯ 24 unchanged lines
713710 .await
714711 .unwrap_or_default();
715712 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();
719713 (
720714 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),
730716 )
731717 .into_response()
732718 }
⋯ 34 unchanged lines
767753 .await
768754 .unwrap_or_default();
769755 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()
783757 }
784758
785759 /// `POST /settings/tokens/{id}/delete` — revoke one of the current user's
⋯ 40 unchanged lines
826800 Redirect::to("/-/settings").into_response()
827801 }
828802
829-#[allow(clippy::too_many_arguments)]
830803 fn account_page(
831804 user: &User,
832805 keys: &[SshKey],
833806 tokens: &[ApiToken],
834- passkeys: &[anvil_core::Passkey],
835807 new_token: Option<&str>,
836808 error: Option<&str>,
837809 csrf: &str,
⋯ 6 unchanged lines
844816 p.muted {
845817 "Signed in as " strong { (user.username) }
846818 @if !user.email.is_empty() { " · " (user.email) }
819+ @if !user.sso_sub.is_empty() { " · " span.pill { "single sign-on" } }
847820 }
848821
849822 h2 { "SSH keys" }
⋯ 57 unchanged lines
907880 p { label { "Name" br; input type="text" name="name" placeholder="claude"; } }
908881 p { button.btn type="submit" { "Create token" } }
909882 }
910- (crate::passkeys::shared_script())
911- (crate::passkeys::settings_section(user, passkeys, csrf))
912883 },
913884 )
914885 }
⋯ 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
1313 # differently named instance (ANVIL_DEV_NAME=anvil2) still gets correct links.
1414 base_url = "https://anvil.localhost"
1515
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+
1625 [ssh]
1726 enabled = true
1827 listen = "0.0.0.0:2222"
⋯ 18 unchanged lines
modifieddeploy/dev.sh+25 −0
⋯ 64 unchanged lines
6565
6666 echo "==> (re)starting container $NAME"
6767 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+
6892 # The CI runner is a Docker client, so it needs the socket and the group that
6993 # owns it. `label=disable` rather than a `:z` relabel: :z would rewrite the
7094 # label on the *host's* socket, which every other container also uses.
⋯ 5 unchanged lines
76100 --security-opt label=disable \
77101 --group-add "$(stat -c '%g' /var/run/docker.sock)" \
78102 -e "ANVIL_BASE_URL=https://$NAME.localhost" \
103+ "${SSO_ARGS[@]}" \
79104 "$IMAGE" >/dev/null
80105
81106 # 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
1818 # pushed. Do not expose this instance to untrusted users.
1919 SOCK_GID="$(stat -c '%g' "$DOCKER_SOCK")"
2020
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+
2129 docker rm -f anvil 2>/dev/null || true
2230 docker run -d \
2331 --name anvil \
⋯ 3 unchanged lines
2735 -v anvil-data:/data \
2836 -v "${DOCKER_SOCK}:/var/run/docker.sock" \
2937 --group-add "$SOCK_GID" \
38+ "${OIDC_ENV[@]}" \
3039 "$IMAGE"
3140
3241 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.