diff --git a/Cargo.lock b/Cargo.lock index 9073954686..22675d2c80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -477,7 +477,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.17", "password-hash", ] @@ -494,7 +494,7 @@ dependencies = [ "serde_json", "thiserror 2.0.17", "utoipa", - "uuid 1.18.1", + "uuid 1.23.3", ] [[package]] @@ -530,6 +530,45 @@ dependencies = [ "zbus", ] +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "astral-tokio-tar" version = "0.5.6" @@ -1000,7 +1039,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "tracing", - "uuid 1.18.1", + "uuid 1.23.3", ] [[package]] @@ -1369,6 +1408,17 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +[[package]] +name = "base64urlsafedata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b08e33815c87d8cadcddb1e74ac307368a3751fbe40c961538afa21a1899f21c" +dependencies = [ + "base64 0.21.7", + "pastey", + "serde", +] + [[package]] name = "bindgen" version = "0.72.1" @@ -1831,7 +1881,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" dependencies = [ "byteorder", "fnv", - "uuid 1.18.1", + "uuid 1.23.3", ] [[package]] @@ -1856,6 +1906,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chardetng" version = "0.1.17" @@ -2004,7 +2065,7 @@ dependencies = [ "time", "tokio", "url", - "uuid 1.18.1", + "uuid 1.23.3", ] [[package]] @@ -2332,6 +2393,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.3.0" @@ -2706,7 +2776,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" dependencies = [ "serde", - "uuid 1.18.1", + "uuid 1.23.3", ] [[package]] @@ -2726,6 +2796,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.4" @@ -3871,11 +3955,25 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasi 0.14.7+wasi-0.2.4", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + [[package]] name = "gif" version = "0.13.3" @@ -4720,6 +4818,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -5307,8 +5411,10 @@ dependencies = [ "utoipa", "utoipa-actix-web", "utoipa-scalar", - "uuid 1.18.1", + "uuid 1.23.3", "validator", + "webauthn-rs", + "webauthn-rs-proto", "webp", "woothee", "yaserde", @@ -5341,6 +5447,12 @@ dependencies = [ "spin 0.9.8", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "lebe" version = "0.5.3" @@ -5742,7 +5854,7 @@ dependencies = [ "thiserror 2.0.17", "time", "tokio", - "uuid 1.18.1", + "uuid 1.23.3", "wasm-bindgen-futures", "web-sys", "yaup", @@ -5882,7 +5994,7 @@ dependencies = [ "rustc_version", "smallvec", "tagptr", - "uuid 1.18.1", + "uuid 1.23.3", ] [[package]] @@ -5933,7 +6045,7 @@ dependencies = [ "serde_with", "strum", "utoipa", - "uuid 1.18.1", + "uuid 1.23.3", ] [[package]] @@ -6568,6 +6680,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -6598,15 +6719,14 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.73" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ "bitflags 2.9.4", "cfg-if", "foreign-types 0.3.2", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -6630,9 +6750,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-sys" -version = "0.9.109" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", @@ -6936,6 +7056,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "path-util" version = "0.0.0" @@ -7764,6 +7890,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "r2d2" version = "0.8.10" @@ -7816,6 +7948,17 @@ dependencies = [ "rand_core 0.9.3", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -7873,6 +8016,12 @@ dependencies = [ "getrandom 0.3.3", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_hc" version = "0.2.0" @@ -8269,7 +8418,7 @@ dependencies = [ "rkyv_derive", "seahash", "tinyvec", - "uuid 1.18.1", + "uuid 1.23.3", ] [[package]] @@ -8408,6 +8557,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "0.38.44" @@ -8652,7 +8810,7 @@ dependencies = [ "serde", "serde_json", "url", - "uuid 1.18.1", + "uuid 1.23.3", ] [[package]] @@ -8927,7 +9085,7 @@ dependencies = [ "thiserror 2.0.17", "time", "url", - "uuid 1.18.1", + "uuid 1.23.3", ] [[package]] @@ -8984,6 +9142,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_cbor_2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c" +dependencies = [ + "half 2.7.0", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -9230,7 +9398,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -9247,7 +9415,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -9503,7 +9671,7 @@ dependencies = [ "tokio-stream", "tracing", "url", - "uuid 1.18.1", + "uuid 1.23.3", "webpki-roots 0.26.11", ] @@ -9586,7 +9754,7 @@ dependencies = [ "stringprep", "thiserror 2.0.17", "tracing", - "uuid 1.18.1", + "uuid 1.23.3", "whoami", ] @@ -9626,7 +9794,7 @@ dependencies = [ "stringprep", "thiserror 2.0.17", "tracing", - "uuid 1.18.1", + "uuid 1.23.3", "whoami", ] @@ -9653,7 +9821,7 @@ dependencies = [ "thiserror 2.0.17", "tracing", "url", - "uuid 1.18.1", + "uuid 1.23.3", ] [[package]] @@ -10118,7 +10286,7 @@ dependencies = [ "thiserror 2.0.17", "time", "url", - "uuid 1.18.1", + "uuid 1.23.3", "walkdir", ] @@ -10426,7 +10594,7 @@ dependencies = [ "toml 0.9.8", "url", "urlpattern", - "uuid 1.18.1", + "uuid 1.23.3", "walkdir", ] @@ -10582,7 +10750,7 @@ dependencies = [ "tracing-error", "tracing-subscriber", "url", - "uuid 1.18.1", + "uuid 1.23.3", "whoami", "windows", "windows-core 0.61.2", @@ -10630,7 +10798,7 @@ dependencies = [ "tracing-error", "url", "urlencoding", - "uuid 1.18.1", + "uuid 1.23.3", "webview2-com", "windows", "windows-core 0.61.2", @@ -11119,9 +11287,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -11139,14 +11307,14 @@ dependencies = [ "mutually_exclusive_features", "pin-project", "tracing", - "uuid 1.18.1", + "uuid 1.23.3", ] [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -11155,9 +11323,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -11553,7 +11721,7 @@ dependencies = [ "regex", "syn 2.0.106", "url", - "uuid 1.18.1", + "uuid 1.23.3", ] [[package]] @@ -11579,14 +11747,14 @@ dependencies = [ [[package]] name = "uuid" -version = "1.18.1" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.4.2", "js-sys", - "rand 0.9.2", - "serde", + "rand 0.10.1", + "serde_core", "wasm-bindgen", ] @@ -11761,7 +11929,16 @@ version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.46.0", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] @@ -11829,6 +12006,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.11.4", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -11855,6 +12054,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.9.4", + "hashbrown 0.15.5", + "indexmap 2.11.4", + "semver", +] + [[package]] name = "wayland-backend" version = "0.3.11" @@ -11935,6 +12146,74 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webauthn-attestation-ca" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6475c0bbd1a3f04afaa3e98880408c5be61680c5e6bd3c6f8c250990d5d3e18e" +dependencies = [ + "base64urlsafedata", + "openssl", + "openssl-sys", + "serde", + "tracing", + "uuid 1.23.3", +] + +[[package]] +name = "webauthn-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c548915e0e92ee946bbf2aecf01ea21bef53d974b0793cc6732ba81a03fc422" +dependencies = [ + "base64urlsafedata", + "serde", + "tracing", + "url", + "uuid 1.23.3", + "webauthn-rs-core", +] + +[[package]] +name = "webauthn-rs-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "296d2d501feb715d80b8e186fb88bab1073bca17f460303a1013d17b673bea6a" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "der-parser", + "hex", + "nom 7.1.3", + "openssl", + "openssl-sys", + "rand 0.9.2", + "rand_chacha 0.9.0", + "serde", + "serde_cbor_2", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", + "uuid 1.23.3", + "webauthn-attestation-ca", + "webauthn-rs-proto", + "x509-parser", +] + +[[package]] +name = "webauthn-rs-proto" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c37393beac9c1ed1ca6dbb30b1e01783fb316ab3a45d90ecd48c99052dd7ef1e" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "serde", + "serde_json", + "url", +] + [[package]] name = "webkit2gtk" version = "2.0.2" @@ -12657,6 +12936,94 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.11.4", + "prettyplease", + "syn 2.0.106", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.106", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.9.4", + "indexmap 2.11.4", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.11.4", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "woothee" version = "0.13.0" @@ -12748,6 +13115,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "xattr" version = "1.6.1" diff --git a/Cargo.toml b/Cargo.toml index 7ce30a47f6..922bb0aeef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -219,6 +219,8 @@ utoipa-actix-web = { version = "0.1.2" } utoipa-scalar = { version = "0.3.0", default-features = false } uuid = "1.18.1" validator = "0.20.0" +webauthn-rs = "0.5.5" +webauthn-rs-proto = "0.5.5" webp = { version = "0.3.1", default-features = false } webview2-com = "0.38.0" # Should be updated in lockstep with wry whoami = "1.6.1" diff --git a/apps/frontend/src/components/ui/auth/PasskeySettings.vue b/apps/frontend/src/components/ui/auth/PasskeySettings.vue new file mode 100644 index 0000000000..79fe5771a1 --- /dev/null +++ b/apps/frontend/src/components/ui/auth/PasskeySettings.vue @@ -0,0 +1,422 @@ + + + + + diff --git a/apps/frontend/src/components/ui/auth/SignIn.vue b/apps/frontend/src/components/ui/auth/SignIn.vue index ffb3414653..43f2d47a2f 100644 --- a/apps/frontend/src/components/ui/auth/SignIn.vue +++ b/apps/frontend/src/components/ui/auth/SignIn.vue @@ -153,6 +153,25 @@ + + + + {{ formatMessage(messages.continueWithPasskey) }} + + +
@@ -235,6 +254,7 @@ import { MicrosoftColorIcon, RightArrowIcon, SteamColorIcon, + UserKeyIcon, } from '@modrinth/assets' import { ButtonStyled, commonMessages, defineMessages, StyledInput, useVIntl } from '@modrinth/ui' import { useStorage } from '@vueuse/core' @@ -248,7 +268,7 @@ import { PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY, } from '@/composables/auth.ts' -type AuthProvider = 'discord' | 'google' | 'github' | 'gitlab' | 'steam' | 'microsoft' +type AuthProvider = 'discord' | 'google' | 'github' | 'gitlab' | 'steam' | 'microsoft' | 'passkey' interface AuthGlobals { captcha_enabled?: boolean @@ -263,6 +283,7 @@ interface Props { globals?: AuthGlobals | null onPasswordSignIn?: () => void onTwoFactorSignIn?: () => void + onPasskeySignIn?: () => void onSetCaptchaRef?: ((captchaRef: unknown) => void) | undefined } @@ -274,6 +295,7 @@ const { globals = null, onPasswordSignIn = () => {}, onTwoFactorSignIn = () => {}, + onPasskeySignIn = () => {}, onSetCaptchaRef = undefined, } = defineProps() @@ -343,6 +365,10 @@ const messages = defineMessages({ id: 'auth.sign-in.last-sign-in', defaultMessage: 'Last sign in', }, + continueWithPasskey: { + id: 'auth.sign-in.continue-with-passkey', + defaultMessage: 'Continue with passkey', + }, }) diff --git a/apps/frontend/src/helpers/passkey.ts b/apps/frontend/src/helpers/passkey.ts new file mode 100644 index 0000000000..746e6638aa --- /dev/null +++ b/apps/frontend/src/helpers/passkey.ts @@ -0,0 +1,95 @@ +function ensurePasskeySupported() { + const supported = + typeof window !== 'undefined' && + typeof window.PublicKeyCredential !== 'undefined' && + typeof navigator !== 'undefined' && + !!navigator.credentials + if (!supported) { + throw new Error('Passkeys are not supported by this browser.') + } +} + +function base64urlToBuffer(base64url: string) { + return Uint8Array.from(atob(base64url.replace(/-/g, '+').replace(/_/g, '/')), (char) => + char.charCodeAt(0), + ) +} + +function bufferToBase64url(buffer: ArrayBuffer) { + const bytes = new Uint8Array(buffer) + let str = '' + for (let i = 0; i < bytes.length; i++) { + str += String.fromCharCode(bytes[i]) + } + return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +/** + * Creates a passkey credential using the browser's WebAuthn API. + * + * @param options The public key options for creating the passkey credential, provided by the server. + */ +export async function createPasskeyCredential(options: any) { + ensurePasskeySupported() + + const publicKey = { + ...options, + challenge: base64urlToBuffer(options.challenge), + user: { + ...options.user, + id: base64urlToBuffer(options.user.id), + }, + excludeCredentials: options.excludeCredentials?.map((cred: any) => ({ + ...cred, + id: base64urlToBuffer(cred.id), + })), + } + + const credential = (await navigator.credentials.create({ publicKey })) as PublicKeyCredential + const response = credential.response as AuthenticatorAttestationResponse + + return { + id: credential.id, + rawId: bufferToBase64url(credential.rawId), + type: credential.type, + response: { + clientDataJSON: bufferToBase64url(response.clientDataJSON), + attestationObject: bufferToBase64url(response.attestationObject), + }, + extensions: credential.getClientExtensionResults(), + } +} + +/** + * Authenticates a user using a passkey credential. + * + * @param options The public key options for authenticating the passkey credential, provided by the server. + */ +export async function getPasskeyCredential(options: any) { + ensurePasskeySupported() + + const publicKey = { + ...options, + challenge: base64urlToBuffer(options.challenge), + allowCredentials: options.allowCredentials?.map((cred: any) => ({ + ...cred, + id: base64urlToBuffer(cred.id), + })), + } + + const credential = (await navigator.credentials.get({ publicKey })) as PublicKeyCredential + const response = credential.response as AuthenticatorAssertionResponse + + return { + id: credential.id, + rawId: bufferToBase64url(credential.rawId), + type: credential.type, + response: { + clientDataJSON: bufferToBase64url(response.clientDataJSON), + authenticatorData: bufferToBase64url(response.authenticatorData), + signature: bufferToBase64url(response.signature), + userHandle: response.userHandle ? bufferToBase64url(response.userHandle) : null, + }, + extensions: credential.getClientExtensionResults(), + } +} diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json index ce0c295fc8..a8ac6df2ab 100644 --- a/apps/frontend/src/locales/en-US/index.json +++ b/apps/frontend/src/locales/en-US/index.json @@ -815,6 +815,9 @@ "auth.sign-in.continue-with-email": { "message": "Continue with Email" }, + "auth.sign-in.continue-with-passkey": { + "message": "Continue with passkey" + }, "auth.sign-in.create-account": { "message": "Sign up" }, @@ -4043,6 +4046,48 @@ "settings.account.security.email.title": { "message": "Email" }, + "settings.account.security.passkey.add": { + "message": "Add passkey" + }, + "settings.account.security.passkey.add-modal.name.description": { + "message": "Make sure to pick something memorable, so you can identify this passkey later." + }, + "settings.account.security.passkey.add-modal.name.label": { + "message": "Name" + }, + "settings.account.security.passkey.add-modal.name.placeholder": { + "message": "My passkey" + }, + "settings.account.security.passkey.description": { + "message": "Manage your registered passkeys, or add a new one." + }, + "settings.account.security.passkey.modal.added": { + "message": "Added {ago}" + }, + "settings.account.security.passkey.modal.last-used": { + "message": "Last used {ago}" + }, + "settings.account.security.passkey.modal.loading": { + "message": "Loading passkeys…" + }, + "settings.account.security.passkey.modal.never-used": { + "message": "Never used" + }, + "settings.account.security.passkey.modal.no-passkeys": { + "message": "You have no passkeys registered yet." + }, + "settings.account.security.passkey.remove.description": { + "message": "This will permanently remove the passkey \"{name}\". You will no longer be able to sign in with it." + }, + "settings.account.security.passkey.remove.title": { + "message": "Are you sure you want to remove this passkey?" + }, + "settings.account.security.passkey.rename-modal.header": { + "message": "Rename passkey" + }, + "settings.account.security.passkey.title": { + "message": "Manage passkeys" + }, "settings.account.security.password.action.add": { "message": "Add password" }, diff --git a/apps/frontend/src/pages/auth/sign-in.vue b/apps/frontend/src/pages/auth/sign-in.vue index 03b6371d0e..adb3e4f488 100644 --- a/apps/frontend/src/pages/auth/sign-in.vue +++ b/apps/frontend/src/pages/auth/sign-in.vue @@ -11,6 +11,7 @@ :globals="globals" :on-password-sign-in="beginPasswordSignIn" :on-two-factor-sign-in="begin2FASignIn" + :on-passkey-sign-in="beginPasskeySignin" :on-set-captcha-ref="setCaptchaRef" /> @@ -34,8 +35,9 @@ import { PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY, promotePendingSignInOAuthProvider, } from '@/composables/auth.ts' +import { getPasskeyCredential } from '@/helpers/passkey.ts' -type AuthProvider = 'discord' | 'google' | 'github' | 'gitlab' | 'steam' | 'microsoft' +type AuthProvider = 'discord' | 'google' | 'github' | 'gitlab' | 'steam' | 'microsoft' | 'passkey' interface AuthGlobalsResponse { captcha_enabled?: boolean @@ -193,6 +195,30 @@ async function begin2FASignIn() { stopLoading() } +async function beginPasskeySignin() { + startLoading() + try { + const start = await client.labrinth.auth_v2.authenticatePasskeyStart() + + const credential = await getPasskeyCredential(start.options.publicKey) + + const result = await client.labrinth.auth_v2.authenticatePasskeyFinish({ + flow: start.flow, + credential, + }) + + pendingSignInOAuthProvider.value = 'passkey' + await finishSignIn(result.session) + } catch (err) { + addNotification({ + title: formatMessage(commonMessages.errorNotificationTitle), + text: getErrorMessage(err), + type: 'error', + }) + } + stopLoading() +} + async function finishSignIn(sessionToken?: string | null) { if (route.query.launcher) { let token = sessionToken diff --git a/apps/frontend/src/pages/settings/account.vue b/apps/frontend/src/pages/settings/account.vue index 702e1b7f84..76ae58b229 100644 --- a/apps/frontend/src/pages/settings/account.vue +++ b/apps/frontend/src/pages/settings/account.vue @@ -440,6 +440,7 @@ +
@@ -513,6 +514,7 @@ import MicrosoftIcon from 'assets/icons/auth/sso-microsoft.svg' import SteamIcon from 'assets/icons/auth/sso-steam.svg' import QrcodeVue from 'qrcode.vue' +import PasskeySettings from '~/components/ui/auth/PasskeySettings.vue' import { getAuthUrl, removeAuthProvider } from '~/composables/auth.ts' definePageMeta({ diff --git a/apps/labrinth/.env.docker-compose b/apps/labrinth/.env.docker-compose index 045cfd800c..f799177874 100644 --- a/apps/labrinth/.env.docker-compose +++ b/apps/labrinth/.env.docker-compose @@ -170,3 +170,6 @@ MURALPAY_SOURCE_ACCOUNT_ID=00000000-0000-0000-0000-000000000000 DEFAULT_AFFILIATE_REVENUE_SPLIT=0.1 SERVER_PING_TIMEOUT=10000 SERVER_PING_RETRIES=3 + +# Display name for Webauthn Authenticators +WEBAUTHN_RP_NAME=Modrinth diff --git a/apps/labrinth/.env.local b/apps/labrinth/.env.local index 19a7d95287..dfd8ed7487 100644 --- a/apps/labrinth/.env.local +++ b/apps/labrinth/.env.local @@ -192,3 +192,6 @@ DEFAULT_AFFILIATE_REVENUE_SPLIT=0.1 SERVER_PING_TIMEOUT=10000 SERVER_PING_RETRIES=3 SERVER_PING_MIN_INTERVAL_SEC=1800 + +# Display name for Webauthn Authenticators +WEBAUTHN_RP_NAME=Modrinth diff --git a/apps/labrinth/.sqlx/query-05d26562a95715d65bbb2fd1c4163ebb067931f4c3caeb93601c98f1d533983b.json b/apps/labrinth/.sqlx/query-05d26562a95715d65bbb2fd1c4163ebb067931f4c3caeb93601c98f1d533983b.json new file mode 100644 index 0000000000..106e54f7af --- /dev/null +++ b/apps/labrinth/.sqlx/query-05d26562a95715d65bbb2fd1c4163ebb067931f4c3caeb93601c98f1d533983b.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, user_id, name, credential_id,\n passkey AS \"passkey: sqlx::types::Json\",\n last_used, created_at\n FROM user_passkeys\n WHERE credential_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "credential_id", + "type_info": "Bytea" + }, + { + "ordinal": 4, + "name": "passkey: sqlx::types::Json", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "last_used", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Bytea" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false + ] + }, + "hash": "05d26562a95715d65bbb2fd1c4163ebb067931f4c3caeb93601c98f1d533983b" +} diff --git a/apps/labrinth/.sqlx/query-0f3d943e4fc48a94363b77c8a7d36eb1dd626e77331d8278c406df952691be4c.json b/apps/labrinth/.sqlx/query-0f3d943e4fc48a94363b77c8a7d36eb1dd626e77331d8278c406df952691be4c.json deleted file mode 100644 index 4bc87e73a5..0000000000 --- a/apps/labrinth/.sqlx/query-0f3d943e4fc48a94363b77c8a7d36eb1dd626e77331d8278c406df952691be4c.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT COUNT(*) FROM payouts_values_notifications WHERE notified = FALSE AND user_id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "count", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - null - ] - }, - "hash": "0f3d943e4fc48a94363b77c8a7d36eb1dd626e77331d8278c406df952691be4c" -} diff --git a/apps/labrinth/.sqlx/query-1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286.json b/apps/labrinth/.sqlx/query-1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286.json deleted file mode 100644 index 921f7f92d9..0000000000 --- a/apps/labrinth/.sqlx/query-1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n id,\n status AS \"status: PayoutStatus\"\n FROM payouts\n ORDER BY id\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "status: PayoutStatus", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false - ] - }, - "hash": "1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286" -} diff --git a/apps/labrinth/.sqlx/query-20cff8fdf7971e91c9d473b9a4663ce02ca16781e32232ae0fa7a0af1973d3a4.json b/apps/labrinth/.sqlx/query-20cff8fdf7971e91c9d473b9a4663ce02ca16781e32232ae0fa7a0af1973d3a4.json deleted file mode 100644 index 3c99ff3fed..0000000000 --- a/apps/labrinth/.sqlx/query-20cff8fdf7971e91c9d473b9a4663ce02ca16781e32232ae0fa7a0af1973d3a4.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT COUNT(*) FROM payouts_values_notifications WHERE notified = FALSE", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "count", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - null - ] - }, - "hash": "20cff8fdf7971e91c9d473b9a4663ce02ca16781e32232ae0fa7a0af1973d3a4" -} diff --git a/apps/labrinth/.sqlx/query-29fc17743ca5cf06fcea50ebc477576b2fd7fe535afee2f3136d6b153bbf4129.json b/apps/labrinth/.sqlx/query-29fc17743ca5cf06fcea50ebc477576b2fd7fe535afee2f3136d6b153bbf4129.json new file mode 100644 index 0000000000..f42f890f09 --- /dev/null +++ b/apps/labrinth/.sqlx/query-29fc17743ca5cf06fcea50ebc477576b2fd7fe535afee2f3136d6b153bbf4129.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_passkeys SET name = $1\n WHERE id = $2 AND user_id = $3\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "29fc17743ca5cf06fcea50ebc477576b2fd7fe535afee2f3136d6b153bbf4129" +} diff --git a/apps/labrinth/.sqlx/query-31c1a2872410a5834e9995ad73cdc1cdeda4f3386e385dd66bec5be2aaa75b5b.json b/apps/labrinth/.sqlx/query-31c1a2872410a5834e9995ad73cdc1cdeda4f3386e385dd66bec5be2aaa75b5b.json new file mode 100644 index 0000000000..b8b7a22428 --- /dev/null +++ b/apps/labrinth/.sqlx/query-31c1a2872410a5834e9995ad73cdc1cdeda4f3386e385dd66bec5be2aaa75b5b.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM sessions WHERE user_id = $1 RETURNING id, session\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "session", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "31c1a2872410a5834e9995ad73cdc1cdeda4f3386e385dd66bec5be2aaa75b5b" +} diff --git a/apps/labrinth/.sqlx/query-cc2056c368e11fcd820dfd6951d4fa2dd26da8813d4b0dbaa6bd66f7060f1e76.json b/apps/labrinth/.sqlx/query-3ca51012492b969bb6a474ee22c9e53e57b6da3a1145f0d86a2bab36be436eb6.json similarity index 75% rename from apps/labrinth/.sqlx/query-cc2056c368e11fcd820dfd6951d4fa2dd26da8813d4b0dbaa6bd66f7060f1e76.json rename to apps/labrinth/.sqlx/query-3ca51012492b969bb6a474ee22c9e53e57b6da3a1145f0d86a2bab36be436eb6.json index 96ca5abb6f..506d54fda7 100644 --- a/apps/labrinth/.sqlx/query-cc2056c368e11fcd820dfd6951d4fa2dd26da8813d4b0dbaa6bd66f7060f1e76.json +++ b/apps/labrinth/.sqlx/query-3ca51012492b969bb6a474ee22c9e53e57b6da3a1145f0d86a2bab36be436eb6.json @@ -1,10 +1,6 @@ { "db_name": "PostgreSQL", -<<<<<<<< HEAD:apps/labrinth/.sqlx/query-cc2056c368e11fcd820dfd6951d4fa2dd26da8813d4b0dbaa6bd66f7060f1e76.json - "query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter,\n eligibility_verified_at\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ", -======== - "query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n (\n SELECT MAX(campaign_donations.donated_at)\n FROM campaign_donations\n WHERE campaign_donations.user_id = users.id\n ) AS campaign_pride_26_last_donated_at,\n (\n SELECT SUM(campaign_donations.amount_usd)\n FROM campaign_donations\n WHERE campaign_donations.user_id = users.id\n ) AS campaign_pride_26_total_amount_donated_usd,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ", ->>>>>>>> main:apps/labrinth/.sqlx/query-e6c22fe10d603206c8466da630b30d0d4848455f5cddbf9202d9cdbfa1f306b5.json + "query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n (\n SELECT MAX(campaign_donations.donated_at)\n FROM campaign_donations\n WHERE campaign_donations.user_id = users.id\n ) AS campaign_pride_26_last_donated_at,\n (\n SELECT SUM(campaign_donations.amount_usd)\n FROM campaign_donations\n WHERE campaign_donations.user_id = users.id\n ) AS campaign_pride_26_total_amount_donated_usd,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter,\n eligibility_verified_at\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ", "describe": { "columns": [ { @@ -143,7 +139,7 @@ "type_info": "Bool" }, { - "ordinal": 25, + "ordinal": 27, "name": "eligibility_verified_at", "type_info": "Timestamptz" } @@ -185,9 +181,5 @@ true ] }, -<<<<<<<< HEAD:apps/labrinth/.sqlx/query-cc2056c368e11fcd820dfd6951d4fa2dd26da8813d4b0dbaa6bd66f7060f1e76.json - "hash": "cc2056c368e11fcd820dfd6951d4fa2dd26da8813d4b0dbaa6bd66f7060f1e76" -======== - "hash": "e6c22fe10d603206c8466da630b30d0d4848455f5cddbf9202d9cdbfa1f306b5" ->>>>>>>> main:apps/labrinth/.sqlx/query-e6c22fe10d603206c8466da630b30d0d4848455f5cddbf9202d9cdbfa1f306b5.json + "hash": "3ca51012492b969bb6a474ee22c9e53e57b6da3a1145f0d86a2bab36be436eb6" } diff --git a/apps/labrinth/.sqlx/query-42a9ddd851497b7a340e00b8289aa56692577571ec6d21131b4a31f55d37b98e.json b/apps/labrinth/.sqlx/query-42a9ddd851497b7a340e00b8289aa56692577571ec6d21131b4a31f55d37b98e.json new file mode 100644 index 0000000000..44d2b50c59 --- /dev/null +++ b/apps/labrinth/.sqlx/query-42a9ddd851497b7a340e00b8289aa56692577571ec6d21131b4a31f55d37b98e.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM user_passkeys\n WHERE id = $1 AND user_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "42a9ddd851497b7a340e00b8289aa56692577571ec6d21131b4a31f55d37b98e" +} diff --git a/apps/labrinth/.sqlx/query-538bfc1694ce1d177ea20f269353922e836d2dfd9eb447e278f38266d13c8e73.json b/apps/labrinth/.sqlx/query-538bfc1694ce1d177ea20f269353922e836d2dfd9eb447e278f38266d13c8e73.json new file mode 100644 index 0000000000..c990bb2864 --- /dev/null +++ b/apps/labrinth/.sqlx/query-538bfc1694ce1d177ea20f269353922e836d2dfd9eb447e278f38266d13c8e73.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_passkeys\n SET passkey = $1, last_used = NOW()\n WHERE id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "538bfc1694ce1d177ea20f269353922e836d2dfd9eb447e278f38266d13c8e73" +} diff --git a/apps/labrinth/.sqlx/query-6678cd4c51771cfaae2be8021ba66908ea41a06ba858dc5b523aef6aae27b850.json b/apps/labrinth/.sqlx/query-6678cd4c51771cfaae2be8021ba66908ea41a06ba858dc5b523aef6aae27b850.json deleted file mode 100644 index b4c2e5a56e..0000000000 --- a/apps/labrinth/.sqlx/query-6678cd4c51771cfaae2be8021ba66908ea41a06ba858dc5b523aef6aae27b850.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO payouts_values_notifications (date_available, user_id, notified)\n VALUES ($1, $2, FALSE)\n ON CONFLICT (date_available, user_id) DO NOTHING", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Timestamptz", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "6678cd4c51771cfaae2be8021ba66908ea41a06ba858dc5b523aef6aae27b850" -} diff --git a/apps/labrinth/.sqlx/query-69a1cb4b7f1115a990d1fc4805d58541fc78e910111c09ba3d50a12d9ca4a9f8.json b/apps/labrinth/.sqlx/query-69a1cb4b7f1115a990d1fc4805d58541fc78e910111c09ba3d50a12d9ca4a9f8.json deleted file mode 100644 index fc7d2ac98d..0000000000 --- a/apps/labrinth/.sqlx/query-69a1cb4b7f1115a990d1fc4805d58541fc78e910111c09ba3d50a12d9ca4a9f8.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO payouts_values (user_id, mod_id, amount, created, date_available)\n VALUES ($1, NULL, $2, NOW(), $3)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Numeric", - "Timestamptz" - ] - }, - "nullable": [] - }, - "hash": "69a1cb4b7f1115a990d1fc4805d58541fc78e910111c09ba3d50a12d9ca4a9f8" -} diff --git a/apps/labrinth/.sqlx/query-7de293b153f075b8e44bf8ec8eee0fef0bc06a8aafd844c38aff3dcbf63e1bc9.json b/apps/labrinth/.sqlx/query-7de293b153f075b8e44bf8ec8eee0fef0bc06a8aafd844c38aff3dcbf63e1bc9.json new file mode 100644 index 0000000000..b0e65451e3 --- /dev/null +++ b/apps/labrinth/.sqlx/query-7de293b153f075b8e44bf8ec8eee0fef0bc06a8aafd844c38aff3dcbf63e1bc9.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_passkeys (\n id, user_id, name, credential_id, passkey, created_at, last_used\n )\n VALUES (\n $1, $2 ,$3, $4, $5, $6, $7\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Varchar", + "Bytea", + "Jsonb", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "7de293b153f075b8e44bf8ec8eee0fef0bc06a8aafd844c38aff3dcbf63e1bc9" +} diff --git a/apps/labrinth/.sqlx/query-806c5ed76a076bfd060fca40aa4cff8823503afefd68c2a2a7a6b879dfdaea2d.json b/apps/labrinth/.sqlx/query-806c5ed76a076bfd060fca40aa4cff8823503afefd68c2a2a7a6b879dfdaea2d.json new file mode 100644 index 0000000000..a5eb0420a0 --- /dev/null +++ b/apps/labrinth/.sqlx/query-806c5ed76a076bfd060fca40aa4cff8823503afefd68c2a2a7a6b879dfdaea2d.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, user_id, name, credential_id,\n passkey AS \"passkey: sqlx::types::Json\",\n last_used, created_at\n FROM user_passkeys\n WHERE user_id = $1\n ORDER BY created_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "credential_id", + "type_info": "Bytea" + }, + { + "ordinal": 4, + "name": "passkey: sqlx::types::Json", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "last_used", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false + ] + }, + "hash": "806c5ed76a076bfd060fca40aa4cff8823503afefd68c2a2a7a6b879dfdaea2d" +} diff --git a/apps/labrinth/.sqlx/query-9851b2891716958cb2e0eb8f2deccd25d6f36dfc03b69e83079bfce0bf2030fb.json b/apps/labrinth/.sqlx/query-9851b2891716958cb2e0eb8f2deccd25d6f36dfc03b69e83079bfce0bf2030fb.json new file mode 100644 index 0000000000..ae5395b6c8 --- /dev/null +++ b/apps/labrinth/.sqlx/query-9851b2891716958cb2e0eb8f2deccd25d6f36dfc03b69e83079bfce0bf2030fb.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM user_passkeys WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "9851b2891716958cb2e0eb8f2deccd25d6f36dfc03b69e83079bfce0bf2030fb" +} diff --git a/apps/labrinth/.sqlx/query-b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3.json b/apps/labrinth/.sqlx/query-b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3.json deleted file mode 100644 index 89bd8147dc..0000000000 --- a/apps/labrinth/.sqlx/query-b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT status AS \"status: PayoutStatus\" FROM payouts WHERE id = 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "status: PayoutStatus", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false - ] - }, - "hash": "b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3" -} diff --git a/apps/labrinth/.sqlx/query-cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02.json b/apps/labrinth/.sqlx/query-cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02.json deleted file mode 100644 index 469c30168a..0000000000 --- a/apps/labrinth/.sqlx/query-cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO payouts (id, method, platform_id, status, user_id, amount, created)\n VALUES ($1, $2, $3, $4, $5, 10.0, NOW())\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Text", - "Text", - "Varchar", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02" -} diff --git a/apps/labrinth/.sqlx/query-cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606.json b/apps/labrinth/.sqlx/query-cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606.json deleted file mode 100644 index 52e020ebf2..0000000000 --- a/apps/labrinth/.sqlx/query-cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO payouts (id, method, platform_id, status, user_id, amount, created)\n VALUES ($1, $2, NULL, $3, $4, 10.00, NOW())\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Text", - "Varchar", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606" -} diff --git a/apps/labrinth/.sqlx/query-ddef9fee29f75736494b196a10dfe363a86e42417a047a6ed80f2c62811d5c2d.json b/apps/labrinth/.sqlx/query-ddef9fee29f75736494b196a10dfe363a86e42417a047a6ed80f2c62811d5c2d.json new file mode 100644 index 0000000000..f2f563deb2 --- /dev/null +++ b/apps/labrinth/.sqlx/query-ddef9fee29f75736494b196a10dfe363a86e42417a047a6ed80f2c62811d5c2d.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM user_passkeys\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "ddef9fee29f75736494b196a10dfe363a86e42417a047a6ed80f2c62811d5c2d" +} diff --git a/apps/labrinth/.sqlx/query-e6c22fe10d603206c8466da630b30d0d4848455f5cddbf9202d9cdbfa1f306b5.json b/apps/labrinth/.sqlx/query-e6c22fe10d603206c8466da630b30d0d4848455f5cddbf9202d9cdbfa1f306b5.json deleted file mode 100644 index 96ca5abb6f..0000000000 --- a/apps/labrinth/.sqlx/query-e6c22fe10d603206c8466da630b30d0d4848455f5cddbf9202d9cdbfa1f306b5.json +++ /dev/null @@ -1,193 +0,0 @@ -{ - "db_name": "PostgreSQL", -<<<<<<<< HEAD:apps/labrinth/.sqlx/query-cc2056c368e11fcd820dfd6951d4fa2dd26da8813d4b0dbaa6bd66f7060f1e76.json - "query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter,\n eligibility_verified_at\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ", -======== - "query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n (\n SELECT MAX(campaign_donations.donated_at)\n FROM campaign_donations\n WHERE campaign_donations.user_id = users.id\n ) AS campaign_pride_26_last_donated_at,\n (\n SELECT SUM(campaign_donations.amount_usd)\n FROM campaign_donations\n WHERE campaign_donations.user_id = users.id\n ) AS campaign_pride_26_total_amount_donated_usd,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ", ->>>>>>>> main:apps/labrinth/.sqlx/query-e6c22fe10d603206c8466da630b30d0d4848455f5cddbf9202d9cdbfa1f306b5.json - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "email", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "avatar_url", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "raw_avatar_url", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "username", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "bio", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "created", - "type_info": "Timestamptz" - }, - { - "ordinal": 7, - "name": "role", - "type_info": "Varchar" - }, - { - "ordinal": 8, - "name": "badges", - "type_info": "Int8" - }, - { - "ordinal": 9, - "name": "campaign_pride_26_last_donated_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 10, - "name": "campaign_pride_26_total_amount_donated_usd", - "type_info": "Numeric" - }, - { - "ordinal": 11, - "name": "github_id", - "type_info": "Int8" - }, - { - "ordinal": 12, - "name": "discord_id", - "type_info": "Int8" - }, - { - "ordinal": 13, - "name": "gitlab_id", - "type_info": "Int8" - }, - { - "ordinal": 14, - "name": "google_id", - "type_info": "Varchar" - }, - { - "ordinal": 15, - "name": "steam_id", - "type_info": "Int8" - }, - { - "ordinal": 16, - "name": "microsoft_id", - "type_info": "Varchar" - }, - { - "ordinal": 17, - "name": "email_verified", - "type_info": "Bool" - }, - { - "ordinal": 18, - "name": "password", - "type_info": "Text" - }, - { - "ordinal": 19, - "name": "totp_secret", - "type_info": "Varchar" - }, - { - "ordinal": 20, - "name": "paypal_id", - "type_info": "Text" - }, - { - "ordinal": 21, - "name": "paypal_country", - "type_info": "Text" - }, - { - "ordinal": 22, - "name": "paypal_email", - "type_info": "Text" - }, - { - "ordinal": 23, - "name": "venmo_handle", - "type_info": "Text" - }, - { - "ordinal": 24, - "name": "stripe_customer_id", - "type_info": "Text" - }, - { - "ordinal": 25, - "name": "allow_friend_requests", - "type_info": "Bool" - }, - { - "ordinal": 26, - "name": "is_subscribed_to_newsletter", - "type_info": "Bool" - }, - { - "ordinal": 25, - "name": "eligibility_verified_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Int8Array", - "TextArray" - ] - }, - "nullable": [ - false, - true, - true, - true, - false, - true, - false, - false, - false, - null, - null, - true, - true, - true, - true, - true, - true, - false, - true, - true, - true, - true, - true, - true, - true, - false, - false, - true - ] - }, -<<<<<<<< HEAD:apps/labrinth/.sqlx/query-cc2056c368e11fcd820dfd6951d4fa2dd26da8813d4b0dbaa6bd66f7060f1e76.json - "hash": "cc2056c368e11fcd820dfd6951d4fa2dd26da8813d4b0dbaa6bd66f7060f1e76" -======== - "hash": "e6c22fe10d603206c8466da630b30d0d4848455f5cddbf9202d9cdbfa1f306b5" ->>>>>>>> main:apps/labrinth/.sqlx/query-e6c22fe10d603206c8466da630b30d0d4848455f5cddbf9202d9cdbfa1f306b5.json -} diff --git a/apps/labrinth/.sqlx/query-fd5c773a61d35bcd71503ec4d5f86e8917cfab9679d5064074681663ba467e41.json b/apps/labrinth/.sqlx/query-fd5c773a61d35bcd71503ec4d5f86e8917cfab9679d5064074681663ba467e41.json deleted file mode 100644 index d3e3520bcc..0000000000 --- a/apps/labrinth/.sqlx/query-fd5c773a61d35bcd71503ec4d5f86e8917cfab9679d5064074681663ba467e41.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND body->>'type' = 'payout_available'", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "count", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - null - ] - }, - "hash": "fd5c773a61d35bcd71503ec4d5f86e8917cfab9679d5064074681663ba467e41" -} diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index 9d99dc5007..e0294a98bb 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -130,6 +130,8 @@ utoipa-actix-web = { workspace = true } utoipa-scalar = { workspace = true, features = ["actix-web"] } uuid = { workspace = true, features = ["fast-rng", "serde", "v4"] } validator = { workspace = true, features = ["derive"] } +webauthn-rs = { workspace = true, features = ["danger-allow-state-serialisation", "conditional-ui"] } +webauthn-rs-proto = { workspace = true } webp = { workspace = true } woothee = { workspace = true } yaserde = { workspace = true, features = ["derive"] } diff --git a/apps/labrinth/migrations/20260610162635_passkeys.sql b/apps/labrinth/migrations/20260610162635_passkeys.sql new file mode 100644 index 0000000000..9e798b3bcf --- /dev/null +++ b/apps/labrinth/migrations/20260610162635_passkeys.sql @@ -0,0 +1,11 @@ +CREATE TABLE user_passkeys ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + credential_id BYTEA NOT NULL UNIQUE, + passkey JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used TIMESTAMPTZ +); + +CREATE INDEX user_passkeys_user_id ON user_passkeys (user_id); diff --git a/apps/labrinth/src/database/models/flow_item.rs b/apps/labrinth/src/database/models/flow_item.rs index a000057f6e..2bc8dd864d 100644 --- a/apps/labrinth/src/database/models/flow_item.rs +++ b/apps/labrinth/src/database/models/flow_item.rs @@ -11,6 +11,7 @@ use rand_chacha::ChaCha20Rng; use rand_chacha::rand_core::SeedableRng; use serde::{Deserialize, Serialize}; use url::Url; +use webauthn_rs::prelude::{DiscoverableAuthentication, PasskeyRegistration}; const FLOWS_NAMESPACE: &str = "flows"; @@ -58,6 +59,13 @@ pub enum DBFlow { scopes: Scopes, original_redirect_uri: Option, // Needed for https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 }, + RegisterPasskey { + user_id: DBUserId, + state: PasskeyRegistration, + }, + AuthenticatePasskey { + state: DiscoverableAuthentication, + }, } impl DBFlow { diff --git a/apps/labrinth/src/database/models/ids.rs b/apps/labrinth/src/database/models/ids.rs index 1ebb09b27d..5fe435a37a 100644 --- a/apps/labrinth/src/database/models/ids.rs +++ b/apps/labrinth/src/database/models/ids.rs @@ -4,9 +4,10 @@ use crate::models::ids::{ AffiliateCodeId, AnalyticsEventId, CampaignDonationId, ChargeId, CollectionId, FileId, ImageId, NotificationId, OAuthAccessTokenId, OAuthClientAuthorizationId, OAuthClientId, OAuthRedirectUriId, - OrganizationId, PatId, PayoutId, ProductId, ProductPriceId, ProjectId, - ReportId, SessionId, SharedInstanceId, SharedInstanceVersionId, TeamId, - TeamMemberId, ThreadId, ThreadMessageId, UserSubscriptionId, VersionId, + OrganizationId, PasskeyId, PatId, PayoutId, ProductId, ProductPriceId, + ProjectId, ReportId, SessionId, SharedInstanceId, SharedInstanceVersionId, + TeamId, TeamMemberId, ThreadId, ThreadMessageId, UserSubscriptionId, + VersionId, }; use ariadne::ids::base62_impl::to_base62; use ariadne::ids::{UserId, random_base62_rng, random_base62_rng_range}; @@ -277,6 +278,10 @@ db_id_interface!( AnalyticsEventId, generator: generate_analytics_event_id @ "analytics_events", ); +db_id_interface!( + PasskeyId, + generator: generate_passkey_id @ "user_passkeys", +); id_type!(CategoryId as i32); id_type!(GameId as i32); diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index f07bc6a135..a499f33d2f 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -23,6 +23,7 @@ pub mod oauth_client_authorization_item; pub mod oauth_client_item; pub mod oauth_token_item; pub mod organization_item; +pub mod passkey_item; pub mod pat_item; pub mod payout_item; pub mod payouts_values_notifications; @@ -51,6 +52,7 @@ pub use ids::*; pub use image_item::DBImage; pub use oauth_client_item::DBOAuthClient; pub use organization_item::DBOrganization; +pub use passkey_item::DBPasskey; pub use project_item::DBProject; pub use team_item::DBTeam; pub use team_item::DBTeamMember; diff --git a/apps/labrinth/src/database/models/passkey_item.rs b/apps/labrinth/src/database/models/passkey_item.rs new file mode 100644 index 0000000000..063e0724f4 --- /dev/null +++ b/apps/labrinth/src/database/models/passkey_item.rs @@ -0,0 +1,191 @@ +use super::ids::*; +use crate::database::PgTransaction; +use crate::database::models::DatabaseError; +use chrono::{DateTime, Utc}; +use futures::TryStreamExt; +use serde::{Deserialize, Serialize}; +use sqlx::types::Json; +use webauthn_rs::prelude::Passkey; + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct DBPasskey { + pub id: DBPasskeyId, + pub user_id: DBUserId, + pub name: String, + pub credential_id: Vec, + pub passkey: Passkey, + pub created_at: DateTime, + pub last_used: Option>, +} + +impl DBPasskey { + pub async fn insert( + &self, + transaction: &mut PgTransaction<'_>, + ) -> Result<(), DatabaseError> { + sqlx::query!( + " + INSERT INTO user_passkeys ( + id, user_id, name, credential_id, passkey, created_at, last_used + ) + VALUES ( + $1, $2 ,$3, $4, $5, $6, $7 + ) + ", + self.id as DBPasskeyId, + self.user_id as DBUserId, + self.name, + self.credential_id, + Json(&self.passkey) as _, + self.created_at, + self.last_used, + ) + .execute(&mut *transaction) + .await?; + + Ok(()) + } + + pub async fn get_by_credential_id<'a, E>( + credential_id: &[u8], + exec: E, + ) -> Result, DatabaseError> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + let row = sqlx::query!( + r#" + SELECT id, user_id, name, credential_id, + passkey AS "passkey: sqlx::types::Json", + last_used, created_at + FROM user_passkeys + WHERE credential_id = $1 + "#, + credential_id, + ) + .fetch_optional(exec) + .await? + .map(|x| DBPasskey { + id: DBPasskeyId(x.id), + user_id: DBUserId(x.user_id), + name: x.name, + credential_id: x.credential_id, + passkey: x.passkey.0, + created_at: x.created_at, + last_used: x.last_used, + }); + + Ok(row) + } + + pub async fn get_for_user<'a, E>( + user_id: DBUserId, + exec: E, + ) -> Result, DatabaseError> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + let passkeys = sqlx::query!( + r#" + SELECT id, user_id, name, credential_id, + passkey AS "passkey: sqlx::types::Json", + last_used, created_at + FROM user_passkeys + WHERE user_id = $1 + ORDER BY created_at DESC + "#, + user_id.0, + ) + .fetch(exec) + .map_ok(|x| DBPasskey { + id: DBPasskeyId(x.id), + user_id: DBUserId(x.user_id), + name: x.name, + credential_id: x.credential_id, + passkey: x.passkey.0, + created_at: x.created_at, + last_used: x.last_used, + }) + .try_collect::>() + .await?; + + Ok(passkeys) + } + + pub async fn rename( + id: DBPasskeyId, + user_id: DBUserId, + name: &str, + transaction: &mut PgTransaction<'_>, + ) -> Result { + let result = sqlx::query!( + " + UPDATE user_passkeys SET name = $1 + WHERE id = $2 AND user_id = $3 + ", + name, + id as DBPasskeyId, + user_id as DBUserId, + ) + .execute(&mut *transaction) + .await?; + + Ok(result.rows_affected() > 0) + } + + pub async fn update_after_auth( + id: DBPasskeyId, + passkey: Passkey, + transaction: &mut PgTransaction<'_>, + ) -> Result { + let result = sqlx::query!( + " + UPDATE user_passkeys + SET passkey = $1, last_used = NOW() + WHERE id = $2 + ", + Json(&passkey) as _, + id as DBPasskeyId, + ) + .execute(&mut *transaction) + .await?; + + Ok(result.rows_affected() > 0) + } + + pub async fn remove( + id: DBPasskeyId, + transaction: &mut PgTransaction<'_>, + ) -> Result { + let result = sqlx::query!( + " + DELETE FROM user_passkeys + WHERE id = $1 + ", + id as DBPasskeyId, + ) + .execute(&mut *transaction) + .await?; + + Ok(result.rows_affected() > 0) + } + + pub async fn remove_for_user( + id: DBPasskeyId, + user_id: DBUserId, + transaction: &mut PgTransaction<'_>, + ) -> Result { + let result = sqlx::query!( + " + DELETE FROM user_passkeys + WHERE id = $1 AND user_id = $2 + ", + id as DBPasskeyId, + user_id as DBUserId, + ) + .execute(&mut *transaction) + .await?; + + Ok(result.rows_affected() > 0) + } +} diff --git a/apps/labrinth/src/database/models/session_item.rs b/apps/labrinth/src/database/models/session_item.rs index d49a3a8e64..c3e6025b5b 100644 --- a/apps/labrinth/src/database/models/session_item.rs +++ b/apps/labrinth/src/database/models/session_item.rs @@ -5,6 +5,7 @@ use crate::database::redis::RedisPool; use ariadne::ids::base62_impl::parse_base62; use chrono::{DateTime, Utc}; use dashmap::DashMap; +use futures_util::TryStreamExt; use serde::{Deserialize, Serialize}; use std::fmt::{Debug, Display}; use std::hash::Hash; @@ -311,4 +312,22 @@ impl DBSession { Ok(Some(())) } + + pub async fn remove_all_for_user( + user_id: DBUserId, + transaction: &mut PgTransaction<'_>, + ) -> Result, sqlx::Error> { + let sessions = sqlx::query!( + " + DELETE FROM sessions WHERE user_id = $1 RETURNING id, session + ", + user_id.0 + ) + .fetch(&mut *transaction) + .map_ok(|x| (DBSessionId(x.id), x.session)) + .try_collect() + .await?; + + Ok(sessions) + } } diff --git a/apps/labrinth/src/env.rs b/apps/labrinth/src/env.rs index de55497644..ffa11fb1ad 100644 --- a/apps/labrinth/src/env.rs +++ b/apps/labrinth/src/env.rs @@ -308,4 +308,6 @@ vars! { SERVER_PING_MIN_INTERVAL_SEC: u64 = 30u64 * 60; SERVER_PING_TIMEOUT_MS: u64 = 3u64 * 1000; SERVER_PING_MAX_FAIL_COUNT: u64 = 3u64; + + WEBAUTHN_RP_NAME: String; } diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs index a97ec86bdc..ffb7ab67dc 100644 --- a/apps/labrinth/src/lib.rs +++ b/apps/labrinth/src/lib.rs @@ -28,6 +28,8 @@ use crate::util::http::HttpClient; use crate::util::ratelimit::{AsyncRateLimiter, GCRAParameters}; use crate::util::tiltify::TiltifyClient; use sync::friends::handle_pubsub; +use url::Url; +use webauthn_rs::{Webauthn, WebauthnBuilder}; pub mod auth; pub mod background_task; @@ -75,6 +77,7 @@ pub struct LabrinthConfig { pub gotenberg_client: GotenbergClient, pub http_client: web::Data, pub tiltify_client: web::Data, + pub webauthn: web::Data, } #[allow(clippy::too_many_arguments)] @@ -297,6 +300,19 @@ pub fn app_setup( }); } + let webauthn_origin = Url::parse(&ENV.SITE_URL).expect("invalid SITE_URL"); + let webauthn_rp_id = webauthn_origin + .host_str() + .expect("SITE_URL has no host") + .to_string(); + let webauthn = web::Data::new( + WebauthnBuilder::new(&webauthn_rp_id, &webauthn_origin) + .expect("invalid webauthn configuration") + .rp_name(&ENV.WEBAUTHN_RP_NAME) + .build() + .expect("failed to build webauthn"), + ); + LabrinthConfig { pool, ro_pool, @@ -322,6 +338,7 @@ pub fn app_setup( .expect("ARCHON_URL and PYRO_API_KEY must be set"), ), email_queue: web::Data::new(email_queue), + webauthn, } } @@ -361,6 +378,7 @@ pub fn app_config( .app_data(web::Data::new(labrinth_config.stripe_client.clone())) .app_data(web::Data::new(labrinth_config.anrok_client.clone())) .app_data(labrinth_config.rate_limiter.clone()) + .app_data(labrinth_config.webauthn.clone()) .configure(routes::v3::config) .configure(routes::internal::config) .configure(routes::root_config) diff --git a/apps/labrinth/src/models/v3/ids.rs b/apps/labrinth/src/models/v3/ids.rs index d7919fe681..bcdb82306b 100644 --- a/apps/labrinth/src/models/v3/ids.rs +++ b/apps/labrinth/src/models/v3/ids.rs @@ -28,3 +28,4 @@ base62_id!(UserSubscriptionId); base62_id!(VersionId); base62_id!(AffiliateCodeId); base62_id!(AnalyticsEventId); +base62_id!(PasskeyId); diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index b84ed5bbd8..8c1d9af950 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -6,11 +6,13 @@ use crate::database::PgPool; use crate::database::PgTransaction; use crate::database::models::flow_item::DBFlow; use crate::database::models::notification_item::NotificationBuilder; -use crate::database::models::{DBUser, DBUserId}; +use crate::database::models::session_item::DBSession; +use crate::database::models::{DBPasskey, DBPasskeyId, DBUser, DBUserId}; use crate::database::redis::RedisPool; use crate::env::ENV; use crate::file_hosting::{FileHost, FileHostPublicity}; use crate::models::error::ApiError as ApiErrorResponse; +use crate::models::ids::PasskeyId; use crate::models::notifications::NotificationBody; use crate::models::pats::Scopes; use crate::models::users::{Badges, Role}; @@ -50,7 +52,12 @@ use std::sync::Arc; use thiserror::Error; use tracing::{error, info}; use url::Url; +use uuid::Uuid; use validator::Validate; +use webauthn_rs::prelude::{ + CredentialID, DiscoverableKey, PublicKeyCredential, + RegisterPublicKeyCredential, Webauthn, WebauthnError, +}; use zxcvbn::Score; pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { @@ -74,7 +81,14 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { .service(verify_email) .service(subscribe_newsletter) .service(get_newsletter_subscription_status) - .service(discord_community_link), + .service(discord_community_link) + .service(register_passkey_start) + .service(register_passkey_finish) + .service(authenticate_passkey_start) + .service(authenticate_passkey_finish) + .service(list_passkeys) + .service(rename_passkey) + .service(delete_passkey), ); } @@ -3091,3 +3105,489 @@ pub async fn get_newsletter_subscription_status( "subscribed": is_subscribed }))) } + +const MAX_PASSKEYS_PER_USER: usize = 20; + +#[utoipa::path( + post, + operation_id = "registerPasskeyStart", + responses( + (status = 200, description = "Passkey registration challenge created"), + (status = 401, description = "Invalid credentials") + ), + security(("bearer_auth" = [])) +)] +#[post("/passkey/register/start")] +pub async fn register_passkey_start( + req: HttpRequest, + pool: Data, + redis: Data, + session_queue: Data, + webauthn: Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::USER_AUTH_WRITE, + ) + .await? + .1; + + // Get currently registered credentials, so an authenticator knows not to register + // duplicate credentials + let excluded_credentials = DBPasskey::get_for_user(user.id.into(), &**pool) + .await + .wrap_internal_err("failed to fetch passkeys for user")? + .into_iter() + .map(|cred| CredentialID::from(cred.credential_id)) + .collect::>(); + + if excluded_credentials.len() >= MAX_PASSKEYS_PER_USER { + return Err(ApiError::Request(eyre!( + "maximum of {MAX_PASSKEYS_PER_USER} passkeys per user reached" + ))); + } + + // Doesn't have to be a real UUID as long as it's unique + let user_uuid = Uuid::from_u128(user.id.0 as u128); + // Confusingly named in library and specs, but since we already use the username as the display + // name, using the email as normal name is better, the Webauthn specs state: + // "It is intended only for display, i.e., aiding the user in determining the difference + // between user accounts with similar displayNames." + let name = user.email.as_deref().unwrap_or(&user.username); + let (mut ccr, reg_state) = webauthn + .start_passkey_registration( + user_uuid, + name, + &user.username, + Some(excluded_credentials), + ) + .wrap_internal_err("failed to start passkey registration")?; + + // This is not ideal, but webauthn-rs doesn't expose anything that allows us to force a resident + // key. And since we are implementing a one-click login flow without input of a username, + // we have to require a resident key, since this is a prerequisite for a discoverable + // credential. The default of this library is "discouraged", which does not match our use case. + // In the future this can be set to "preferred" if 2FA using a security key is implemented. + if let Some(ref mut auth_sel) = ccr.public_key.authenticator_selection { + auth_sel.resident_key = + Some(webauthn_rs_proto::ResidentKeyRequirement::Required); + auth_sel.require_resident_key = true; + } + + let flow = DBFlow::RegisterPasskey { + user_id: user.id.into(), + state: reg_state, + } + .insert(Duration::minutes(30), &redis) + .await + .wrap_internal_err("failed to store passkey registration flow")?; + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "options": ccr, + "flow": flow, + }))) +} + +#[derive(Deserialize, Validate, utoipa::ToSchema)] +pub struct RegisterPasskeyFinish { + pub flow: String, + #[validate(length(min = 1, max = 255))] + pub name: String, + #[schema(value_type = Object)] + pub credential: RegisterPublicKeyCredential, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct PasskeyResponse { + pub id: PasskeyId, + pub name: String, + pub created_at: chrono::DateTime, + pub last_used: Option>, +} + +#[utoipa::path( + post, + operation_id = "registerPasskeyFinish", + responses( + (status = 201, description = "Passkey registered", body = PasskeyResponse), + (status = 400, description = "Invalid input"), + (status = 401, description = "Invalid credentials") + ), + security(("bearer_auth" = [])) +)] +#[post("/passkey/register/finish")] +pub async fn register_passkey_finish( + req: HttpRequest, + pool: Data, + redis: Data, + session_queue: Data, + webauthn: Data, + response: web::Json, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::USER_AUTH_WRITE, + ) + .await? + .1; + + response.validate().map_err(|err| { + ApiError::InvalidInput(validation_errors_to_string(err, None)) + })?; + + let db_user_id: DBUserId = user.id.into(); + let flow = DBFlow::take_if( + &response.flow, + |f| matches!(f, DBFlow::RegisterPasskey { user_id, .. } if *user_id == db_user_id), + &redis, + ) + .await?; + if let Some(DBFlow::RegisterPasskey { user_id, state }) = flow { + if user_id != db_user_id { + return Err(ApiError::Authentication( + AuthenticationError::InvalidCredentials, + )); + } + + let result = webauthn + .finish_passkey_registration(&response.credential, &state) + .wrap_request_err("failed to finish passkey registration")?; + + let mut transaction = pool.begin().await?; + let passkey_id = + crate::database::models::generate_passkey_id(&mut transaction) + .await + .wrap_internal_err("failed to generate passkey id")?; + + let passkey = DBPasskey { + id: passkey_id, + user_id: db_user_id, + name: response.name.clone(), + credential_id: result.cred_id().to_vec(), + passkey: result, + created_at: Utc::now(), + last_used: None, + }; + passkey + .insert(&mut transaction) + .await + .wrap_internal_err("Failed to create passkey object")?; + + transaction.commit().await?; + Ok(HttpResponse::Created().json(PasskeyResponse { + id: passkey.id.into(), + name: passkey.name, + created_at: passkey.created_at, + last_used: passkey.last_used, + })) + } else { + Err(ApiError::Request(eyre!( + "flow does not exist. try restarting the passkey registration process" + ))) + } +} + +#[utoipa::path( + post, + operation_id = "authenticatePasskeyStart", + responses( + (status = 200, description = "Passkey authentication challenge created") + ) +)] +#[post("/passkey/start")] +pub async fn authenticate_passkey_start( + redis: Data, + webauthn: Data, +) -> Result { + let (mut ccr, auth_state) = webauthn + .start_discoverable_authentication() + .wrap_internal_err("failed to start passkey authentication")?; + + // Webauthn-rs implements discoverable credentials as if they will only ever be used with + // conditional UI, but as their own documentation says this has poor UX due to browser and OS + // implementation. So we use a button, but that means mediation must be set to "required". + // We use none since the enum only supports conditional, and the default is required. + ccr.mediation = None; + + let flow = DBFlow::AuthenticatePasskey { state: auth_state } + .insert(Duration::minutes(30), &redis) + .await + .wrap_internal_err("failed to store passkey authentication flow")?; + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "options": ccr, + "flow": flow, + }))) +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct AuthenticatePasskeyFinish { + pub flow: String, + #[schema(value_type = Object)] + pub credential: PublicKeyCredential, +} + +#[utoipa::path( + post, + operation_id = "authenticatePasskeyFinish", + responses( + (status = 200, description = "Passkey authentication successful"), + (status = 400, description = "Invalid input") + ) +)] +#[post("/passkey/finish")] +pub async fn authenticate_passkey_finish( + req: HttpRequest, + pool: Data, + redis: Data, + webauthn: Data, + response: web::Json, +) -> Result { + let flow = DBFlow::take_if( + &response.flow, + |f| matches!(f, DBFlow::AuthenticatePasskey { .. }), + &redis, + ) + .await?; + + if let Some(DBFlow::AuthenticatePasskey { state }) = flow { + let credential_id = response.credential.get_credential_id(); + let db_passkey = + DBPasskey::get_by_credential_id(credential_id, &**pool) + .await + .wrap_internal_err("failed to fetch passkey")? + .ok_or_else(|| ApiError::Request(eyre!("passkey not found")))?; + + let mut transaction = pool + .begin() + .await + .wrap_internal_err("failed to begin transaction")?; + + let auth_result = match webauthn.finish_discoverable_authentication( + &response.credential, + state, + &[DiscoverableKey::from(&db_passkey.passkey)], + ) { + Ok(r) => r, + Err(WebauthnError::CredentialPossibleCompromise) => { + DBPasskey::remove(db_passkey.id, &mut transaction) + .await + .wrap_internal_err( + "failed to remove compromised passkey", + )?; + + // Log out all sessions + let sessions = DBSession::remove_all_for_user( + db_passkey.user_id, + &mut transaction, + ) + .await + .wrap_internal_err("failed to invalidate user sessions")?; + transaction.commit().await?; + DBSession::clear_cache( + sessions + .into_iter() + .map(|(id, session)| (Some(id), Some(session), None)) + .chain(std::iter::once(( + None, + None, + Some(db_passkey.user_id), + ))) + .collect(), + &redis, + ) + .await + .wrap_internal_err("failed to clear user session cache")?; + + return Err(ApiError::Request(eyre!( + "the credential may have been compromised and has been invalidated, please try another login method" + ))); + } + Err(e) => return Err(ApiError::Request(eyre::Report::from(e))), + }; + + let mut updated_passkey = db_passkey.passkey; + updated_passkey.update_credential(&auth_result); + + let updated = DBPasskey::update_after_auth( + db_passkey.id, + updated_passkey, + &mut transaction, + ) + .await + .wrap_internal_err("failed to update passkey")?; + if !updated { + return Err(ApiError::Internal(eyre!( + "failed to update passkey information" + ))); + } + + let session = issue_session( + req, + db_passkey.user_id, + &mut transaction, + &redis, + None, + ) + .await?; + let res = crate::models::sessions::Session::from(session, true, None); + + transaction.commit().await?; + Ok(HttpResponse::Ok().json(res)) + } else { + Err(ApiError::Request(eyre!( + "flow does not exist. try restarting the passkey authentication process" + ))) + } +} + +#[utoipa::path( + get, + operation_id = "listPasskeys", + responses( + (status = 200, description = "List of passkeys", body = [PasskeyResponse]), + (status = 401, description = "Invalid credentials") + ), + security(("bearer_auth" = [])) +)] +#[get("/passkey")] +pub async fn list_passkeys( + req: HttpRequest, + pool: Data, + redis: Data, + session_queue: Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::USER_AUTH_WRITE, + ) + .await? + .1; + + let passkeys = DBPasskey::get_for_user(user.id.into(), &**pool) + .await + .wrap_internal_err("failed to fetch passkeys")? + .into_iter() + .map(|p| PasskeyResponse { + id: p.id.into(), + name: p.name, + created_at: p.created_at, + last_used: p.last_used, + }) + .collect::>(); + + Ok(HttpResponse::Ok().json(passkeys)) +} + +#[derive(Deserialize, Validate, utoipa::ToSchema)] +pub struct RenamePasskey { + #[validate(length(min = 1, max = 255))] + pub name: String, +} + +#[utoipa::path( + patch, + operation_id = "renamePasskey", + responses( + (status = 204, description = "Passkey renamed"), + (status = 400, description = "Invalid input"), + (status = 401, description = "Invalid credentials") + ), + security(("bearer_auth" = [])) +)] +#[patch("/passkey/{id}")] +pub async fn rename_passkey( + req: HttpRequest, + pool: Data, + redis: Data, + session_queue: Data, + info: web::Path<(String,)>, + body: web::Json, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::USER_AUTH_WRITE, + ) + .await? + .1; + + body.validate().map_err(|err| { + ApiError::InvalidInput(validation_errors_to_string(err, None)) + })?; + + let id = DBPasskeyId( + parse_base62(&info.into_inner().0) + .wrap_request_err("invalid passkey id")? as i64, + ); + + let mut transaction = pool.begin().await?; + + let found = + DBPasskey::rename(id, user.id.into(), &body.name, &mut transaction) + .await + .wrap_internal_err("failed to rename passkey")?; + if !found { + return Err(ApiError::NotFound); + } + + transaction.commit().await?; + Ok(HttpResponse::NoContent().finish()) +} + +#[utoipa::path( + delete, + operation_id = "deletePasskey", + responses( + (status = 204, description = "Passkey deleted"), + (status = 401, description = "Invalid credentials") + ), + security(("bearer_auth" = [])) +)] +#[delete("/passkey/{id}")] +pub async fn delete_passkey( + req: HttpRequest, + pool: Data, + redis: Data, + session_queue: Data, + info: web::Path<(String,)>, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::USER_AUTH_WRITE, + ) + .await? + .1; + + let id = DBPasskeyId( + parse_base62(&info.into_inner().0) + .wrap_request_err("invalid passkey id")? as i64, + ); + + let mut transaction = pool.begin().await?; + + let found = + DBPasskey::remove_for_user(id, user.id.into(), &mut transaction) + .await + .wrap_internal_err("failed to delete passkey")?; + if !found { + return Err(ApiError::NotFound); + } + + transaction.commit().await?; + Ok(HttpResponse::NoContent().finish()) +} diff --git a/packages/api-client/src/modules/labrinth/auth/v2.ts b/packages/api-client/src/modules/labrinth/auth/v2.ts index e1829d7726..0a21546231 100644 --- a/packages/api-client/src/modules/labrinth/auth/v2.ts +++ b/packages/api-client/src/modules/labrinth/auth/v2.ts @@ -117,4 +117,116 @@ export class LabrinthAuthV2Module extends AbstractModule { body: data, }) } + + /** + * List the current user's registered passkeys + * + * @returns A promise that resolves to a list of the user's registered passkeys + */ + public async listPasskeys(): Promise { + return this.client.request(`/auth/passkey`, { + api: 'labrinth', + version: 2, + method: 'GET', + }) + } + + /** + * Begin registering a new passkey, returning the WebAuthn creation options and a flow + * + * @returns A promise that resolves to the WebAuthn creation options and flow + */ + public async registerPasskeyStart(): Promise { + return this.client.request( + `/auth/passkey/register/start`, + { + api: 'labrinth', + version: 2, + method: 'POST', + }, + ) + } + + /** + * Complete passkey registration with the created credential + * + * @param data The credential data and flow to complete registration with + * @returns A promise that resolves to the newly registered passkey + */ + public async registerPasskeyFinish( + data: Labrinth.Auth.v2.PasskeyRegisterFinishRequest, + ): Promise { + return this.client.request(`/auth/passkey/register/finish`, { + api: 'labrinth', + version: 2, + method: 'POST', + body: data, + }) + } + + /** + * Begin a passkey authentication flow, returning the WebAuthn request options and a flow + * + * @returns A promise that resolves to the WebAuthn request options and a flow + */ + public async authenticatePasskeyStart(): Promise { + return this.client.request( + `/auth/passkey/start`, + { + api: 'labrinth', + version: 2, + method: 'POST', + skipAuth: true, + }, + ) + } + + /** + * Complete a passkey authentication flow, returning the new session + * + * @param data The credential data and flow to complete authentication with + * @returns A promise that resolves to the new session + */ + public async authenticatePasskeyFinish( + data: Labrinth.Auth.v2.PasskeyAuthenticateFinishRequest, + ): Promise { + return this.client.request(`/auth/passkey/finish`, { + api: 'labrinth', + version: 2, + method: 'POST', + body: data, + skipAuth: true, + }) + } + + /** + * Rename a passkey + * + * @param id The ID of the passkey to rename + * @param data The new name for the passkey + */ + public async renamePasskey( + id: string, + data: Labrinth.Auth.v2.PasskeyRenameRequest, + ): Promise { + return this.client.request(`/auth/passkey/${id}`, { + api: 'labrinth', + version: 2, + method: 'PATCH', + body: data, + }) + } + + /** + * Delete a passkey + * + * @param id The ID of the passkey to delete + */ + public async deletePasskey(id: string): Promise { + return this.client.request(`/auth/passkey/${id}`, { + api: 'labrinth', + version: 2, + method: 'DELETE', + }) + } } diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index 38002bc89a..a5c80d3f88 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -576,6 +576,38 @@ export namespace Labrinth { old_password?: string new_password?: string } + + export type Passkey = { + id: string + name: string + created_at: string + last_used: string | null + } + + export type PasskeyRegisterStartResponse = { + options: Record + flow: string + } + + export type PasskeyRegisterFinishRequest = { + flow: string + name: string + credential: unknown + } + + export type PasskeyAuthenticateStartResponse = { + options: Record + flow: string + } + + export type PasskeyAuthenticateFinishRequest = { + flow: string + credential: unknown + } + + export type PasskeyRenameRequest = { + name: string + } } } diff --git a/packages/assets/generated-icons.ts b/packages/assets/generated-icons.ts index 90d709b0f9..57a0aafc6a 100644 --- a/packages/assets/generated-icons.ts +++ b/packages/assets/generated-icons.ts @@ -411,6 +411,7 @@ import _UpdatedIcon from './icons/updated.svg?component' import _UploadIcon from './icons/upload.svg?component' import _UserIcon from './icons/user.svg?component' import _UserCogIcon from './icons/user-cog.svg?component' +import _UserKeyIcon from './icons/user-key.svg?component' import _UserPlusIcon from './icons/user-plus.svg?component' import _UserRoundIcon from './icons/user-round.svg?component' import _UserSearchIcon from './icons/user-search.svg?component' @@ -833,6 +834,7 @@ export const UpdatedIcon = _UpdatedIcon export const UploadIcon = _UploadIcon export const UserIcon = _UserIcon export const UserCogIcon = _UserCogIcon +export const UserKeyIcon = _UserKeyIcon export const UserPlusIcon = _UserPlusIcon export const UserRoundIcon = _UserRoundIcon export const UserSearchIcon = _UserSearchIcon diff --git a/packages/assets/icons/user-key.svg b/packages/assets/icons/user-key.svg new file mode 100644 index 0000000000..ad1c10ef31 --- /dev/null +++ b/packages/assets/icons/user-key.svg @@ -0,0 +1 @@ + \ No newline at end of file