feat(auth): replace password auth with WebAuthn passkeys

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)
This commit is contained in:
Jon Seager 2026-02-05 11:00:07 +00:00
parent 8c521f0e05
commit 03e03d87d9
No known key found for this signature in database
31 changed files with 2038 additions and 480 deletions

494
Cargo.lock generated
View file

@ -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"

View file

@ -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]]

View file

@ -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);

View file

@ -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;

View file

@ -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<String>,
}
#[derive(Template)]
#[template(path = "login.html")]
struct LoginTemplate {
nav_active: &'static str,
is_authenticated: bool,
error: Option<String>,
}
#[derive(Deserialize)]
pub struct LoginForm {
username: String,
password: String,
}
#[tracing::instrument(skip(state, cookies))]
pub(crate) async fn login_page(
State(state): State<AppState>,
cookies: Cookies,
Query(query): Query<LoginQuery>,
) -> Result<Response, StatusCode> {
// 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<AppState>,
cookies: Cookies,
Form(form): Form<LoginForm>,
) -> Result<Response, StatusCode> {
// 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<AppState>, 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<AppState>, cookies: Cookies) -> R
Redirect::to("/")
}
fn show_login_error(message: &str) -> Result<Response, StatusCode> {
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)

View file

@ -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")],

View file

@ -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<Token> 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<AppState>,
auth_user: AuthenticatedUser,
Json(payload): Json<CreateTokenRequest>,
) -> Result<Json<CreateTokenResponse>, 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)

View file

@ -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<String>,
error: Option<String>,
}
// --- Request/Response types ---
#[derive(Deserialize)]
pub struct RegisterStartRequest {
pub token: String,
pub display_name: String,
}
#[derive(Serialize)]
pub struct ChallengeResponse<T: Serialize> {
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<String>,
pub state: Option<String>,
pub token_name: Option<String>,
}
#[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<String>,
}
// --- Registration page (bootstrap flow) ---
#[tracing::instrument(skip(state))]
pub(crate) async fn register_page(
State(state): State<AppState>,
Path(token): Path<String>,
) -> Result<Response, StatusCode> {
// 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<AppState>,
Json(payload): Json<RegisterStartRequest>,
) -> Result<Json<ChallengeResponse<CreationChallengeResponse>>, 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<AppState>,
cookies: Cookies,
Json(payload): Json<RegisterFinishRequest>,
) -> Result<Json<AuthFinishResponse>, 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, &reg_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<AppState>,
Query(query): Query<AuthStartQuery>,
) -> Result<Json<AuthStartResponse>, 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<Passkey> = 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<AppState>,
cookies: Cookies,
Json(payload): Json<AuthFinishRequest>,
) -> Result<Json<AuthFinishResponse>, 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<Passkey> = 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<Response, StatusCode> {
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);
}

View file

@ -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<String>,
pub admin_username: Option<String>,
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<dyn UserRepository>,
pub token_repo: Arc<dyn TokenRepository>,
pub session_repo: Arc<dyn SessionRepository>,
pub passkey_repo: Arc<dyn PasskeyCredentialRepository>,
pub registration_token_repo: Arc<dyn RegistrationTokenRepository>,
pub webauthn: Arc<Webauthn>,
pub challenge_store: Arc<ChallengeStore>,
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<BackupService>,
}
impl AppState {
#[allow(clippy::too_many_arguments)]
pub fn new(
roaster_repo: Arc<dyn RoasterRepository>,
roast_repo: Arc<dyn RoastRepository>,
bag_repo: Arc<dyn BagRepository>,
gear_repo: Arc<dyn GearRepository>,
brew_repo: Arc<dyn BrewRepository>,
cafe_repo: Arc<dyn CafeRepository>,
cup_repo: Arc<dyn CupRepository>,
timeline_repo: Arc<dyn TimelineEventRepository>,
user_repo: Arc<dyn UserRepository>,
token_repo: Arc<dyn TokenRepository>,
session_repo: Arc<dyn SessionRepository>,
http_client: reqwest::Client,
foursquare_url: String,
foursquare_api_key: String,
openrouter_api_key: String,
openrouter_model: String,
backup_service: Arc<BackupService>,
) -> 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<dyn SessionRepository> =
Arc::new(SqlSessionRepository::new(database.clone_pool()));
let passkey_repo: Arc<dyn PasskeyCredentialRepository> =
Arc::new(SqlPasskeyCredentialRepository::new(database.clone_pool()));
let registration_token_repo: Arc<dyn RegistrationTokenRepository> =
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(&registration_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<dyn RegistrationTokenRepository>,
user_repo: &Arc<dyn UserRepository>,
admin_username: Option<String>,
admin_password: Option<String>,
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(())
}

View file

@ -59,3 +59,5 @@ define_id!(GearId);
define_id!(BrewId);
define_id!(CafeId);
define_id!(CupId);
define_id!(PasskeyCredentialId);
define_id!(RegistrationTokenId);

View file

@ -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;

View file

@ -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<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
}
#[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,
}
}
}

View file

@ -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<Utc>,
pub expires_at: DateTime<Utc>,
pub used_at: Option<DateTime<Utc>>,
pub used_by_user_id: Option<UserId>,
}
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<Utc>,
pub expires_at: DateTime<Utc>,
}
impl NewRegistrationToken {
pub fn new(token_hash: String, created_at: DateTime<Utc>, expires_at: DateTime<Utc>) -> Self {
Self {
token_hash,
created_at,
expires_at,
}
}
}

View file

@ -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<User, RepositoryError>;
async fn get(&self, id: UserId) -> Result<User, RepositoryError>;
async fn get_by_username(&self, username: &str) -> Result<User, RepositoryError>;
async fn get_by_uuid(&self, uuid: &str) -> Result<User, RepositoryError>;
async fn exists(&self) -> Result<bool, RepositoryError>;
async fn list_all(&self) -> Result<Vec<User>, RepositoryError>;
}
#[async_trait]
@ -219,3 +224,39 @@ pub trait CupRepository: Send + Sync {
async fn update(&self, id: CupId, changes: UpdateCup) -> Result<Cup, RepositoryError>;
async fn delete(&self, id: CupId) -> Result<(), RepositoryError>;
}
#[async_trait]
pub trait PasskeyCredentialRepository: Send + Sync {
async fn insert(
&self,
credential: NewPasskeyCredential,
) -> Result<PasskeyCredential, RepositoryError>;
async fn list_by_user(
&self,
user_id: UserId,
) -> Result<Vec<PasskeyCredential>, 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<RegistrationToken, RepositoryError>;
async fn get_by_token_hash(
&self,
token_hash: &str,
) -> Result<RegistrationToken, RepositoryError>;
async fn mark_used(
&self,
id: RegistrationTokenId,
user_id: UserId,
) -> Result<(), RepositoryError>;
}

View file

@ -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<Utc>,
}
#[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<Utc>,
) -> Self {
pub fn new(id: UserId, username: String, uuid: String, created_at: DateTime<Utc>) -> 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 }
}
}

View file

@ -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<String> {
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<bool> {
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<String> {
@ -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);
}
}

View file

@ -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);

View file

@ -14,30 +14,6 @@ impl<'a> TokensClient<'a> {
Self { client }
}
pub async fn create(
&self,
username: &str,
password: &str,
name: &str,
) -> Result<TokenResponse> {
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<Vec<TokenInfo>> {
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,

View file

@ -5,3 +5,4 @@ pub mod client;
pub mod database;
pub mod foursquare;
pub mod repositories;
pub mod webauthn;

View file

@ -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;

View file

@ -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<PasskeyCredential, RepositoryError> {
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<Vec<PasskeyCredential>, 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<Utc>,
last_used_at: Option<DateTime<Utc>>,
}

View file

@ -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<RegistrationToken, RepositoryError> {
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<RegistrationToken, RepositoryError> {
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<Utc>,
expires_at: DateTime<Utc>,
used_at: Option<DateTime<Utc>>,
used_by_user_id: Option<i64>,
}

View file

@ -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<User, RepositoryError> {
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<User, RepositoryError> {
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<User, RepositoryError> {
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<User, RepositoryError> {
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<bool, RepositoryError> {
let query = "SELECT COUNT(*) FROM users";
@ -88,12 +101,23 @@ impl UserRepository for SqlUserRepository {
Ok(count > 0)
}
async fn list_all(&self) -> Result<Vec<User>, 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<Utc>,
}

View file

@ -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<RwLock<HashMap<String, RegistrationEntry>>>,
authentications: Arc<RwLock<HashMap<String, AuthenticationEntry>>>,
}
struct RegistrationEntry {
pub user_id: UserId,
pub state: PasskeyRegistration,
pub expires_at: DateTime<Utc>,
}
struct AuthenticationEntry {
pub state: PasskeyAuthentication,
pub expires_at: DateTime<Utc>,
pub cli_callback: Option<CliCallbackInfo>,
}
#[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<CliCallbackInfo>,
) {
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<CliCallbackInfo>)> {
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<String, RegistrationEntry>) {
let now = Utc::now();
map.retain(|_, entry| entry.expires_at > now);
}
fn cleanup_expired_authentications(map: &mut HashMap<String, AuthenticationEntry>) {
let now = Utc::now();
map.retain(|_, entry| entry.expires_at > now);
}
}

View file

@ -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,

View file

@ -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<String>,
#[arg(long, env = "BREWLOG_RP_ID")]
pub rp_id: Option<String>,
#[arg(long, env = "BREWLOG_ADMIN_USERNAME")]
pub admin_username: Option<String>,
#[arg(long, env = "BREWLOG_RP_ORIGIN")]
pub rp_origin: Option<String>,
#[arg(long, env = "BREWLOG_OPENROUTER_API_KEY")]
pub openrouter_api_key: Option<String>,

View file

@ -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<String>,
/// The password to authenticate with
#[arg(long)]
pub password: Option<String>,
}
#[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::<String>();
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<String>,
) {
use axum::extract::Query;
use axum::response::Html;
use axum::routing::get;
use serde::Deserialize;
#[derive(Deserialize)]
struct CallbackQuery {
token: Option<String>,
state: Option<String>,
}
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<CallbackQuery>| {
let tx = tx.clone();
let expected = state_clone.clone();
async move {
let Some(token) = query.token else {
return Html(
"<html><body><h1>Error</h1><p>No token received.</p></body></html>"
.to_string(),
);
};
let Some(received_state) = query.state else {
return Html(
"<html><body><h1>Error</h1><p>No state parameter.</p></body></html>"
.to_string(),
);
};
if received_state != expected {
return Html(
"<html><body><h1>Error</h1><p>State mismatch - possible CSRF attack.</p></body></html>"
.to_string(),
);
}
if let Some(sender) = tx.lock().await.take() {
let _ = sender.send(token);
}
Html("<html><body><h1>Authenticated</h1><p>You can close this window and return to the terminal.</p></body></html>".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)

View file

@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}Brewlog · CLI Authentication{% endblock %}
{% block content %}
<div class="mx-auto max-w-md">
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-6 shadow-sm">
{% if token.is_some() %}
<h1 class="text-2xl font-semibold text-green-700">Authenticated</h1>
<p class="mt-2 text-sm text-stone-600">
Your CLI has been authenticated. You can close this window.
</p>
{% else if error.is_some() %}
<h1 class="text-2xl font-semibold text-red-700">Authentication Failed</h1>
<div class="mt-4 rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800">
{{ error.as_ref().unwrap() }}
</div>
{% else %}
<h1 class="text-2xl font-semibold text-amber-700">CLI Authentication</h1>
<p class="mt-2 text-sm text-stone-600">
Processing authentication...
</p>
{% endif %}
</div>
</div>
{% endblock %}

View file

@ -1,49 +1,79 @@
{% extends "base.html" %}
{% block title %}Brewlog · Login{% endblock %}
{% extends "base.html" %}
{% block title %}Brewlog · Login{% endblock %}
{% block head %}
<script src="/webauthn.js"></script>
{% endblock %}
{% block content %}
<div class="mx-auto max-w-md">
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-6 shadow-sm">
<h1 class="text-2xl font-semibold text-amber-700">Login</h1>
<p class="mt-2 text-sm text-stone-600">
Sign in to manage your roasters and roasts.
Sign in with your passkey to manage your coffee log.
</p>
{% if error.is_some() %}
<div class="mt-4 rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800">
{{ error.as_ref().unwrap() }}
<div id="login-error" class="mt-4 hidden rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800"></div>
<div id="login-unsupported" class="mt-4 hidden rounded-md bg-yellow-100 border border-yellow-300 p-3 text-sm text-yellow-800">
Your browser does not support passkeys. Please use a modern browser (Chrome, Firefox, Safari, or Edge).
</div>
{% endif %}
<form method="post" action="/login" class="mt-6 flex flex-col gap-4">
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Username</span>
<input
type="text"
name="username"
required
autofocus
class="input-field"
placeholder="admin"
/>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Password</span>
<input
type="password"
name="password"
required
class="input-field"
/>
</label>
<div class="mt-6">
<button
type="submit"
class="mt-2 rounded-md bg-amber-600 px-4 py-3 text-sm font-semibold text-amber-50 transition hover:bg-amber-500"
id="login-button"
type="button"
class="w-full rounded-md bg-amber-600 px-4 py-3 text-sm font-semibold text-amber-50 transition hover:bg-amber-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
Sign In
Sign in with Passkey
</button>
</form>
<div id="login-loading" class="mt-4 hidden text-center text-sm text-stone-500">
<p>Waiting for passkey...</p>
</div>
</div>
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", function () {
const button = document.getElementById("login-button");
const errorDiv = document.getElementById("login-error");
const loadingDiv = document.getElementById("login-loading");
const unsupportedDiv = document.getElementById("login-unsupported");
// Check WebAuthn support
if (!window.PublicKeyCredential) {
button.disabled = true;
unsupportedDiv.classList.remove("hidden");
return;
}
// Check for CLI callback query params
const params = new URLSearchParams(window.location.search);
let queryString = "";
if (params.has("cli_callback")) {
queryString = "?" + params.toString();
}
button.addEventListener("click", async function () {
errorDiv.classList.add("hidden");
loadingDiv.classList.remove("hidden");
button.disabled = true;
try {
const result = await startPasskeyAuthentication(queryString);
if (result.redirect) {
window.location.href = result.redirect;
} else {
window.location.href = "/";
}
} catch (err) {
errorDiv.textContent = err.message;
errorDiv.classList.remove("hidden");
loadingDiv.classList.add("hidden");
button.disabled = false;
}
});
});
</script>
{% endblock %}

104
templates/register.html Normal file
View file

@ -0,0 +1,104 @@
{% extends "base.html" %}
{% block title %}Brewlog · Register{% endblock %}
{% block head %}
<script src="/webauthn.js"></script>
{% endblock %}
{% block content %}
<div class="mx-auto max-w-md">
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-6 shadow-sm">
<h1 class="text-2xl font-semibold text-amber-700">Register</h1>
<p class="mt-2 text-sm text-stone-600">
Create your account by registering a passkey. This will be used to sign in going forward.
</p>
<div id="register-error" class="mt-4 hidden rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800"></div>
<div id="register-unsupported" class="mt-4 hidden rounded-md bg-yellow-100 border border-yellow-300 p-3 text-sm text-yellow-800">
Your browser does not support passkeys. Please use a modern browser (Chrome, Firefox, Safari, or Edge).
</div>
<div id="register-form" class="mt-6 flex flex-col gap-4">
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Display Name</span>
<input
type="text"
id="display-name"
required
autofocus
class="input-field"
placeholder="Your name"
/>
</label>
<button
id="register-button"
type="button"
class="mt-2 w-full rounded-md bg-amber-600 px-4 py-3 text-sm font-semibold text-amber-50 transition hover:bg-amber-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
Register Passkey
</button>
<div id="register-loading" class="mt-2 hidden text-center text-sm text-stone-500">
<p>Follow the prompts from your browser or device...</p>
</div>
</div>
<div id="register-success" class="mt-6 hidden">
<div class="rounded-md bg-green-100 border border-green-300 p-4 text-sm text-green-800">
<p class="font-semibold">Registration successful!</p>
<p class="mt-1">Your passkey has been registered and you are now signed in.</p>
</div>
<a
href="/"
class="mt-4 block w-full rounded-md bg-amber-600 px-4 py-3 text-center text-sm font-semibold text-amber-50 transition hover:bg-amber-500"
>
Go to Brewlog
</a>
</div>
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", function () {
const token = "{{ token }}";
const button = document.getElementById("register-button");
const displayNameInput = document.getElementById("display-name");
const errorDiv = document.getElementById("register-error");
const loadingDiv = document.getElementById("register-loading");
const unsupportedDiv = document.getElementById("register-unsupported");
const formDiv = document.getElementById("register-form");
const successDiv = document.getElementById("register-success");
// Check WebAuthn support
if (!window.PublicKeyCredential) {
button.disabled = true;
unsupportedDiv.classList.remove("hidden");
return;
}
button.addEventListener("click", async function () {
const displayName = displayNameInput.value.trim();
if (!displayName) {
errorDiv.textContent = "Please enter a display name.";
errorDiv.classList.remove("hidden");
return;
}
errorDiv.classList.add("hidden");
loadingDiv.classList.remove("hidden");
button.disabled = true;
try {
await startPasskeyRegistration(token, displayName);
formDiv.classList.add("hidden");
successDiv.classList.remove("hidden");
} catch (err) {
errorDiv.textContent = err.message;
errorDiv.classList.remove("hidden");
loadingDiv.classList.add("hidden");
button.disabled = false;
}
});
});
</script>
{% endblock %}

147
templates/webauthn.js Normal file
View file

@ -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();
}