From f92c9dc288f18612359611432a1e5fef927e0bc9 Mon Sep 17 00:00:00 2001 From: DeDiamondPro <67508414+DeDiamondPro@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:28:41 +0200 Subject: [PATCH 1/3] feat: add passkey backend --- Cargo.lock | 497 ++++++++++++++++-- Cargo.toml | 2 + apps/labrinth/.env.docker-compose | 3 + apps/labrinth/.env.local | 3 + ...63ebb067931f4c3caeb93601c98f1d533983b.json | 58 ++ ...7576b2fd7fe535afee2f3136d6b153bbf4129.json | 16 + ...aa56692577571ec6d21131b4a31f55d37b98e.json | 15 + ...3922e836d2dfd9eb447e278f38266d13c8e73.json | 15 + ...e0fef0bc06a8aafd844c38aff3dcbf63e1bc9.json | 20 + ...cff8823503afefd68c2a2a7a6b879dfdaea2d.json | 58 ++ ...ccd25d6f36dfc03b69e83079bfce0bf2030fb.json | 22 + ...fe363a86e42417a047a6ed80f2c62811d5c2d.json | 14 + apps/labrinth/Cargo.toml | 2 + .../migrations/20260610162635_passkeys.sql | 11 + .../labrinth/src/database/models/flow_item.rs | 8 + apps/labrinth/src/database/models/ids.rs | 6 +- apps/labrinth/src/database/models/mod.rs | 2 + .../src/database/models/passkey_item.rs | 191 +++++++ apps/labrinth/src/env.rs | 2 + apps/labrinth/src/lib.rs | 18 + apps/labrinth/src/models/v3/ids.rs | 1 + apps/labrinth/src/routes/internal/flows.rs | 477 ++++++++++++++++- 22 files changed, 1381 insertions(+), 60 deletions(-) create mode 100644 apps/labrinth/.sqlx/query-05d26562a95715d65bbb2fd1c4163ebb067931f4c3caeb93601c98f1d533983b.json create mode 100644 apps/labrinth/.sqlx/query-29fc17743ca5cf06fcea50ebc477576b2fd7fe535afee2f3136d6b153bbf4129.json create mode 100644 apps/labrinth/.sqlx/query-42a9ddd851497b7a340e00b8289aa56692577571ec6d21131b4a31f55d37b98e.json create mode 100644 apps/labrinth/.sqlx/query-538bfc1694ce1d177ea20f269353922e836d2dfd9eb447e278f38266d13c8e73.json create mode 100644 apps/labrinth/.sqlx/query-7de293b153f075b8e44bf8ec8eee0fef0bc06a8aafd844c38aff3dcbf63e1bc9.json create mode 100644 apps/labrinth/.sqlx/query-806c5ed76a076bfd060fca40aa4cff8823503afefd68c2a2a7a6b879dfdaea2d.json create mode 100644 apps/labrinth/.sqlx/query-9851b2891716958cb2e0eb8f2deccd25d6f36dfc03b69e83079bfce0bf2030fb.json create mode 100644 apps/labrinth/.sqlx/query-ddef9fee29f75736494b196a10dfe363a86e42417a047a6ed80f2c62811d5c2d.json create mode 100644 apps/labrinth/migrations/20260610162635_passkeys.sql create mode 100644 apps/labrinth/src/database/models/passkey_item.rs diff --git a/Cargo.lock b/Cargo.lock index 1abbf338b6..45b9d07ec1 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" @@ -1378,7 +1428,7 @@ dependencies = [ "bitflags 2.9.4", "cexpr", "clang-sys", - "itertools 0.12.1", + "itertools 0.13.0", "log", "prettyplease", "proc-macro2", @@ -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" @@ -2927,7 +3011,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" dependencies = [ - "libloading 0.7.4", + "libloading 0.8.8", ] [[package]] @@ -3283,7 +3367,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3894,11 +3978,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" @@ -4600,7 +4698,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.1", "system-configuration", "tokio", "tower-service", @@ -4743,6 +4841,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" @@ -4997,7 +5101,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" dependencies = [ "hermit-abi 0.5.2", "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5331,8 +5435,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", @@ -5365,6 +5471,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" @@ -5463,7 +5575,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.53.5", ] [[package]] @@ -5766,7 +5878,7 @@ dependencies = [ "thiserror 2.0.17", "time", "tokio", - "uuid 1.18.1", + "uuid 1.23.3", "wasm-bindgen-futures", "web-sys", "yaup", @@ -5906,7 +6018,7 @@ dependencies = [ "rustc_version", "smallvec", "tagptr", - "uuid 1.18.1", + "uuid 1.23.3", ] [[package]] @@ -5957,7 +6069,7 @@ dependencies = [ "serde_with", "strum", "utoipa", - "uuid 1.18.1", + "uuid 1.23.3", ] [[package]] @@ -6592,6 +6704,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" @@ -6622,15 +6743,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", ] @@ -6654,9 +6774,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", @@ -6960,6 +7080,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" @@ -7725,7 +7851,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.32", - "socket2 0.5.10", + "socket2 0.6.1", "thiserror 2.0.17", "tokio", "tracing", @@ -7762,9 +7888,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.1", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -7788,6 +7914,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" @@ -7840,6 +7972,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" @@ -7897,6 +8040,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" @@ -8288,7 +8437,7 @@ dependencies = [ "rkyv_derive", "seahash", "tinyvec", - "uuid 1.18.1", + "uuid 1.23.3", ] [[package]] @@ -8427,6 +8576,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" @@ -8437,7 +8595,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -8450,7 +8608,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8644,7 +8802,7 @@ dependencies = [ "serde", "serde_json", "url", - "uuid 1.18.1", + "uuid 1.23.3", ] [[package]] @@ -8919,7 +9077,7 @@ dependencies = [ "thiserror 2.0.17", "time", "url", - "uuid 1.18.1", + "uuid 1.23.3", ] [[package]] @@ -8976,6 +9134,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" @@ -9222,7 +9390,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -9239,7 +9407,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -9495,7 +9663,7 @@ dependencies = [ "tokio-stream", "tracing", "url", - "uuid 1.18.1", + "uuid 1.23.3", "webpki-roots 0.26.11", ] @@ -9578,7 +9746,7 @@ dependencies = [ "stringprep", "thiserror 2.0.17", "tracing", - "uuid 1.18.1", + "uuid 1.23.3", "whoami", ] @@ -9618,7 +9786,7 @@ dependencies = [ "stringprep", "thiserror 2.0.17", "tracing", - "uuid 1.18.1", + "uuid 1.23.3", "whoami", ] @@ -9645,7 +9813,7 @@ dependencies = [ "thiserror 2.0.17", "tracing", "url", - "uuid 1.18.1", + "uuid 1.23.3", ] [[package]] @@ -9678,7 +9846,6 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.52.0", "windows-sys 0.59.0", ] @@ -10111,7 +10278,7 @@ dependencies = [ "thiserror 2.0.17", "time", "url", - "uuid 1.18.1", + "uuid 1.23.3", "walkdir", ] @@ -10419,7 +10586,7 @@ dependencies = [ "toml 0.9.8", "url", "urlpattern", - "uuid 1.18.1", + "uuid 1.23.3", "walkdir", ] @@ -10443,7 +10610,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix 1.1.2", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -10574,7 +10741,7 @@ dependencies = [ "tracing-error", "tracing-subscriber", "url", - "uuid 1.18.1", + "uuid 1.23.3", "whoami", "windows", "windows-core 0.61.2", @@ -10619,7 +10786,7 @@ dependencies = [ "tracing-error", "url", "urlencoding", - "uuid 1.18.1", + "uuid 1.23.3", "webview2-com", "windows-core 0.61.2", ] @@ -11107,9 +11274,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", @@ -11127,14 +11294,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", @@ -11143,9 +11310,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", @@ -11541,7 +11708,7 @@ dependencies = [ "regex", "syn 2.0.106", "url", - "uuid 1.18.1", + "uuid 1.23.3", ] [[package]] @@ -11567,14 +11734,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", ] @@ -11749,7 +11916,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]] @@ -11817,6 +11993,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" @@ -11843,6 +12041,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" @@ -11923,6 +12133,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" @@ -12097,7 +12375,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -12636,6 +12914,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" @@ -12727,6 +13093,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 af805f52e8..18cba66337 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -216,6 +216,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/labrinth/.env.docker-compose b/apps/labrinth/.env.docker-compose index 23f8398ce9..3faa68bd36 100644 --- a/apps/labrinth/.env.docker-compose +++ b/apps/labrinth/.env.docker-compose @@ -166,3 +166,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 8f6c29d907..631f5bc326 100644 --- a/apps/labrinth/.env.local +++ b/apps/labrinth/.env.local @@ -188,3 +188,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-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-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-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-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/Cargo.toml b/apps/labrinth/Cargo.toml index 0632770a08..7fd5de1c13 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -131,6 +131,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 646d5e4cc2..a194ce774c 100644 --- a/apps/labrinth/src/database/models/ids.rs +++ b/apps/labrinth/src/database/models/ids.rs @@ -3,7 +3,7 @@ use crate::database::PgTransaction; use crate::models::ids::{ AffiliateCodeId, ChargeId, CollectionId, FileId, ImageId, NotificationId, OAuthAccessTokenId, OAuthClientAuthorizationId, OAuthClientId, - OAuthRedirectUriId, OrganizationId, PatId, PayoutId, ProductId, + OAuthRedirectUriId, OrganizationId, PasskeyId, PatId, PayoutId, ProductId, ProductPriceId, ProjectId, ReportId, SessionId, SharedInstanceId, SharedInstanceVersionId, TeamId, TeamMemberId, ThreadId, ThreadMessageId, UserSubscriptionId, VersionId, @@ -269,6 +269,10 @@ db_id_interface!( AffiliateCodeId, generator: generate_affiliate_code_id @ "affiliate_codes", ); +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 0db87c5082..2bd93a9aba 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -21,6 +21,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; @@ -48,6 +49,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/env.rs b/apps/labrinth/src/env.rs index ab5c6165b8..24dd1e5f94 100644 --- a/apps/labrinth/src/env.rs +++ b/apps/labrinth/src/env.rs @@ -294,4 +294,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 15363ed09b..0745e813d6 100644 --- a/apps/labrinth/src/lib.rs +++ b/apps/labrinth/src/lib.rs @@ -27,6 +27,8 @@ use crate::util::archon::ArchonClient; use crate::util::http::HttpClient; use crate::util::ratelimit::{AsyncRateLimiter, GCRAParameters}; use sync::friends::handle_pubsub; +use url::Url; +use webauthn_rs::{Webauthn, WebauthnBuilder}; pub mod auth; pub mod background_task; @@ -73,6 +75,7 @@ pub struct LabrinthConfig { pub archon_client: web::Data, pub gotenberg_client: GotenbergClient, pub http_client: web::Data, + pub webauthn: web::Data, } #[allow(clippy::too_many_arguments)] @@ -293,6 +296,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, @@ -317,6 +333,7 @@ pub fn app_setup( .expect("ARCHON_URL and PYRO_API_KEY must be set"), ), email_queue: web::Data::new(email_queue), + webauthn, } } @@ -355,6 +372,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 7cb162ec27..0aa8c7e30f 100644 --- a/apps/labrinth/src/models/v3/ids.rs +++ b/apps/labrinth/src/models/v3/ids.rs @@ -26,3 +26,4 @@ base62_id!(ThreadMessageId); base62_id!(UserSubscriptionId); base62_id!(VersionId); base62_id!(AffiliateCodeId); +base62_id!(PasskeyId); diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index 2cc59bdb1d..0185446bdc 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -6,11 +6,12 @@ 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::{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}; @@ -45,7 +46,9 @@ use std::sync::Arc; use thiserror::Error; use tracing::{error, info}; use url::Url; +use uuid::Uuid; use validator::Validate; +use webauthn_rs::prelude::*; use zxcvbn::Score; pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { @@ -68,7 +71,14 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { .service(set_email) .service(verify_email) .service(subscribe_newsletter) - .service(get_newsletter_subscription_status), + .service(get_newsletter_subscription_status) + .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), ); } @@ -2981,3 +2991,466 @@ 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", + )?; + transaction.commit().await?; + return Err(ApiError::Request(eyre!( + "passkey counter did not advance; the credential may be cloned and has been invalidated" + ))); + } + 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()) +} From fbd17b34cb5a65b33d0de84d770ec2866da0e205 Mon Sep 17 00:00:00 2001 From: DeDiamondPro <67508414+DeDiamondPro@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:19:09 +0200 Subject: [PATCH 2/3] feat: passkey frontend --- .../components/ui/auth/PasskeySettings.vue | 422 ++++++++++++++++++ .../src/components/ui/auth/SignIn.vue | 28 +- apps/frontend/src/helpers/passkey.ts | 95 ++++ apps/frontend/src/locales/en-US/index.json | 45 ++ apps/frontend/src/pages/auth/sign-in.vue | 28 +- apps/frontend/src/pages/settings/account.vue | 2 + .../src/modules/labrinth/auth/v2.ts | 112 +++++ .../api-client/src/modules/labrinth/types.ts | 32 ++ packages/assets/generated-icons.ts | 2 + packages/assets/icons/user-key.svg | 1 + 10 files changed, 765 insertions(+), 2 deletions(-) create mode 100644 apps/frontend/src/components/ui/auth/PasskeySettings.vue create mode 100644 apps/frontend/src/helpers/passkey.ts create mode 100644 packages/assets/icons/user-key.svg 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 7a58e6cd41..99b7e9391f 100644 --- a/apps/frontend/src/locales/en-US/index.json +++ b/apps/frontend/src/locales/en-US/index.json @@ -332,6 +332,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" }, @@ -3518,6 +3521,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 057ed2eef7..940df6b60f 100644 --- a/apps/frontend/src/pages/settings/account.vue +++ b/apps/frontend/src/pages/settings/account.vue @@ -434,6 +434,7 @@ +
@@ -507,6 +508,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/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 fd27b2fbed..ad19e04478 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -305,6 +305,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 c5cb8747fe..8d1b5f1efa 100644 --- a/packages/assets/generated-icons.ts +++ b/packages/assets/generated-icons.ts @@ -381,6 +381,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' @@ -773,6 +774,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 From 6cee7036998ff1dcecfe439655d9cb537be78019 Mon Sep 17 00:00:00 2001 From: DeDiamondPro <67508414+DeDiamondPro@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:56:49 +0200 Subject: [PATCH 3/3] invalidate sessions on compromised passkey --- ...dc1cdeda4f3386e385dd66bec5be2aaa75b5b.json | 28 +++++++++++++++++ .../src/database/models/session_item.rs | 19 ++++++++++++ apps/labrinth/src/routes/internal/flows.rs | 31 +++++++++++++++++-- 3 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 apps/labrinth/.sqlx/query-31c1a2872410a5834e9995ad73cdc1cdeda4f3386e385dd66bec5be2aaa75b5b.json 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/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/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index 0185446bdc..8408d14a4f 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -6,6 +6,7 @@ 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::session_item::DBSession; use crate::database::models::{DBPasskey, DBPasskeyId, DBUser, DBUserId}; use crate::database::redis::RedisPool; use crate::env::ENV; @@ -48,7 +49,10 @@ use tracing::{error, info}; use url::Url; use uuid::Uuid; use validator::Validate; -use webauthn_rs::prelude::*; +use webauthn_rs::prelude::{ + CredentialID, DiscoverableKey, PublicKeyCredential, + RegisterPublicKeyCredential, Webauthn, WebauthnError, +}; use zxcvbn::Score; pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { @@ -3267,9 +3271,32 @@ pub async fn authenticate_passkey_finish( .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!( - "passkey counter did not advance; the credential may be cloned and has been invalidated" + "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))),