From 03e03d87d9c55b336defc55473287c23e1cd6fb8 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Thu, 5 Feb 2026 11:00:07 +0000 Subject: [PATCH] feat(auth): replace password auth with WebAuthn passkeys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace username/password authentication with FIDO2/WebAuthn passkey-based auth using webauthn-rs. Sessions and bearer tokens are unchanged — only the way they are created changes. - Add webauthn-rs, uuid, open, url deps; remove argon2, rpassword - Add passkey_credentials and registration_tokens tables (migrations 17-18) - Add domain entities, typed IDs, and repository traits for passkeys/tokens - Add SQL repository implementations for passkeys and registration tokens - Add ChallengeStore for in-memory WebAuthn ceremony state - Add WebAuthn route handlers (register/auth start+finish ceremonies) - Add CLI browser handoff for token creation (opens browser, local callback) - Replace login form with "Sign in with Passkey" button - Add registration page for first-user bootstrap via one-time token - Replace BREWLOG_ADMIN_USERNAME/PASSWORD with BREWLOG_RP_ID/RP_ORIGIN - Change default BREWLOG_URL from 127.0.0.1 to localhost (WebAuthn requires it) --- Cargo.lock | 494 +++++++++++++++--- Cargo.toml | 8 +- migrations/0017_passkey_auth.sql | 34 ++ migrations/0018_remove_password_hash.sql | 3 + src/application/routes/auth.rs | 101 +--- src/application/routes/mod.rs | 20 +- src/application/routes/tokens.rs | 28 +- src/application/routes/webauthn.rs | 445 ++++++++++++++++ src/application/server.rs | 154 +++--- src/domain/ids.rs | 2 + src/domain/mod.rs | 2 + src/domain/passkey_credentials.rs | 31 ++ src/domain/registration_tokens.rs | 45 ++ src/domain/repositories.rs | 43 +- src/domain/users.rs | 21 +- src/infrastructure/auth.rs | 64 +-- src/infrastructure/client/mod.rs | 4 - src/infrastructure/client/tokens.rs | 31 -- src/infrastructure/mod.rs | 1 + src/infrastructure/repositories/mod.rs | 2 + .../repositories/passkey_credentials.rs | 145 +++++ .../repositories/registration_tokens.rs | 121 +++++ src/infrastructure/repositories/users.rs | 38 +- src/infrastructure/webauthn.rs | 118 +++++ src/main.rs | 19 +- src/presentation/cli/mod.rs | 10 +- src/presentation/cli/tokens.rs | 161 ++++-- templates/cli_callback.html | 24 + templates/login.html | 98 ++-- templates/register.html | 104 ++++ templates/webauthn.js | 147 ++++++ 31 files changed, 2038 insertions(+), 480 deletions(-) create mode 100644 migrations/0017_passkey_auth.sql create mode 100644 migrations/0018_remove_password_hash.sql create mode 100644 src/application/routes/webauthn.rs create mode 100644 src/domain/passkey_credentials.rs create mode 100644 src/domain/registration_tokens.rs create mode 100644 src/infrastructure/repositories/passkey_credentials.rs create mode 100644 src/infrastructure/repositories/registration_tokens.rs create mode 100644 src/infrastructure/webauthn.rs create mode 100644 templates/cli_callback.html create mode 100644 templates/register.html create mode 100644 templates/webauthn.js diff --git a/Cargo.lock b/Cargo.lock index cdbd802..6e9aaf3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -101,18 +101,6 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" -[[package]] -name = "argon2" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" -dependencies = [ - "base64ct", - "blake2", - "cpufeatures", - "password-hash", -] - [[package]] name = "askama" version = "0.12.1" @@ -157,6 +145,45 @@ dependencies = [ "nom", ] +[[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", + "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.110", + "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.110", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -179,6 +206,28 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -296,6 +345,17 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +[[package]] +name = "base64urlsafedata" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f7f6be94fa637132933fd0a68b9140bcb60e3d46164cb68e82a2bb8d102b3a" +dependencies = [ + "base64 0.21.7", + "pastey", + "serde", +] + [[package]] name = "basic-toml" version = "0.1.10" @@ -305,6 +365,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.10.0" @@ -314,15 +380,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "blake2" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" -dependencies = [ - "digest", -] - [[package]] name = "block-buffer" version = "0.10.4" @@ -337,7 +394,6 @@ name = "brewlog" version = "0.1.0" dependencies = [ "anyhow", - "argon2", "askama", "async-trait", "axum", @@ -347,10 +403,10 @@ dependencies = [ "dotenvy", "isocountry", "once_cell", + "open", "portpicker", "rand 0.8.5", "reqwest", - "rpassword", "serde", "serde_json", "sha2", @@ -365,6 +421,11 @@ dependencies = [ "tracing-bunyan-formatter", "tracing-log 0.2.0", "tracing-subscriber", + "url", + "uuid", + "webauthn-authenticator-rs", + "webauthn-rs", + "webauthn-rs-proto", "wiremock", ] @@ -574,6 +635,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -584,6 +651,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + [[package]] name = "deadpool" version = "0.12.3" @@ -613,6 +686,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", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.5" @@ -747,6 +834,21 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -922,6 +1024,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1272,6 +1385,25 @@ dependencies = [ "serde", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1331,7 +1463,7 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ - "bitflags", + "bitflags 2.10.0", "libc", "redox_syscall", ] @@ -1479,6 +1611,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -1501,6 +1643,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -1541,6 +1694,15 @@ dependencies = [ "libc", ] +[[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" @@ -1553,6 +1715,55 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1576,23 +1787,24 @@ dependencies = [ "windows-link", ] -[[package]] -name = "password-hash" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" -dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "paste" 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 = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -1840,7 +2052,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.10.0", ] [[package]] @@ -1930,17 +2142,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rpassword" -version = "7.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39" -dependencies = [ - "libc", - "rtoolbox", - "windows-sys 0.59.0", -] - [[package]] name = "rsa" version = "0.9.9" @@ -1961,29 +2162,28 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rtoolbox" -version = "0.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "rustc-hash" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys", @@ -2093,6 +2293,26 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_cbor_2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c" +dependencies = [ + "half", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -2375,7 +2595,7 @@ checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418" dependencies = [ "atoi", "base64 0.21.7", - "bitflags", + "bitflags 2.10.0", "byteorder", "bytes", "chrono", @@ -2418,7 +2638,7 @@ checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e" dependencies = [ "atoi", "base64 0.21.7", - "bitflags", + "bitflags 2.10.0", "byteorder", "chrono", "crc", @@ -2708,6 +2928,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -2773,7 +2994,7 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags", + "bitflags 2.10.0", "bytes", "futures-util", "http", @@ -2799,9 +3020,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", @@ -2811,9 +3032,9 @@ dependencies = [ [[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", @@ -2840,9 +3061,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", @@ -2981,6 +3202,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -3107,6 +3340,107 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webauthn-attestation-ca" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fafcf13f7dc1fb292ed4aea22cdd3757c285d7559e9748950ee390249da4da6b" +dependencies = [ + "base64urlsafedata", + "openssl", + "openssl-sys", + "serde", + "tracing", + "uuid", +] + +[[package]] +name = "webauthn-authenticator-rs" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b41ed08aba475a969094226ae0691a286686210ae497bb2c5d0ed722d8d526" +dependencies = [ + "async-stream", + "async-trait", + "base64 0.21.7", + "base64urlsafedata", + "bitflags 1.3.2", + "futures", + "hex", + "nom", + "num-derive", + "num-traits", + "openssl", + "openssl-sys", + "serde", + "serde_bytes", + "serde_cbor_2", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tracing", + "unicode-normalization", + "url", + "uuid", + "webauthn-rs-core", + "webauthn-rs-proto", +] + +[[package]] +name = "webauthn-rs" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b24d082d3360258fefb6ffe56123beef7d6868c765c779f97b7a2fcf06727f8" +dependencies = [ + "base64urlsafedata", + "serde", + "tracing", + "url", + "uuid", + "webauthn-rs-core", +] + +[[package]] +name = "webauthn-rs-core" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15784340a24c170ce60567282fb956a0938742dbfbf9eff5df793a686a009b8b" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "der-parser", + "hex", + "nom", + "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", + "webauthn-attestation-ca", + "webauthn-rs-proto", + "x509-parser", +] + +[[package]] +name = "webauthn-rs-proto" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1fb2580ce73baa42d3011a24de2ceab0d428de1879ece06e02e8c416e497c" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "serde", + "serde_json", + "url", +] + [[package]] name = "webpki-roots" version = "0.25.4" @@ -3231,15 +3565,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.60.2" @@ -3479,6 +3804,23 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[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", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 6439f18..217bbd4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,6 @@ postgres = ["sqlx/postgres"] [dependencies] anyhow = "1.0" -argon2 = "0.5" async-trait = "0.1" axum = { version = "0.7", features = ["macros"] } askama = "0.12" @@ -19,8 +18,8 @@ chrono = { version = "0.4", features = ["serde", "clock"] } clap = { version = "4.5", features = ["derive", "env"] } dotenvy = "0.15" isocountry = "0.3" +open = "5" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "gzip"] } -rpassword = "7.3" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" rand = "0.8" @@ -42,12 +41,17 @@ tower-cookies = "0.10" slug = "0.1.6" tracing-bunyan-formatter = "0.3.10" tracing-log = "0.2.0" +url = "2" +uuid = { version = "1", features = ["v4"] } +webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation"] } +webauthn-rs-proto = "0.5" [dev-dependencies] portpicker = "0.1" reqwest = { version = "0.12", default-features = false, features = ["blocking", "cookies", "rustls-tls"] } tempfile = "3.8" once_cell = "1.19" +webauthn-authenticator-rs = { version = "0.5", features = ["softpasskey"] } wiremock = "0.6" [[test]] diff --git a/migrations/0017_passkey_auth.sql b/migrations/0017_passkey_auth.sql new file mode 100644 index 0000000..340c1e2 --- /dev/null +++ b/migrations/0017_passkey_auth.sql @@ -0,0 +1,34 @@ +-- Add UUID column to users for WebAuthn user handle +ALTER TABLE users ADD COLUMN uuid TEXT; + +-- Backfill existing users with random v4 UUIDs +UPDATE users SET uuid = + lower(hex(randomblob(4))) || '-' || + lower(hex(randomblob(2))) || '-' || + '4' || substr(lower(hex(randomblob(2))), 2) || '-' || + substr('89ab', abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))), 2) || '-' || + lower(hex(randomblob(6))); + +-- Passkey credential storage +CREATE TABLE passkey_credentials ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + credential_json TEXT NOT NULL, + name TEXT NOT NULL DEFAULT 'default', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + last_used_at TEXT +); + +CREATE INDEX idx_passkey_credentials_user_id ON passkey_credentials(user_id); + +-- One-time registration tokens for bootstrap and invite flows +CREATE TABLE registration_tokens ( + id INTEGER PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + expires_at TEXT NOT NULL, + used_at TEXT, + used_by_user_id INTEGER REFERENCES users(id) +); + +CREATE INDEX idx_registration_tokens_token_hash ON registration_tokens(token_hash); diff --git a/migrations/0018_remove_password_hash.sql b/migrations/0018_remove_password_hash.sql new file mode 100644 index 0000000..e504618 --- /dev/null +++ b/migrations/0018_remove_password_hash.sql @@ -0,0 +1,3 @@ +-- Remove password_hash column from users (passkey-only auth) +-- SQLite 3.35+ supports ALTER TABLE DROP COLUMN +ALTER TABLE users DROP COLUMN password_hash; diff --git a/src/application/routes/auth.rs b/src/application/routes/auth.rs index 91ced30..f8bb4f0 100644 --- a/src/application/routes/auth.rs +++ b/src/application/routes/auth.rs @@ -1,119 +1,54 @@ use askama::Template; -use axum::Form; -use axum::extract::State; +use axum::extract::{Query, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Redirect, Response}; -use chrono::{Duration, Utc}; use serde::Deserialize; use tower_cookies::{Cookie, Cookies}; -use tracing::{error, warn}; use crate::application::routes::render_html; use crate::application::server::AppState; -use crate::domain::sessions::NewSession; -use crate::infrastructure::auth::{generate_session_token, hash_token, verify_password}; +use crate::infrastructure::auth::hash_token; const SESSION_COOKIE_NAME: &str = "brewlog_session"; +#[derive(Debug, Deserialize)] +pub struct LoginQuery { + pub cli_callback: Option, +} + #[derive(Template)] #[template(path = "login.html")] struct LoginTemplate { nav_active: &'static str, is_authenticated: bool, - error: Option, -} - -#[derive(Deserialize)] -pub struct LoginForm { - username: String, - password: String, } #[tracing::instrument(skip(state, cookies))] pub(crate) async fn login_page( State(state): State, cookies: Cookies, + Query(query): Query, ) -> Result { - // Check if already authenticated - if is_authenticated(&state, &cookies).await { + // Don't redirect when CLI callback params are present — the user needs + // to authenticate with their passkey to generate a bearer token for the CLI. + if query.cli_callback.is_none() && is_authenticated(&state, &cookies).await { return Ok(Redirect::to("/").into_response()); } let template = LoginTemplate { nav_active: "login", is_authenticated: false, - - error: None, }; render_html(template).map(IntoResponse::into_response) } -#[tracing::instrument(name = "User attempting login", skip(state, cookies, form), fields(username = %form.username))] -pub(crate) async fn login_submit( - State(state): State, - cookies: Cookies, - Form(form): Form, -) -> Result { - // Validate credentials - let user = match state.user_repo.get_by_username(&form.username).await { - Ok(user) => user, - Err(err) => { - warn!(username = %form.username, error = %err, "login attempt with non-existent username or error"); - return show_login_error("Invalid username or password"); - } - }; - - // Verify password - if !verify_password(&form.password, &user.password_hash) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - { - warn!(username = %form.username, "login attempt with incorrect password"); - return show_login_error("Invalid username or password"); - } - - // Create session token - let session_token = generate_session_token(); - let session_token_hash = hash_token(&session_token); - - // Create session in database (valid for 30 days) - let new_session = NewSession::new( - user.id, - session_token_hash, - Utc::now(), - Utc::now() + Duration::days(30), - ); - - if let Err(err) = state.session_repo.insert(new_session).await { - error!(error = %err, "failed to create session"); - return Err(StatusCode::INTERNAL_SERVER_ERROR); - } - - // Set secure cookie - let mut cookie = Cookie::new(SESSION_COOKIE_NAME, session_token); - cookie.set_path("/"); - cookie.set_http_only(true); - cookie.set_same_site(tower_cookies::cookie::SameSite::Lax); - - // Enable secure flag if BREWLOG_SECURE_COOKIES is set to "true" - // This should be enabled in production when serving over HTTPS - if std::env::var("BREWLOG_SECURE_COOKIES").unwrap_or_default() == "true" { - cookie.set_secure(true); - } - - cookies.add(cookie); - - Ok(Redirect::to("/").into_response()) -} - #[tracing::instrument(skip(state, cookies))] pub(crate) async fn logout(State(state): State, cookies: Cookies) -> Redirect { - // Try to delete session from database if cookie exists if let Some(cookie) = cookies.get(SESSION_COOKIE_NAME) { let session_token = cookie.value(); let session_token_hash = hash_token(session_token); - // Try to find and delete the session if let Ok(session) = state .session_repo .get_by_token_hash(&session_token_hash) @@ -127,19 +62,6 @@ pub(crate) async fn logout(State(state): State, cookies: Cookies) -> R Redirect::to("/") } -fn show_login_error(message: &str) -> Result { - let template = LoginTemplate { - nav_active: "login", - is_authenticated: false, - - error: Some(message.to_string()), - }; - - render_html(template).map(IntoResponse::into_response) -} - -/// Check if user is authenticated based on session cookie -/// Validates the session token against the database #[tracing::instrument(skip(state, cookies))] pub async fn is_authenticated(state: &AppState, cookies: &Cookies) -> bool { let Some(cookie) = cookies.get(SESSION_COOKIE_NAME) else { @@ -149,7 +71,6 @@ pub async fn is_authenticated(state: &AppState, cookies: &Cookies) -> bool { let session_token = cookie.value(); let session_token_hash = hash_token(session_token); - // Check if session exists and is valid match state .session_repo .get_by_token_hash(&session_token_hash) diff --git a/src/application/routes/mod.rs b/src/application/routes/mod.rs index 5bc346e..f3a3ed7 100644 --- a/src/application/routes/mod.rs +++ b/src/application/routes/mod.rs @@ -16,6 +16,7 @@ pub mod scan; pub mod support; pub mod timeline; pub mod tokens; +pub mod webauthn; pub(crate) use auth::is_authenticated; @@ -105,18 +106,28 @@ pub fn app_router(state: AppState) -> axum::Router { post(backup::restore_backup).layer(DefaultBodyLimit::max(50 * 1024 * 1024)), ); + let webauthn_routes = axum::Router::new() + .route("/register/start", post(webauthn::register_start)) + .route("/register/finish", post(webauthn::register_finish)) + .route("/auth/start", get(webauthn::auth_start)) + .route("/auth/finish", post(webauthn::auth_finish)); + axum::Router::new() .route("/", get(home::home_page)) - .route("/login", get(auth::login_page).post(auth::login_submit)) + .route("/login", get(auth::login_page)) .route("/logout", post(auth::logout)) + .route("/register/:token", get(webauthn::register_page)) + .route("/auth/cli-callback", get(webauthn::cli_callback_page)) .route("/data", get(data::data_page)) .route("/add", get(add::add_page)) .route("/scan", get(scan_redirect)) .route("/check-in", get(checkin::checkin_page)) .route("/timeline", get(timeline::timeline_page)) .route("/styles.css", get(styles)) + .route("/webauthn.js", get(webauthn_js)) .route("/favicon.ico", get(favicon)) .nest("/api/v1", api_routes) + .nest("/api/v1/webauthn", webauthn_routes) .layer(ServiceBuilder::new().layer(CookieManagerLayer::new())) .with_state(state) } @@ -132,6 +143,13 @@ async fn styles() -> impl IntoResponse { ) } +async fn webauthn_js() -> impl IntoResponse { + ( + [("content-type", "application/javascript; charset=utf-8")], + include_str!("../../../templates/webauthn.js"), + ) +} + async fn favicon() -> impl IntoResponse { ( [("content-type", "image/x-icon")], diff --git a/src/application/routes/tokens.rs b/src/application/routes/tokens.rs index 2640cff..1ca14da 100644 --- a/src/application/routes/tokens.rs +++ b/src/application/routes/tokens.rs @@ -8,12 +8,10 @@ use crate::application::auth::AuthenticatedUser; use crate::application::server::AppState; use crate::domain::ids::{TokenId, UserId}; use crate::domain::tokens::{NewToken, Token}; -use crate::infrastructure::auth::{generate_token, hash_token, verify_password}; +use crate::infrastructure::auth::{generate_token, hash_token}; #[derive(Debug, Deserialize)] pub struct CreateTokenRequest { - pub username: String, - pub password: String, pub name: String, } @@ -47,33 +45,17 @@ impl From for TokenResponse { } } -#[tracing::instrument(skip(state, payload), fields(token_name = %payload.name, username = %payload.username))] +#[tracing::instrument(skip(state, auth_user, payload), fields(token_name = %payload.name))] pub async fn create_token( State(state): State, + auth_user: AuthenticatedUser, Json(payload): Json, ) -> Result, StatusCode> { - // Verify username and password - let user = state - .user_repo - .get_by_username(&payload.username) - .await - .map_err(|_| StatusCode::UNAUTHORIZED)?; - - let password_valid = verify_password(&payload.password, &user.password_hash) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - if !password_valid { - return Err(StatusCode::UNAUTHORIZED); - } - - // Generate new token let token_value = generate_token().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let token_hash_value = hash_token(&token_value); - let token_hash = hash_token(&token_value); + let new_token = NewToken::new(auth_user.0.id, token_hash_value, payload.name.clone()); - let new_token = NewToken::new(user.id, token_hash, payload.name.clone()); - - // Store token let stored_token = state .token_repo .insert(new_token) diff --git a/src/application/routes/webauthn.rs b/src/application/routes/webauthn.rs new file mode 100644 index 0000000..55443b5 --- /dev/null +++ b/src/application/routes/webauthn.rs @@ -0,0 +1,445 @@ +use askama::Template; +use axum::Json; +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use chrono::{Duration, Utc}; +use serde::{Deserialize, Serialize}; +use tower_cookies::{Cookie, Cookies}; +use tracing::{error, info, warn}; +use uuid::Uuid; +use webauthn_rs::prelude::*; + +use crate::application::routes::render_html; +use crate::application::server::AppState; +use crate::domain::passkey_credentials::NewPasskeyCredential; +use crate::domain::sessions::NewSession; +use crate::domain::tokens::NewToken; +use crate::domain::users::NewUser; +use crate::infrastructure::auth::{generate_session_token, generate_token, hash_token}; +use crate::infrastructure::webauthn::CliCallbackInfo; + +const SESSION_COOKIE_NAME: &str = "brewlog_session"; + +// --- Templates --- + +#[derive(Template)] +#[template(path = "register.html")] +struct RegisterTemplate { + nav_active: &'static str, + is_authenticated: bool, + token: String, +} + +#[derive(Template)] +#[template(path = "cli_callback.html")] +struct CliCallbackTemplate { + nav_active: &'static str, + is_authenticated: bool, + token: Option, + error: Option, +} + +// --- Request/Response types --- + +#[derive(Deserialize)] +pub struct RegisterStartRequest { + pub token: String, + pub display_name: String, +} + +#[derive(Serialize)] +pub struct ChallengeResponse { + pub challenge_id: String, + pub options: T, +} + +#[derive(Deserialize)] +pub struct RegisterFinishRequest { + pub challenge_id: String, + pub credential: RegisterPublicKeyCredential, +} + +#[derive(Debug, Deserialize)] +pub struct AuthStartQuery { + pub cli_callback: Option, + pub state: Option, + pub token_name: Option, +} + +#[derive(Serialize)] +pub struct AuthStartResponse { + pub challenge_id: String, + pub options: RequestChallengeResponse, +} + +#[derive(Deserialize)] +pub struct AuthFinishRequest { + pub challenge_id: String, + pub credential: PublicKeyCredential, +} + +#[derive(Serialize)] +pub struct AuthFinishResponse { + pub redirect: Option, +} + +// --- Registration page (bootstrap flow) --- + +#[tracing::instrument(skip(state))] +pub(crate) async fn register_page( + State(state): State, + Path(token): Path, +) -> Result { + // Validate the token exists and is usable + let token_hash = hash_token(&token); + let reg_token = state + .registration_token_repo + .get_by_token_hash(&token_hash) + .await + .map_err(|err| { + warn!( + %err, + token_hash_prefix = &token_hash[..8], + "registration token lookup failed" + ); + StatusCode::NOT_FOUND + })?; + + if !reg_token.is_valid() { + return Err(StatusCode::GONE); + } + + let template = RegisterTemplate { + nav_active: "", + is_authenticated: false, + token, + }; + + render_html(template).map(IntoResponse::into_response) +} + +// --- Registration start (creates user + begins ceremony) --- + +#[tracing::instrument(skip(state, payload), fields(display_name = %payload.display_name))] +pub(crate) async fn register_start( + State(state): State, + Json(payload): Json, +) -> Result>, StatusCode> { + // Validate registration token + let token_hash = hash_token(&payload.token); + let reg_token = state + .registration_token_repo + .get_by_token_hash(&token_hash) + .await + .map_err(|_| StatusCode::UNAUTHORIZED)?; + + if !reg_token.is_valid() { + return Err(StatusCode::GONE); + } + + // Create the user + let user_uuid = Uuid::new_v4().to_string(); + let new_user = NewUser::new(payload.display_name, user_uuid.clone()); + let user = state.user_repo.insert(new_user).await.map_err(|err| { + error!(error = %err, "failed to create user during registration"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + // Mark registration token as used + let _ = state + .registration_token_repo + .mark_used(reg_token.id, user.id) + .await; + + // Start passkey registration ceremony + let webauthn_uuid = + Uuid::parse_str(&user_uuid).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let exclude_credentials = Vec::new(); + + let (ccr, reg_state) = state + .webauthn + .start_passkey_registration( + webauthn_uuid, + &user.username, + &user.username, + Some(exclude_credentials), + ) + .map_err(|err| { + error!(error = %err, "failed to start passkey registration"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + // Store ceremony state + let challenge_id = generate_session_token(); + state + .challenge_store + .store_registration(challenge_id.clone(), user.id, reg_state) + .await; + + Ok(Json(ChallengeResponse { + challenge_id, + options: ccr, + })) +} + +// --- Registration finish --- + +#[tracing::instrument(skip(state, cookies, payload))] +pub(crate) async fn register_finish( + State(state): State, + cookies: Cookies, + Json(payload): Json, +) -> Result, StatusCode> { + // Retrieve ceremony state + let (user_id, reg_state) = state + .challenge_store + .take_registration(&payload.challenge_id) + .await + .ok_or(StatusCode::BAD_REQUEST)?; + + // Complete registration + let passkey = state + .webauthn + .finish_passkey_registration(&payload.credential, ®_state) + .map_err(|err| { + warn!(error = %err, "passkey registration failed"); + StatusCode::BAD_REQUEST + })?; + + // Store the credential + let credential_json = + serde_json::to_string(&passkey).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let new_credential = NewPasskeyCredential::new(user_id, credential_json, "default".to_string()); + state + .passkey_repo + .insert(new_credential) + .await + .map_err(|err| { + error!(error = %err, "failed to store passkey credential"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + info!(user_id = %user_id, "passkey registered successfully"); + + // Create session for the new user + create_session(&state, &cookies, user_id); + + Ok(Json(AuthFinishResponse { redirect: None })) +} + +// --- Authentication start --- + +#[tracing::instrument(skip(state))] +pub(crate) async fn auth_start( + State(state): State, + Query(query): Query, +) -> Result, StatusCode> { + // Load all passkey credentials from all users + let users = state + .user_repo + .list_all() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let mut all_passkeys: Vec = Vec::new(); + for user in &users { + let credentials = state + .passkey_repo + .list_by_user(user.id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + for cred in credentials { + let passkey: Passkey = serde_json::from_str(&cred.credential_json).map_err(|err| { + error!(error = %err, "failed to deserialize passkey credential"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + all_passkeys.push(passkey); + } + } + + if all_passkeys.is_empty() { + return Err(StatusCode::NOT_FOUND); + } + + let (rcr, auth_state) = state + .webauthn + .start_passkey_authentication(&all_passkeys) + .map_err(|err| { + error!(error = %err, "failed to start passkey authentication"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let cli_callback = match (query.cli_callback, query.state, query.token_name) { + (Some(callback_url), Some(cli_state), Some(token_name)) => Some(CliCallbackInfo { + callback_url, + state: cli_state, + token_name, + }), + _ => None, + }; + + let challenge_id = generate_session_token(); + state + .challenge_store + .store_authentication(challenge_id.clone(), auth_state, cli_callback) + .await; + + Ok(Json(AuthStartResponse { + challenge_id, + options: rcr, + })) +} + +// --- Authentication finish --- + +#[tracing::instrument(skip(state, cookies, payload))] +pub(crate) async fn auth_finish( + State(state): State, + cookies: Cookies, + Json(payload): Json, +) -> Result, StatusCode> { + // Retrieve ceremony state + let (auth_state, cli_callback) = state + .challenge_store + .take_authentication(&payload.challenge_id) + .await + .ok_or(StatusCode::BAD_REQUEST)?; + + // Complete authentication + let auth_result = state + .webauthn + .finish_passkey_authentication(&payload.credential, &auth_state) + .map_err(|err| { + warn!(error = %err, "passkey authentication failed"); + StatusCode::UNAUTHORIZED + })?; + + // Find the user who owns this credential + let credential_id = auth_result.cred_id(); + let users = state + .user_repo + .list_all() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let mut found_user_id = None; + let mut found_cred_id = None; + let mut found_passkey: Option = None; + + 'outer: for user in &users { + let credentials = state + .passkey_repo + .list_by_user(user.id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + for cred in &credentials { + let passkey: Passkey = serde_json::from_str(&cred.credential_json) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if passkey.cred_id() == credential_id { + found_user_id = Some(user.id); + found_cred_id = Some(cred.id); + found_passkey = Some(passkey); + break 'outer; + } + } + } + + let user_id = found_user_id.ok_or(StatusCode::UNAUTHORIZED)?; + let cred_db_id = found_cred_id.ok_or(StatusCode::UNAUTHORIZED)?; + + // Update credential counter if needed + if auth_result.needs_update() + && let Some(mut passkey) = found_passkey + { + passkey.update_credential(&auth_result); + if let Ok(updated_json) = serde_json::to_string(&passkey) { + let _ = state + .passkey_repo + .update_credential_json(cred_db_id, &updated_json) + .await; + } + } + + // Update last used timestamp + let passkey_repo = state.passkey_repo.clone(); + tokio::spawn(async move { + let _ = passkey_repo.update_last_used(cred_db_id).await; + }); + + // Handle CLI callback flow + if let Some(cli_info) = cli_callback { + // Generate a bearer token for the CLI + let token_value = generate_token().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let token_hash_value = hash_token(&token_value); + let new_token = NewToken::new(user_id, token_hash_value, cli_info.token_name); + state + .token_repo + .insert(new_token) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let redirect_url = format!( + "{}?token={}&state={}", + cli_info.callback_url, token_value, cli_info.state + ); + return Ok(Json(AuthFinishResponse { + redirect: Some(redirect_url), + })); + } + + // Normal web login: create session + create_session(&state, &cookies, user_id); + + info!(user_id = %user_id, "user authenticated via passkey"); + + Ok(Json(AuthFinishResponse { redirect: None })) +} + +// --- CLI callback page --- + +pub(crate) async fn cli_callback_page() -> Result { + let template = CliCallbackTemplate { + nav_active: "", + is_authenticated: false, + token: None, + error: None, + }; + + render_html(template).map(IntoResponse::into_response) +} + +// --- Helpers --- + +fn create_session(state: &AppState, cookies: &Cookies, user_id: crate::domain::ids::UserId) { + let session_token = generate_session_token(); + let session_token_hash = hash_token(&session_token); + + let new_session = NewSession::new( + user_id, + session_token_hash, + Utc::now(), + Utc::now() + Duration::days(30), + ); + + // Store the session in a fire-and-forget spawn, set the cookie optimistically. + let session_repo = state.session_repo.clone(); + tokio::spawn(async move { + if let Err(err) = session_repo.insert(new_session).await { + error!(error = %err, "failed to create session"); + } + }); + + let mut cookie = Cookie::new(SESSION_COOKIE_NAME, session_token); + cookie.set_path("/"); + cookie.set_http_only(true); + cookie.set_same_site(tower_cookies::cookie::SameSite::Lax); + + if std::env::var("BREWLOG_SECURE_COOKIES").unwrap_or_default() == "true" { + cookie.set_secure(true); + } + + cookies.add(cookie); +} diff --git a/src/application/server.rs b/src/application/server.rs index 873b591..bb22432 100644 --- a/src/application/server.rs +++ b/src/application/server.rs @@ -3,17 +3,20 @@ use std::sync::Arc; use anyhow::Context; use axum::Router; +use chrono::{Duration, Utc}; use tokio::net::TcpListener; use tokio::signal; use tracing::info; +use webauthn_rs::prelude::*; use crate::application::routes::app_router; +use crate::domain::registration_tokens::NewRegistrationToken; use crate::domain::repositories::{ - BagRepository, BrewRepository, CafeRepository, CupRepository, GearRepository, RoastRepository, - RoasterRepository, SessionRepository, TimelineEventRepository, TokenRepository, UserRepository, + BagRepository, BrewRepository, CafeRepository, CupRepository, GearRepository, + PasskeyCredentialRepository, RegistrationTokenRepository, RoastRepository, RoasterRepository, + SessionRepository, TimelineEventRepository, TokenRepository, UserRepository, }; -use crate::domain::users::NewUser; -use crate::infrastructure::auth::hash_password; +use crate::infrastructure::auth::{generate_session_token, hash_token}; use crate::infrastructure::backup::BackupService; use crate::infrastructure::database::Database; use crate::infrastructure::repositories::bags::SqlBagRepository; @@ -21,18 +24,21 @@ use crate::infrastructure::repositories::brews::SqlBrewRepository; use crate::infrastructure::repositories::cafes::SqlCafeRepository; use crate::infrastructure::repositories::cups::SqlCupRepository; use crate::infrastructure::repositories::gear::SqlGearRepository; +use crate::infrastructure::repositories::passkey_credentials::SqlPasskeyCredentialRepository; +use crate::infrastructure::repositories::registration_tokens::SqlRegistrationTokenRepository; use crate::infrastructure::repositories::roasters::SqlRoasterRepository; use crate::infrastructure::repositories::roasts::SqlRoastRepository; use crate::infrastructure::repositories::sessions::SqlSessionRepository; use crate::infrastructure::repositories::timeline_events::SqlTimelineEventRepository; use crate::infrastructure::repositories::tokens::SqlTokenRepository; use crate::infrastructure::repositories::users::SqlUserRepository; +use crate::infrastructure::webauthn::ChallengeStore; pub struct ServerConfig { pub bind_address: SocketAddr, pub database_url: String, - pub admin_password: Option, - pub admin_username: Option, + pub rp_id: String, + pub rp_origin: String, pub openrouter_api_key: String, pub openrouter_model: String, pub foursquare_api_key: String, @@ -51,6 +57,10 @@ pub struct AppState { pub user_repo: Arc, pub token_repo: Arc, pub session_repo: Arc, + pub passkey_repo: Arc, + pub registration_token_repo: Arc, + pub webauthn: Arc, + pub challenge_store: Arc, pub http_client: reqwest::Client, pub foursquare_url: String, pub foursquare_api_key: String, @@ -59,54 +69,19 @@ pub struct AppState { pub backup_service: Arc, } -impl AppState { - #[allow(clippy::too_many_arguments)] - pub fn new( - roaster_repo: Arc, - roast_repo: Arc, - bag_repo: Arc, - gear_repo: Arc, - brew_repo: Arc, - cafe_repo: Arc, - cup_repo: Arc, - timeline_repo: Arc, - user_repo: Arc, - token_repo: Arc, - session_repo: Arc, - http_client: reqwest::Client, - foursquare_url: String, - foursquare_api_key: String, - openrouter_api_key: String, - openrouter_model: String, - backup_service: Arc, - ) -> Self { - Self { - roaster_repo, - roast_repo, - bag_repo, - gear_repo, - brew_repo, - cafe_repo, - cup_repo, - timeline_repo, - user_repo, - token_repo, - session_repo, - http_client, - foursquare_url, - foursquare_api_key, - openrouter_api_key, - openrouter_model, - backup_service, - } - } -} - pub async fn serve(config: ServerConfig) -> anyhow::Result<()> { let database = Database::connect(&config.database_url) .await .context("failed to connect to database")?; - database.migrate().await?; + + let rp_origin = url::Url::parse(&config.rp_origin).context("invalid BREWLOG_RP_ORIGIN URL")?; + let webauthn = Arc::new( + WebauthnBuilder::new(&config.rp_id, &rp_origin) + .context("failed to build WebAuthn instance")? + .rp_name("Brewlog") + .build() + .context("failed to build WebAuthn instance")?, + ); let roaster_repo = Arc::new(SqlRoasterRepository::new(database.clone_pool())); let roast_repo = Arc::new(SqlRoastRepository::new(database.clone_pool())); @@ -122,13 +97,18 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> { Arc::new(SqlTokenRepository::new(database.clone_pool())); let session_repo: Arc = Arc::new(SqlSessionRepository::new(database.clone_pool())); + let passkey_repo: Arc = + Arc::new(SqlPasskeyCredentialRepository::new(database.clone_pool())); + let registration_token_repo: Arc = + Arc::new(SqlRegistrationTokenRepository::new(database.clone_pool())); let backup_service = Arc::new(BackupService::new(database.clone_pool())); + let challenge_store = Arc::new(ChallengeStore::new()); - // Bootstrap admin user if no users exist - bootstrap_admin_user(&user_repo, config.admin_username, config.admin_password).await?; + // Bootstrap: if no users exist, generate a one-time registration token + bootstrap_registration(®istration_token_repo, &user_repo, &config.rp_origin).await?; - let state = AppState::new( + let state = AppState { roaster_repo, roast_repo, bag_repo, @@ -140,13 +120,17 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> { user_repo, token_repo, session_repo, - reqwest::Client::new(), - crate::infrastructure::foursquare::FOURSQUARE_SEARCH_URL.to_string(), - config.foursquare_api_key, - config.openrouter_api_key, - config.openrouter_model, + passkey_repo, + registration_token_repo, + webauthn, + challenge_store, + http_client: reqwest::Client::new(), + foursquare_url: crate::infrastructure::foursquare::FOURSQUARE_SEARCH_URL.to_string(), + foursquare_api_key: config.foursquare_api_key, + openrouter_api_key: config.openrouter_api_key, + openrouter_model: config.openrouter_model, backup_service, - ); + }; let listener = TcpListener::bind(config.bind_address) .await @@ -154,7 +138,11 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> { let app: Router = app_router(state); - info!(address = %config.bind_address, "starting HTTP server"); + info!( + address = %config.bind_address, + database = %config.database_url, + "starting HTTP server" + ); axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) @@ -166,49 +154,39 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> { Ok(()) } -async fn bootstrap_admin_user( +async fn bootstrap_registration( + registration_token_repo: &Arc, user_repo: &Arc, - admin_username: Option, - admin_password: Option, + rp_origin: &str, ) -> anyhow::Result<()> { - // Check if any users exist let users_exist = user_repo .exists() .await .context("failed to check if users exist")?; if users_exist { - // Users already exist, no need to bootstrap return Ok(()); } - // No users exist - we need to create the admin user - let username = admin_username.ok_or_else(|| { - anyhow::anyhow!( - "No users exist in the database. Please provide BREWLOG_ADMIN_USERNAME \ - environment variable to create the admin user." - ) - })?; + // Generate one-time registration token + let token = generate_session_token(); + let token_hash = hash_token(&token); + let now = Utc::now(); + #[allow(clippy::expect_used)] + let expires_at = now + .checked_add_signed(Duration::hours(1)) + .expect("timestamp overflow adding 1 hour"); - let password = admin_password.ok_or_else(|| { - anyhow::anyhow!( - "No users exist in the database. Please provide BREWLOG_ADMIN_PASSWORD \ - environment variable to create the admin user." - ) - })?; + let new_token = NewRegistrationToken::new(token_hash, now, expires_at); - info!("No users found. Creating admin user '{}'...", username); - - let password_hash = hash_password(&password).context("failed to hash admin password")?; - - let admin_user = NewUser::new(username, password_hash); - - user_repo - .insert(admin_user) + registration_token_repo + .insert(new_token) .await - .context("failed to create admin user")?; + .context("failed to create registration token")?; - info!("Admin user created successfully"); + info!("No users found. Register the first user at:"); + info!(" {}/register/{}", rp_origin, token); + info!("This link expires in 1 hour."); Ok(()) } diff --git a/src/domain/ids.rs b/src/domain/ids.rs index cf7531c..50ebb42 100644 --- a/src/domain/ids.rs +++ b/src/domain/ids.rs @@ -59,3 +59,5 @@ define_id!(GearId); define_id!(BrewId); define_id!(CafeId); define_id!(CupId); +define_id!(PasskeyCredentialId); +define_id!(RegistrationTokenId); diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 63551ed..f53a80b 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -6,6 +6,8 @@ pub mod errors; pub mod gear; pub mod ids; pub mod listing; +pub mod passkey_credentials; +pub mod registration_tokens; pub mod repositories; pub mod roasters; pub mod roasts; diff --git a/src/domain/passkey_credentials.rs b/src/domain/passkey_credentials.rs new file mode 100644 index 0000000..69d50c7 --- /dev/null +++ b/src/domain/passkey_credentials.rs @@ -0,0 +1,31 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::domain::ids::{PasskeyCredentialId, UserId}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PasskeyCredential { + pub id: PasskeyCredentialId, + pub user_id: UserId, + pub credential_json: String, + pub name: String, + pub created_at: DateTime, + pub last_used_at: Option>, +} + +#[derive(Debug, Clone)] +pub struct NewPasskeyCredential { + pub user_id: UserId, + pub credential_json: String, + pub name: String, +} + +impl NewPasskeyCredential { + pub fn new(user_id: UserId, credential_json: String, name: String) -> Self { + Self { + user_id, + credential_json, + name, + } + } +} diff --git a/src/domain/registration_tokens.rs b/src/domain/registration_tokens.rs new file mode 100644 index 0000000..35ff8b0 --- /dev/null +++ b/src/domain/registration_tokens.rs @@ -0,0 +1,45 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::domain::ids::{RegistrationTokenId, UserId}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegistrationToken { + pub id: RegistrationTokenId, + pub token_hash: String, + pub created_at: DateTime, + pub expires_at: DateTime, + pub used_at: Option>, + pub used_by_user_id: Option, +} + +impl RegistrationToken { + pub fn is_expired(&self) -> bool { + Utc::now() > self.expires_at + } + + pub fn is_used(&self) -> bool { + self.used_at.is_some() + } + + pub fn is_valid(&self) -> bool { + !self.is_expired() && !self.is_used() + } +} + +#[derive(Debug, Clone)] +pub struct NewRegistrationToken { + pub token_hash: String, + pub created_at: DateTime, + pub expires_at: DateTime, +} + +impl NewRegistrationToken { + pub fn new(token_hash: String, created_at: DateTime, expires_at: DateTime) -> Self { + Self { + token_hash, + created_at, + expires_at, + } + } +} diff --git a/src/domain/repositories.rs b/src/domain/repositories.rs index 2c59ca7..3a50bda 100644 --- a/src/domain/repositories.rs +++ b/src/domain/repositories.rs @@ -7,8 +7,11 @@ use crate::domain::cafes::{Cafe, CafeSortKey, NewCafe, UpdateCafe}; use crate::domain::cups::{Cup, CupFilter, CupSortKey, CupWithDetails, NewCup, UpdateCup}; use crate::domain::gear::{Gear, GearFilter, GearSortKey, NewGear, UpdateGear}; use crate::domain::ids::{ - BagId, BrewId, CafeId, CupId, GearId, RoastId, RoasterId, SessionId, TokenId, UserId, + BagId, BrewId, CafeId, CupId, GearId, PasskeyCredentialId, RegistrationTokenId, RoastId, + RoasterId, SessionId, TokenId, UserId, }; +use crate::domain::passkey_credentials::{NewPasskeyCredential, PasskeyCredential}; +use crate::domain::registration_tokens::{NewRegistrationToken, RegistrationToken}; use crate::domain::roasters::RoasterSortKey; use crate::domain::roasters::{NewRoaster, Roaster, UpdateRoaster}; use crate::domain::roasts::RoastSortKey; @@ -107,7 +110,9 @@ pub trait UserRepository: Send + Sync { async fn insert(&self, user: NewUser) -> Result; async fn get(&self, id: UserId) -> Result; async fn get_by_username(&self, username: &str) -> Result; + async fn get_by_uuid(&self, uuid: &str) -> Result; async fn exists(&self) -> Result; + async fn list_all(&self) -> Result, RepositoryError>; } #[async_trait] @@ -219,3 +224,39 @@ pub trait CupRepository: Send + Sync { async fn update(&self, id: CupId, changes: UpdateCup) -> Result; async fn delete(&self, id: CupId) -> Result<(), RepositoryError>; } + +#[async_trait] +pub trait PasskeyCredentialRepository: Send + Sync { + async fn insert( + &self, + credential: NewPasskeyCredential, + ) -> Result; + async fn list_by_user( + &self, + user_id: UserId, + ) -> Result, RepositoryError>; + async fn update_credential_json( + &self, + id: PasskeyCredentialId, + credential_json: &str, + ) -> Result<(), RepositoryError>; + async fn update_last_used(&self, id: PasskeyCredentialId) -> Result<(), RepositoryError>; + async fn delete(&self, id: PasskeyCredentialId) -> Result<(), RepositoryError>; +} + +#[async_trait] +pub trait RegistrationTokenRepository: Send + Sync { + async fn insert( + &self, + token: NewRegistrationToken, + ) -> Result; + async fn get_by_token_hash( + &self, + token_hash: &str, + ) -> Result; + async fn mark_used( + &self, + id: RegistrationTokenId, + user_id: UserId, + ) -> Result<(), RepositoryError>; +} diff --git a/src/domain/users.rs b/src/domain/users.rs index 4e3f819..1bb749f 100644 --- a/src/domain/users.rs +++ b/src/domain/users.rs @@ -7,38 +7,29 @@ use crate::domain::ids::UserId; pub struct User { pub id: UserId, pub username: String, - #[serde(skip_serializing)] - pub password_hash: String, + pub uuid: String, pub created_at: DateTime, } #[derive(Debug, Clone)] pub struct NewUser { pub username: String, - pub password_hash: String, + pub uuid: String, } impl User { - pub fn new( - id: UserId, - username: String, - password_hash: String, - created_at: DateTime, - ) -> Self { + pub fn new(id: UserId, username: String, uuid: String, created_at: DateTime) -> Self { Self { id, username, - password_hash, + uuid, created_at, } } } impl NewUser { - pub fn new(username: String, password_hash: String) -> Self { - Self { - username, - password_hash, - } + pub fn new(username: String, uuid: String) -> Self { + Self { username, uuid } } } diff --git a/src/infrastructure/auth.rs b/src/infrastructure/auth.rs index b72f788..b5fbae1 100644 --- a/src/infrastructure/auth.rs +++ b/src/infrastructure/auth.rs @@ -1,38 +1,8 @@ use anyhow::Result; -use argon2::{ - Argon2, - password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, -}; use base64::{Engine as _, engine::general_purpose}; use rand::{RngCore, rngs::OsRng}; use sha2::{Digest, Sha256}; -/// Hashes a password using Argon2id with secure defaults -pub fn hash_password(password: &str) -> Result { - let salt = SaltString::generate(&mut OsRng); - let argon2 = Argon2::default(); - - let password_hash = argon2 - .hash_password(password.as_bytes(), &salt) - .map_err(|e| anyhow::anyhow!("failed to hash password: {e}"))? - .to_string(); - - Ok(password_hash) -} - -/// Verifies a password against a hash -pub fn verify_password(password: &str, password_hash: &str) -> Result { - let parsed_hash = PasswordHash::new(password_hash) - .map_err(|e| anyhow::anyhow!("failed to parse password hash: {e}"))?; - - let argon2 = Argon2::default(); - - match argon2.verify_password(password.as_bytes(), &parsed_hash) { - Ok(()) => Ok(true), - Err(_) => Ok(false), - } -} - /// Generates a cryptographically secure random token /// Returns a base64-encoded token string pub fn generate_token() -> Result { @@ -57,44 +27,17 @@ pub fn generate_session_token() -> String { } #[cfg(test)] -#[allow(clippy::unwrap_used)] // Tests: unwrap is acceptable for test assertions +#[allow(clippy::unwrap_used)] mod tests { use super::*; - #[test] - fn test_password_hashing() { - let password = "test_password_123"; - let hash = hash_password(password).unwrap(); - - assert!(verify_password(password, &hash).unwrap()); - assert!(!verify_password("wrong_password", &hash).unwrap()); - } - - #[test] - fn test_password_hashing_different_salts() { - let password = "test_password_123"; - let hash1 = hash_password(password).unwrap(); - let hash2 = hash_password(password).unwrap(); - - // Different salts should produce different hashes - assert_ne!(hash1, hash2); - - // But both should verify the same password - assert!(verify_password(password, &hash1).unwrap()); - assert!(verify_password(password, &hash2).unwrap()); - } - #[test] fn test_token_generation() { let token1 = generate_token().unwrap(); let token2 = generate_token().unwrap(); - // Tokens should be different assert_ne!(token1, token2); - - // Tokens should be base64 encoded (at least 40 chars for 32 bytes) assert!(token1.len() >= 40); - assert!(token2.len() >= 40); } #[test] @@ -103,12 +46,9 @@ mod tests { let hash1 = hash_token(token); let hash2 = hash_token(token); - // Same token should produce same hash assert_eq!(hash1, hash2); - // Different token should produce different hash - let different_token = "different_token"; - let hash3 = hash_token(different_token); + let hash3 = hash_token("different_token"); assert_ne!(hash1, hash3); } } diff --git a/src/infrastructure/client/mod.rs b/src/infrastructure/client/mod.rs index 9b9eda2..7db87b0 100644 --- a/src/infrastructure/client/mod.rs +++ b/src/infrastructure/client/mod.rs @@ -87,10 +87,6 @@ impl BrewlogClient { .with_context(|| format!("invalid API path: {path}")) } - pub(crate) fn http_client(&self) -> &Client { - &self.http - } - /// Build a request with authentication if token is available pub(crate) fn request(&self, method: reqwest::Method, url: Url) -> reqwest::RequestBuilder { let mut request = self.http.request(method, url); diff --git a/src/infrastructure/client/tokens.rs b/src/infrastructure/client/tokens.rs index fb9ac2a..f2ffae1 100644 --- a/src/infrastructure/client/tokens.rs +++ b/src/infrastructure/client/tokens.rs @@ -14,30 +14,6 @@ impl<'a> TokensClient<'a> { Self { client } } - pub async fn create( - &self, - username: &str, - password: &str, - name: &str, - ) -> Result { - let url = self.client.endpoint("api/v1/tokens")?; - let body = CreateTokenRequest { - username: username.to_string(), - password: password.to_string(), - name: name.to_string(), - }; - - let response = self - .client - .http_client() - .post(url) - .json(&body) - .send() - .await?; - - self.client.handle_response(response).await - } - pub async fn list(&self) -> Result> { let url = self.client.endpoint("api/v1/tokens")?; @@ -65,13 +41,6 @@ impl<'a> TokensClient<'a> { } } -#[derive(Debug, Serialize)] -struct CreateTokenRequest { - username: String, - password: String, - name: String, -} - #[derive(Debug, Deserialize)] pub struct TokenResponse { pub id: TokenId, diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs index eef1141..97d0134 100644 --- a/src/infrastructure/mod.rs +++ b/src/infrastructure/mod.rs @@ -5,3 +5,4 @@ pub mod client; pub mod database; pub mod foursquare; pub mod repositories; +pub mod webauthn; diff --git a/src/infrastructure/repositories/mod.rs b/src/infrastructure/repositories/mod.rs index ec72864..a825035 100644 --- a/src/infrastructure/repositories/mod.rs +++ b/src/infrastructure/repositories/mod.rs @@ -5,6 +5,8 @@ pub mod cups; pub mod gear; mod macros; pub mod pagination; +pub mod passkey_credentials; +pub mod registration_tokens; pub mod roasters; pub mod roasts; pub mod sessions; diff --git a/src/infrastructure/repositories/passkey_credentials.rs b/src/infrastructure/repositories/passkey_credentials.rs new file mode 100644 index 0000000..1eb79de --- /dev/null +++ b/src/infrastructure/repositories/passkey_credentials.rs @@ -0,0 +1,145 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use sqlx::{query, query_as}; + +use crate::domain::RepositoryError; +use crate::domain::ids::{PasskeyCredentialId, UserId}; +use crate::domain::passkey_credentials::{NewPasskeyCredential, PasskeyCredential}; +use crate::domain::repositories::PasskeyCredentialRepository; +use crate::infrastructure::database::DatabasePool; + +#[derive(Clone)] +pub struct SqlPasskeyCredentialRepository { + pool: DatabasePool, +} + +impl SqlPasskeyCredentialRepository { + pub fn new(pool: DatabasePool) -> Self { + Self { pool } + } + + fn to_domain(record: PasskeyCredentialRecord) -> PasskeyCredential { + let PasskeyCredentialRecord { + id, + user_id, + credential_json, + name, + created_at, + last_used_at, + } = record; + + PasskeyCredential { + id: PasskeyCredentialId::from(id), + user_id: UserId::from(user_id), + credential_json, + name, + created_at, + last_used_at, + } + } +} + +#[async_trait] +impl PasskeyCredentialRepository for SqlPasskeyCredentialRepository { + async fn insert( + &self, + credential: NewPasskeyCredential, + ) -> Result { + let sql = r" + INSERT INTO passkey_credentials (user_id, credential_json, name) + VALUES (?, ?, ?) + RETURNING id, user_id, credential_json, name, created_at, last_used_at + "; + + let record = query_as::<_, PasskeyCredentialRecord>(sql) + .bind(i64::from(credential.user_id)) + .bind(&credential.credential_json) + .bind(&credential.name) + .fetch_one(&self.pool) + .await + .map_err(|err| { + RepositoryError::unexpected(format!("failed to insert passkey credential: {err}")) + })?; + + Ok(Self::to_domain(record)) + } + + async fn list_by_user( + &self, + user_id: UserId, + ) -> Result, RepositoryError> { + let sql = r" + SELECT id, user_id, credential_json, name, created_at, last_used_at + FROM passkey_credentials + WHERE user_id = ? + ORDER BY created_at ASC + "; + + let records = query_as::<_, PasskeyCredentialRecord>(sql) + .bind(i64::from(user_id)) + .fetch_all(&self.pool) + .await + .map_err(|err| { + RepositoryError::unexpected(format!("failed to list passkey credentials: {err}")) + })?; + + Ok(records.into_iter().map(Self::to_domain).collect()) + } + + async fn update_credential_json( + &self, + id: PasskeyCredentialId, + credential_json: &str, + ) -> Result<(), RepositoryError> { + query("UPDATE passkey_credentials SET credential_json = ? WHERE id = ?") + .bind(credential_json) + .bind(i64::from(id)) + .execute(&self.pool) + .await + .map_err(|err| { + RepositoryError::unexpected(format!( + "failed to update passkey credential json: {err}" + )) + })?; + + Ok(()) + } + + async fn update_last_used(&self, id: PasskeyCredentialId) -> Result<(), RepositoryError> { + let now = Utc::now(); + query("UPDATE passkey_credentials SET last_used_at = ? WHERE id = ?") + .bind(now) + .bind(i64::from(id)) + .execute(&self.pool) + .await + .map_err(|err| { + RepositoryError::unexpected(format!( + "failed to update passkey credential last_used_at: {err}" + )) + })?; + + Ok(()) + } + + async fn delete(&self, id: PasskeyCredentialId) -> Result<(), RepositoryError> { + query("DELETE FROM passkey_credentials WHERE id = ?") + .bind(i64::from(id)) + .execute(&self.pool) + .await + .map_err(|err| { + RepositoryError::unexpected(format!("failed to delete passkey credential: {err}")) + })?; + + Ok(()) + } +} + +#[derive(sqlx::FromRow)] +struct PasskeyCredentialRecord { + id: i64, + user_id: i64, + credential_json: String, + name: String, + created_at: DateTime, + last_used_at: Option>, +} diff --git a/src/infrastructure/repositories/registration_tokens.rs b/src/infrastructure/repositories/registration_tokens.rs new file mode 100644 index 0000000..5cca96e --- /dev/null +++ b/src/infrastructure/repositories/registration_tokens.rs @@ -0,0 +1,121 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use sqlx::query_as; + +use crate::domain::RepositoryError; +use crate::domain::ids::{RegistrationTokenId, UserId}; +use crate::domain::registration_tokens::{NewRegistrationToken, RegistrationToken}; +use crate::domain::repositories::RegistrationTokenRepository; +use crate::infrastructure::database::DatabasePool; + +#[derive(Clone)] +pub struct SqlRegistrationTokenRepository { + pool: DatabasePool, +} + +impl SqlRegistrationTokenRepository { + pub fn new(pool: DatabasePool) -> Self { + Self { pool } + } + + fn to_domain(record: RegistrationTokenRecord) -> RegistrationToken { + let RegistrationTokenRecord { + id, + token_hash, + created_at, + expires_at, + used_at, + used_by_user_id, + } = record; + + RegistrationToken { + id: RegistrationTokenId::from(id), + token_hash, + created_at, + expires_at, + used_at, + used_by_user_id: used_by_user_id.map(UserId::from), + } + } +} + +#[async_trait] +impl RegistrationTokenRepository for SqlRegistrationTokenRepository { + async fn insert( + &self, + token: NewRegistrationToken, + ) -> Result { + let sql = r" + INSERT INTO registration_tokens (token_hash, created_at, expires_at) + VALUES (?, ?, ?) + RETURNING id, token_hash, created_at, expires_at, used_at, used_by_user_id + "; + + let record = query_as::<_, RegistrationTokenRecord>(sql) + .bind(&token.token_hash) + .bind(token.created_at) + .bind(token.expires_at) + .fetch_one(&self.pool) + .await + .map_err(|err| { + RepositoryError::unexpected(format!("failed to insert registration token: {err}")) + })?; + + Ok(Self::to_domain(record)) + } + + async fn get_by_token_hash( + &self, + token_hash: &str, + ) -> Result { + let sql = r" + SELECT id, token_hash, created_at, expires_at, used_at, used_by_user_id + FROM registration_tokens + WHERE token_hash = ? + "; + + let record = query_as::<_, RegistrationTokenRecord>(sql) + .bind(token_hash) + .fetch_optional(&self.pool) + .await + .map_err(|err| { + RepositoryError::unexpected(format!("failed to get registration token: {err}")) + })? + .ok_or(RepositoryError::NotFound)?; + + Ok(Self::to_domain(record)) + } + + async fn mark_used( + &self, + id: RegistrationTokenId, + user_id: UserId, + ) -> Result<(), RepositoryError> { + let now = Utc::now(); + let sql = "UPDATE registration_tokens SET used_at = ?, used_by_user_id = ? WHERE id = ?"; + + sqlx::query(sql) + .bind(now) + .bind(i64::from(user_id)) + .bind(i64::from(id)) + .execute(&self.pool) + .await + .map_err(|err| { + RepositoryError::unexpected(format!( + "failed to mark registration token as used: {err}" + )) + })?; + + Ok(()) + } +} + +#[derive(sqlx::FromRow)] +struct RegistrationTokenRecord { + id: i64, + token_hash: String, + created_at: DateTime, + expires_at: DateTime, + used_at: Option>, + used_by_user_id: Option, +} diff --git a/src/infrastructure/repositories/users.rs b/src/infrastructure/repositories/users.rs index 58238b2..3507c05 100644 --- a/src/infrastructure/repositories/users.rs +++ b/src/infrastructure/repositories/users.rs @@ -22,22 +22,22 @@ impl SqlUserRepository { let UserRecord { id, username, - password_hash, + uuid, created_at, } = record; - User::new(UserId::from(id), username, password_hash, created_at) + User::new(UserId::from(id), username, uuid, created_at) } } #[async_trait] impl UserRepository for SqlUserRepository { async fn insert(&self, user: NewUser) -> Result { - let query = "INSERT INTO users (username, password_hash) VALUES (?, ?) RETURNING id, username, password_hash, created_at"; + let query = "INSERT INTO users (username, uuid) VALUES (?, ?) RETURNING id, username, uuid, created_at"; let record = sqlx::query_as::<_, UserRecord>(query) .bind(&user.username) - .bind(&user.password_hash) + .bind(&user.uuid) .fetch_one(&self.pool) .await .map_err(|err| { @@ -53,7 +53,7 @@ impl UserRepository for SqlUserRepository { } async fn get(&self, id: UserId) -> Result { - let query = "SELECT id, username, password_hash, created_at FROM users WHERE id = ?"; + let query = "SELECT id, username, uuid, created_at FROM users WHERE id = ?"; let record = query_as::<_, UserRecord>(query) .bind(i64::from(id)) @@ -66,7 +66,7 @@ impl UserRepository for SqlUserRepository { } async fn get_by_username(&self, username: &str) -> Result { - let query = "SELECT id, username, password_hash, created_at FROM users WHERE username = ?"; + let query = "SELECT id, username, uuid, created_at FROM users WHERE username = ?"; let record = query_as::<_, UserRecord>(query) .bind(username) @@ -78,6 +78,19 @@ impl UserRepository for SqlUserRepository { Ok(Self::to_domain(record)) } + async fn get_by_uuid(&self, uuid: &str) -> Result { + let query = "SELECT id, username, uuid, created_at FROM users WHERE uuid = ?"; + + let record = query_as::<_, UserRecord>(query) + .bind(uuid) + .fetch_optional(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))? + .ok_or(RepositoryError::NotFound)?; + + Ok(Self::to_domain(record)) + } + async fn exists(&self) -> Result { let query = "SELECT COUNT(*) FROM users"; @@ -88,12 +101,23 @@ impl UserRepository for SqlUserRepository { Ok(count > 0) } + + async fn list_all(&self) -> Result, RepositoryError> { + let query = "SELECT id, username, uuid, created_at FROM users ORDER BY created_at ASC"; + + let records = query_as::<_, UserRecord>(query) + .fetch_all(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + Ok(records.into_iter().map(Self::to_domain).collect()) + } } #[derive(sqlx::FromRow)] struct UserRecord { id: i64, username: String, - password_hash: String, + uuid: String, created_at: DateTime, } diff --git a/src/infrastructure/webauthn.rs b/src/infrastructure/webauthn.rs new file mode 100644 index 0000000..38d3f77 --- /dev/null +++ b/src/infrastructure/webauthn.rs @@ -0,0 +1,118 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use chrono::{DateTime, Duration, Utc}; +use tokio::sync::RwLock; +use webauthn_rs::prelude::{PasskeyAuthentication, PasskeyRegistration}; + +use crate::domain::ids::UserId; + +/// Stores in-flight `WebAuthn` ceremony state between start/finish calls. +/// Entries expire after 5 minutes. +#[derive(Clone)] +pub struct ChallengeStore { + registrations: Arc>>, + authentications: Arc>>, +} + +struct RegistrationEntry { + pub user_id: UserId, + pub state: PasskeyRegistration, + pub expires_at: DateTime, +} + +struct AuthenticationEntry { + pub state: PasskeyAuthentication, + pub expires_at: DateTime, + pub cli_callback: Option, +} + +#[derive(Clone)] +pub struct CliCallbackInfo { + pub callback_url: String, + pub state: String, + pub token_name: String, +} + +const CHALLENGE_TTL_MINUTES: i64 = 5; + +impl Default for ChallengeStore { + fn default() -> Self { + Self::new() + } +} + +impl ChallengeStore { + pub fn new() -> Self { + Self { + registrations: Arc::new(RwLock::new(HashMap::new())), + authentications: Arc::new(RwLock::new(HashMap::new())), + } + } + + pub async fn store_registration( + &self, + challenge_id: String, + user_id: UserId, + state: PasskeyRegistration, + ) { + let entry = RegistrationEntry { + user_id, + state, + expires_at: Utc::now() + Duration::minutes(CHALLENGE_TTL_MINUTES), + }; + let mut map = self.registrations.write().await; + Self::cleanup_expired_registrations(&mut map); + map.insert(challenge_id, entry); + } + + pub async fn take_registration( + &self, + challenge_id: &str, + ) -> Option<(UserId, PasskeyRegistration)> { + let mut map = self.registrations.write().await; + let entry = map.remove(challenge_id)?; + if Utc::now() > entry.expires_at { + return None; + } + Some((entry.user_id, entry.state)) + } + + pub async fn store_authentication( + &self, + challenge_id: String, + state: PasskeyAuthentication, + cli_callback: Option, + ) { + let entry = AuthenticationEntry { + state, + expires_at: Utc::now() + Duration::minutes(CHALLENGE_TTL_MINUTES), + cli_callback, + }; + let mut map = self.authentications.write().await; + Self::cleanup_expired_authentications(&mut map); + map.insert(challenge_id, entry); + } + + pub async fn take_authentication( + &self, + challenge_id: &str, + ) -> Option<(PasskeyAuthentication, Option)> { + let mut map = self.authentications.write().await; + let entry = map.remove(challenge_id)?; + if Utc::now() > entry.expires_at { + return None; + } + Some((entry.state, entry.cli_callback)) + } + + fn cleanup_expired_registrations(map: &mut HashMap) { + let now = Utc::now(); + map.retain(|_, entry| entry.expires_at > now); + } + + fn cleanup_expired_authentications(map: &mut HashMap) { + let now = Utc::now(); + map.retain(|_, entry| entry.expires_at > now); + } +} diff --git a/src/main.rs b/src/main.rs index 95cdd58..ec78553 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,6 @@ use brewlog::presentation::cli::{ Cli, Commands, ServeCommand, bags, brews, cafes, cups, gear, roasters, roasts, tokens, }; use clap::Parser; - use tracing::{Subscriber, subscriber::set_global_default}; use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer}; use tracing_log::LogTracer; @@ -76,6 +75,20 @@ async fn main() -> Result<()> { } async fn run_server(command: ServeCommand) -> Result<()> { + let rp_id = command.rp_id.ok_or_else(|| { + anyhow::anyhow!( + "BREWLOG_RP_ID is required. Set this to the domain where the app is hosted \ + (e.g. 'brewlog.example.com' or 'localhost')." + ) + })?; + + let rp_origin = command.rp_origin.ok_or_else(|| { + anyhow::anyhow!( + "BREWLOG_RP_ORIGIN is required. Set this to the full origin URL \ + (e.g. 'https://brewlog.example.com' or 'http://localhost:3000')." + ) + })?; + let openrouter_api_key = command.openrouter_api_key.ok_or_else(|| { anyhow::anyhow!( "BREWLOG_OPENROUTER_API_KEY is required. Set this environment variable \ @@ -93,8 +106,8 @@ async fn run_server(command: ServeCommand) -> Result<()> { let config = ServerConfig { bind_address: command.bind_address, database_url: command.database_url, - admin_password: command.admin_password, - admin_username: command.admin_username, + rp_id, + rp_origin, openrouter_api_key, openrouter_model: command.openrouter_model, foursquare_api_key, diff --git a/src/presentation/cli/mod.rs b/src/presentation/cli/mod.rs index 9ca3b42..684f76a 100644 --- a/src/presentation/cli/mod.rs +++ b/src/presentation/cli/mod.rs @@ -29,7 +29,7 @@ pub struct Cli { long, global = true, env = "BREWLOG_URL", - default_value = "http://127.0.0.1:3000" + default_value = "http://localhost:3000" )] pub api_url: String, @@ -109,11 +109,11 @@ pub struct ServeCommand { #[arg(long, env = "BREWLOG_BIND_ADDRESS", default_value = "127.0.0.1:3000")] pub bind_address: SocketAddr, - #[arg(long, env = "BREWLOG_ADMIN_PASSWORD")] - pub admin_password: Option, + #[arg(long, env = "BREWLOG_RP_ID")] + pub rp_id: Option, - #[arg(long, env = "BREWLOG_ADMIN_USERNAME")] - pub admin_username: Option, + #[arg(long, env = "BREWLOG_RP_ORIGIN")] + pub rp_origin: Option, #[arg(long, env = "BREWLOG_OPENROUTER_API_KEY")] pub openrouter_api_key: Option, diff --git a/src/presentation/cli/tokens.rs b/src/presentation/cli/tokens.rs index b694102..4edf5e7 100644 --- a/src/presentation/cli/tokens.rs +++ b/src/presentation/cli/tokens.rs @@ -1,14 +1,18 @@ -use anyhow::{Context, Result}; +use std::net::SocketAddr; + +use anyhow::{Context, Result, anyhow}; use clap::{Args, Subcommand}; -use std::io::{self, Write}; +use tokio::net::TcpListener; +use tokio::sync::oneshot; use super::print_json; use crate::domain::ids::TokenId; +use crate::infrastructure::auth::generate_session_token; use crate::infrastructure::client::BrewlogClient; #[derive(Debug, Subcommand)] pub enum TokenCommands { - /// Create a new API token + /// Create a new API token (opens browser for passkey authentication) Create(CreateTokenCommand), /// List all tokens List, @@ -29,14 +33,6 @@ pub struct CreateTokenCommand { /// A descriptive name for this token #[arg(long)] pub name: String, - - /// The username to authenticate with - #[arg(long)] - pub username: Option, - - /// The password to authenticate with - #[arg(long)] - pub password: Option, } #[derive(Debug, Args)] @@ -47,41 +43,130 @@ pub struct RevokeTokenCommand { } pub async fn create_token(client: &BrewlogClient, cmd: CreateTokenCommand) -> Result<()> { - let username = if let Some(u) = cmd.username { - u - } else { - // Prompt for username - print!("Username: "); - io::stdout().flush()?; - let mut username = String::new(); - io::stdin().read_line(&mut username)?; - username.trim().to_string() + let state = generate_session_token(); + + // Start a local server on a random port to receive the callback + let listener = TcpListener::bind("127.0.0.1:0") + .await + .context("failed to bind local callback server")?; + let local_addr = listener + .local_addr() + .context("failed to get local callback address")?; + + let callback_url = format!("http://127.0.0.1:{}/callback", local_addr.port()); + + // Build the browser URL + let mut server_url = client + .endpoint("login") + .context("failed to build login URL")?; + server_url + .query_pairs_mut() + .append_pair("cli_callback", &callback_url) + .append_pair("state", &state) + .append_pair("token_name", &cmd.name); + + println!("Opening browser for authentication..."); + println!("If the browser doesn't open, visit this URL:"); + println!("\n {server_url}\n"); + + // Open the browser + if let Err(err) = open::that(server_url.as_str()) { + eprintln!("Warning: failed to open browser: {err}"); + } + + // Wait for the callback + let (tx, rx) = oneshot::channel::(); + let expected_state = state.clone(); + + let server = tokio::spawn(run_callback_server( + listener, + local_addr, + expected_state, + tx, + )); + + // Wait for the token with a timeout + let token = tokio::select! { + result = rx => { + result.context("callback server closed without receiving a token")? + } + () = tokio::time::sleep(std::time::Duration::from_secs(120)) => { + return Err(anyhow!("timed out waiting for browser authentication (2 minutes)")); + } }; - let password = if let Some(p) = cmd.password { - p - } else { - // Prompt for password (without echo) - rpassword::prompt_password("Password: ").context("failed to read password")? - }; + // Clean up the server task + server.abort(); - // Create the token - let token_response = client - .tokens() - .create(&username, &password, &cmd.name) - .await?; - - println!("\nToken created successfully!"); - println!("Token ID: {}", token_response.id); - println!("Token Name: {}", token_response.name); - println!("\n⚠️ Save this token securely - it will not be shown again:"); - println!("\n{}", token_response.token); + println!("Token created successfully!"); + println!("Token Name: {}", cmd.name); + println!("\nSave this token securely - it will not be shown again:"); + println!("\n{token}"); println!("\nExport it in your environment:"); - println!(" export BREWLOG_TOKEN={}", token_response.token); + println!(" export BREWLOG_TOKEN={token}"); Ok(()) } +async fn run_callback_server( + listener: TcpListener, + _addr: SocketAddr, + expected_state: String, + tx: oneshot::Sender, +) { + use axum::extract::Query; + use axum::response::Html; + use axum::routing::get; + use serde::Deserialize; + + #[derive(Deserialize)] + struct CallbackQuery { + token: Option, + state: Option, + } + + let tx = std::sync::Arc::new(tokio::sync::Mutex::new(Some(tx))); + let state_clone = expected_state.clone(); + + let app = axum::Router::new().route( + "/callback", + get(move |Query(query): Query| { + let tx = tx.clone(); + let expected = state_clone.clone(); + async move { + let Some(token) = query.token else { + return Html( + "

Error

No token received.

" + .to_string(), + ); + }; + + let Some(received_state) = query.state else { + return Html( + "

Error

No state parameter.

" + .to_string(), + ); + }; + + if received_state != expected { + return Html( + "

Error

State mismatch - possible CSRF attack.

" + .to_string(), + ); + } + + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(token); + } + + Html("

Authenticated

You can close this window and return to the terminal.

".to_string()) + } + }), + ); + + let _ = axum::serve(listener, app).await; +} + pub async fn list_tokens(client: &BrewlogClient) -> Result<()> { let tokens = client.tokens().list().await?; print_json(&tokens) diff --git a/templates/cli_callback.html b/templates/cli_callback.html new file mode 100644 index 0000000..08e8185 --- /dev/null +++ b/templates/cli_callback.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}Brewlog · CLI Authentication{% endblock %} +{% block content %} +
+
+ {% if token.is_some() %} +

Authenticated

+

+ Your CLI has been authenticated. You can close this window. +

+ {% else if error.is_some() %} +

Authentication Failed

+
+ {{ error.as_ref().unwrap() }} +
+ {% else %} +

CLI Authentication

+

+ Processing authentication... +

+ {% endif %} +
+
+{% endblock %} diff --git a/templates/login.html b/templates/login.html index cc8bac6..5cac581 100644 --- a/templates/login.html +++ b/templates/login.html @@ -1,49 +1,79 @@ -{% extends "base.html" %} -{% block title %}Brewlog · Login{% endblock %} +{% extends "base.html" %} +{% block title %}Brewlog · Login{% endblock %} +{% block head %} + +{% endblock %} {% block content %}

Login

- Sign in to manage your roasters and roasts. + Sign in with your passkey to manage your coffee log.

- {% if error.is_some() %} -
- {{ error.as_ref().unwrap() }} + + + - {% endif %} - -
- - - +
- + + +
+ + {% endblock %} diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..8af7e2b --- /dev/null +++ b/templates/register.html @@ -0,0 +1,104 @@ +{% extends "base.html" %} +{% block title %}Brewlog · Register{% endblock %} +{% block head %} + +{% endblock %} +{% block content %} +
+
+

Register

+

+ Create your account by registering a passkey. This will be used to sign in going forward. +

+ + + + + +
+ + + + + +
+ + +
+
+ + +{% endblock %} diff --git a/templates/webauthn.js b/templates/webauthn.js new file mode 100644 index 0000000..69f8ad3 --- /dev/null +++ b/templates/webauthn.js @@ -0,0 +1,147 @@ +// Base64url encoding/decoding helpers for WebAuthn +function base64urlToBuffer(base64url) { + const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} + +function bufferToBase64url(buffer) { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +// Convert server challenge options to format navigator.credentials expects +function prepareCreationOptions(options) { + const publicKey = options.publicKey; + publicKey.challenge = base64urlToBuffer(publicKey.challenge); + publicKey.user.id = base64urlToBuffer(publicKey.user.id); + if (publicKey.excludeCredentials) { + publicKey.excludeCredentials = publicKey.excludeCredentials.map(function (cred) { + return Object.assign({}, cred, { id: base64urlToBuffer(cred.id) }); + }); + } + return options; +} + +function prepareRequestOptions(options) { + const publicKey = options.publicKey; + publicKey.challenge = base64urlToBuffer(publicKey.challenge); + if (publicKey.allowCredentials) { + publicKey.allowCredentials = publicKey.allowCredentials.map(function (cred) { + return Object.assign({}, cred, { id: base64urlToBuffer(cred.id) }); + }); + } + return options; +} + +// Serialize credential for sending back to server +function serializeRegistrationCredential(credential) { + const response = credential.response; + return { + id: credential.id, + rawId: bufferToBase64url(credential.rawId), + type: credential.type, + response: { + attestationObject: bufferToBase64url(response.attestationObject), + clientDataJSON: bufferToBase64url(response.clientDataJSON), + }, + }; +} + +function serializeAuthenticationCredential(credential) { + const response = credential.response; + return { + id: credential.id, + rawId: bufferToBase64url(credential.rawId), + type: credential.type, + response: { + authenticatorData: bufferToBase64url(response.authenticatorData), + clientDataJSON: bufferToBase64url(response.clientDataJSON), + signature: bufferToBase64url(response.signature), + userHandle: response.userHandle ? bufferToBase64url(response.userHandle) : null, + }, + }; +} + +// Start passkey registration ceremony +async function startPasskeyRegistration(token, displayName) { + // 1. Get challenge from server + const startResponse = await fetch("/api/v1/webauthn/register/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, display_name: displayName }), + }); + + if (!startResponse.ok) { + const status = startResponse.status; + if (status === 401) throw new Error("Invalid registration token."); + if (status === 410) throw new Error("Registration token has expired or already been used."); + throw new Error("Failed to start registration (HTTP " + status + ")."); + } + + const { challenge_id, options } = await startResponse.json(); + + // 2. Create credential via browser WebAuthn API + const creationOptions = prepareCreationOptions(options); + const credential = await navigator.credentials.create(creationOptions); + + // 3. Send credential to server + const finishResponse = await fetch("/api/v1/webauthn/register/finish", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + challenge_id, + credential: serializeRegistrationCredential(credential), + }), + }); + + if (!finishResponse.ok) { + throw new Error("Failed to complete registration (HTTP " + finishResponse.status + ")."); + } + + return finishResponse.json(); +} + +// Start passkey authentication ceremony +async function startPasskeyAuthentication(queryParams) { + // 1. Get challenge from server + const url = "/api/v1/webauthn/auth/start" + (queryParams || ""); + const startResponse = await fetch(url); + + if (!startResponse.ok) { + const status = startResponse.status; + if (status === 404) throw new Error("No passkeys registered. Please register first."); + throw new Error("Failed to start authentication (HTTP " + status + ")."); + } + + const { challenge_id, options } = await startResponse.json(); + + // 2. Get assertion via browser WebAuthn API + const requestOptions = prepareRequestOptions(options); + const credential = await navigator.credentials.get(requestOptions); + + // 3. Send assertion to server + const finishResponse = await fetch("/api/v1/webauthn/auth/finish", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + challenge_id, + credential: serializeAuthenticationCredential(credential), + }), + }); + + if (!finishResponse.ok) { + throw new Error("Authentication failed (HTTP " + finishResponse.status + ")."); + } + + return finishResponse.json(); +}