diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index 1f8d4d68..ed263262 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -54,6 +54,7 @@ jobs: uses: Swatinem/rust-cache@v2 with: cache-on-failure: true + shared-key: "rust-test-suite" # Cache cargo-nextest and insta separately to avoid reinstalling - name: Install nextest diff --git a/Cargo.lock b/Cargo.lock index 406f3b4c..42c0268d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -212,6 +212,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core", + "axum-macros", "base64 0.22.1", "bytes", "form_urlencoded", @@ -260,12 +261,70 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef252edff26ddba56bbcdf2ee3307b8129acb86f5749b68990c168a6fcc9c76" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-core", + "futures-util", + "headers", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-jwt-auth" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26502f886f3cda7cf02edfe0a5d34ea6a4a6db56850b7ae075d224ccbed08c61" +dependencies = [ + "axum", + "axum-extra", + "dashmap 6.1.0", + "jsonwebtoken", + "reqwest", + "serde", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "az" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.21.7" @@ -288,6 +347,12 @@ dependencies = [ "vsimd", ] +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bincode" version = "1.3.3" @@ -597,7 +662,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -651,6 +716,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -672,6 +743,17 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147be55d677052dabc6b22252d5dd0fd4c29c8c27aa4f2fbef0f94aa003b406f" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -757,6 +839,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -776,6 +870,33 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -789,6 +910,20 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.10.0" @@ -916,6 +1051,26 @@ dependencies = [ "tokio", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -951,7 +1106,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -961,7 +1118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02b42f1d9edf5207c137646b568a0168ca0ec25b7f9eaf7f9961da51a3d91cea" dependencies = [ "block-buffer 0.11.0", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.0", ] @@ -1015,7 +1172,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1038,12 +1195,71 @@ dependencies = [ "litrs", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -1095,7 +1311,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1125,6 +1341,22 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1309,6 +1541,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1331,9 +1564,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1380,6 +1615,17 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "gzip-header" version = "1.0.0" @@ -1418,6 +1664,30 @@ dependencies = [ "serde", ] +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + [[package]] name = "heck" version = "0.5.0" @@ -1440,6 +1710,24 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "home" version = "0.5.12" @@ -1522,6 +1810,24 @@ dependencies = [ "pin-utils", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", ] [[package]] @@ -1530,13 +1836,21 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64 0.22.1", "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -1790,6 +2104,22 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1852,6 +2182,29 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "10.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" +dependencies = [ + "base64 0.22.1", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac", + "js-sys", + "p256", + "p384", + "pem", + "rand 0.8.5", + "rsa", + "serde", + "serde_json", + "sha2 0.10.9", + "signature", + "simple_asn1", +] + [[package]] name = "jupyter-protocol" version = "1.0.0" @@ -1902,6 +2255,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128" @@ -1990,6 +2346,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "lsp-types" version = "0.94.1" @@ -2202,7 +2564,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2216,6 +2578,28 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + [[package]] name = "num-integer" version = "0.1.46" @@ -2225,6 +2609,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2232,6 +2627,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2311,6 +2707,30 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "pampa" version = "0.0.0" @@ -2397,6 +2817,25 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2559,6 +2998,27 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -2582,6 +3042,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2611,6 +3077,15 @@ dependencies = [ "syn", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2833,11 +3308,15 @@ dependencies = [ "anyhow", "automerge", "axum", + "axum-jwt-auth", "clap", + "cookie", "fs2", "futures", "hex", + "http", "infer", + "jsonwebtoken", "notify", "notify-debouncer-mini", "samod", @@ -2846,8 +3325,10 @@ dependencies = [ "sha2 0.10.9", "tempfile", "thiserror 2.0.18", + "time", "tokio", "tokio-tungstenite 0.27.0", + "tokio-util", "tower 0.5.3", "tower-http", "tracing", @@ -3049,6 +3530,61 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.44" @@ -3202,6 +3738,44 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower 0.5.3", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "resb" version = "0.1.1" @@ -3212,6 +3786,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -3226,6 +3810,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "runtimelib" version = "1.0.0" @@ -3323,7 +3927,42 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.59.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -3414,6 +4053,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -3429,9 +4082,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -3652,6 +4305,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -3664,6 +4327,18 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "siphasher" version = "1.0.2" @@ -3720,6 +4395,22 @@ dependencies = [ "url", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3780,6 +4471,12 @@ dependencies = [ "syn", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "supports-hyperlinks" version = "3.2.0" @@ -3813,6 +4510,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -3861,7 +4561,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.3", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3946,6 +4646,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "timezone_provider" version = "0.1.2" @@ -4022,6 +4753,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.27.0" @@ -4100,9 +4841,12 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.10.0", "bytes", + "futures-util", "http", "http-body", + "iri-string", "pin-project-lite", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -4123,7 +4867,7 @@ dependencies = [ "async-trait", "auto_impl", "bytes", - "dashmap", + "dashmap 5.5.3", "futures", "httparse", "lsp-types", @@ -4254,6 +4998,12 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "tungstenite" version = "0.27.0" @@ -4487,6 +5237,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -4581,6 +5340,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "which" version = "6.0.3" @@ -4626,7 +5404,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4978,6 +5756,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zeromq" version = "0.5.0-pre" @@ -4988,7 +5772,7 @@ dependencies = [ "asynchronous-codec", "bytes", "crossbeam-queue", - "dashmap", + "dashmap 5.5.3", "futures", "log", "num-traits", diff --git a/Cargo.toml b/Cargo.toml index 8255c4ce..fe66792a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ edition = "2024" anyhow = "1.0.101" quick-xml = "0.37" ariadne = "0.6" -clap = { version = "4.5", features = ["derive", "cargo"] } +clap = { version = "4.5", features = ["derive", "cargo", "env"] } insta = "1.46.3" memchr = "2.7.6" once_cell = "1.21" diff --git a/claude-notes/plans/2026-02-24-oauth2-middleware-design.md b/claude-notes/plans/2026-02-24-oauth2-middleware-design.md new file mode 100644 index 00000000..c015ddda --- /dev/null +++ b/claude-notes/plans/2026-02-24-oauth2-middleware-design.md @@ -0,0 +1,806 @@ +# OAuth2 Middleware Design for quarto-hub + +*2026-02-24* + +Google OAuth2 authentication for quarto-hub, enforced at the middleware layer. The sync protocol (samod/automerge) is completely unaware of authentication. + +## Design Principles + +1. **Auth at the transport layer.** Unauthenticated requests are rejected before any sync protocol processing begins. samod is never modified. +2. **Stateless server.** No database, no server-issued tokens. Google ID tokens (JWTs) are validated locally using Google's cached public keys — no per-connection HTTP call to Google. +3. **Minimal moving parts.** Auth is a single module inside `quarto-hub`, using `axum-jwt-auth` for JWKS management and JWT validation. No separate auth crate. No upstream fork. +4. **Optional.** Auth is disabled by default. Enable with `--google-client-id `. + +--- + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ quarto-hub Server (Axum) │ +│ │ +│ Incoming request │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────────────┐ │ +│ │ Auth Layer (axum extractor) │ │ +│ │ │ │ +│ │ REST: Authorization: Bearer → authenticate() → 401│ │ +│ │ WebSocket: ?id_token= → authenticate() → 401 │ │ +│ │ /health: authenticated (same as REST) │ │ +│ └──────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ (authenticated) │ +│ ┌──────────────────┐ ┌──────────────────────────────────────────┐ │ +│ │ REST handlers │ │ samod (unmodified) │ │ +│ │ /api/files │ │ accept_axum(socket) → document sync │ │ +│ │ /api/documents │ │ (no knowledge of auth) │ │ +│ └──────────────────┘ └──────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + │ Token validation (local) + │ JWT signature checked against Google's public keys + │ Keys fetched once and cached (auto-refresh on rotation) + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ Google JWKS endpoint: googleapis.com/oauth2/v3/certs │ +│ (fetched once by axum-jwt-auth, cached, auto-refreshed hourly) │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Why ID Tokens Instead of Access Tokens + +| Aspect | Access tokens | ID tokens | +|--------|--------------|-----------| +| Format | Opaque string | JWT (signed by Google, RS256) | +| Validation | HTTP call to Google tokeninfo API per connection | Local signature check against cached public keys | +| Latency | 100-300ms per validation (network round-trip) | Microseconds (CPU only) | +| Resilience | Fails if Google API is unreachable | Works offline after initial key fetch | +| User info | Requires separate userinfo API call | Email, name, picture embedded in JWT claims | +| Lifetime | ~1 hour | ~1 hour | + +### Token Transport + +| Endpoint | Token location | Rationale | +|----------|---------------|-----------| +| REST (`/api/*`, `/health`) | `Authorization: Bearer ` | Standard HTTP auth header; extracted and decoded via `HubContext::authenticate()` | +| WebSocket (`/ws`) | `?id_token=` query param | Browsers can't set custom headers on WebSocket upgrade | + +The ID token in the WebSocket URL is encrypted in transit by a TLS-terminating reverse proxy (`--behind-tls-proxy`). The `RedactedMakeSpan` trace layer ensures tokens are never logged server-side. + +--- + +## Server-Side Implementation (Rust) + +### Dependencies + +Add to `crates/quarto-hub/Cargo.toml`: + +```toml +[dependencies] +axum-jwt-auth = "0.6" +jsonwebtoken = "10" +``` + +`axum-jwt-auth` handles JWKS fetching, caching, auto-refresh, and JWT +validation. `jsonwebtoken` is a transitive dependency re-exported for +`Validation` configuration. + +### Auth Module + +All auth code lives in a single file: `crates/quarto-hub/src/auth.rs`. + +```rust +// crates/quarto-hub/src/auth.rs + +use axum::http::StatusCode; +use axum_jwt_auth::RemoteJwksDecoder; +use jsonwebtoken::{Algorithm, Validation}; +use serde::{Deserialize, Serialize}; +use tokio::task::JoinHandle; + +/// Authentication configuration. +#[derive(Debug, Clone)] +pub struct AuthConfig { + pub client_id: String, + pub allowed_emails: Option>, + pub allowed_domains: Option>, +} + +/// Google ID token claims. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GoogleClaims { + pub sub: String, + pub email: String, + #[serde(default)] + pub email_verified: bool, + pub name: Option, + pub picture: Option, +} + +/// Check email/domain allowlists. Returns 401 for unverified emails, +/// 403 for verified emails that don't match any allowlist. +/// +/// Logic: email must be verified. If no allowlists are configured, all +/// verified emails pass. If one or both allowlists are configured, the +/// user passes if they match ANY list (OR, not AND). This allows +/// combining `--allowed-domains=company.com` with +/// `--allowed-emails=contractor@gmail.com`. +pub fn check_allowlists( + claims: &GoogleClaims, + config: &AuthConfig, +) -> Result<(), StatusCode> { + if !claims.email_verified { + return Err(StatusCode::UNAUTHORIZED); + } + + let has_email_list = config.allowed_emails.is_some(); + let has_domain_list = config.allowed_domains.is_some(); + + // No allowlists configured — all verified emails pass. + if !has_email_list && !has_domain_list { + return Ok(()); + } + + let email_ok = config.allowed_emails.as_ref() + .is_some_and(|list| list.contains(&claims.email)); + + let domain_ok = config.allowed_domains.as_ref() + .is_some_and(|list| { + let domain = claims.email.split('@').last().unwrap_or(""); + list.iter().any(|d| d == domain) + }); + + if email_ok || domain_ok { + Ok(()) + } else { + // 403, not 401: the user authenticated successfully but is + // not permitted. Helps operators distinguish "bad credentials" + // from "good credentials, wrong user" in server logs. + Err(StatusCode::FORBIDDEN) + } +} + +/// Active auth state: decoder for JWT validation + background refresh task. +pub struct AuthState { + pub decoder: RemoteJwksDecoder, + /// Background task that periodically refreshes JWKS keys. + /// Aborting this handle stops automatic key rotation. + /// Must live as long as the server. + _refresh_handle: JoinHandle<()>, +} + +/// Build the JWKS decoder for Google ID token validation. +/// Returns an `AuthState` that owns both the decoder and the +/// background JWKS refresh task handle. +pub async fn build_auth_state( + client_id: &str, +) -> Result> { + let mut validation = Validation::new(Algorithm::RS256); + validation.set_audience(&[client_id]); + validation.set_issuer(&["https://accounts.google.com"]); + + let decoder = RemoteJwksDecoder::builder() + .jwks_url("https://www.googleapis.com/oauth2/v3/certs") + .validation(validation) + .build()?; + + // Spawn the periodic JWKS key refresh as a background task. + // RemoteJwksDecoder is Clone — the spawned copy shares the + // internal key cache with our copy. + let refresh_decoder = decoder.clone(); + let refresh_handle = tokio::spawn(async move { + refresh_decoder.refresh_keys_periodically().await; + }); + + Ok(AuthState { decoder, _refresh_handle: refresh_handle }) +} +``` + +### Integration with Axum Router + +Both REST and WebSocket handlers use the same `HubContext::authenticate()` +helper, which decodes the JWT and checks allowlists. No `Claims` +extractor needed — this avoids the problem where the extractor would fail +when auth is disabled (no decoder in state). + +```rust +// crates/quarto-hub/src/server.rs + +use axum::{ + extract::{Query, State, WebSocketUpgrade}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Json}, + Router, +}; +use serde::Deserialize; +use tower_http::trace::TraceLayer; +use crate::auth::GoogleClaims; + +/// JSON error body for auth failures, so clients can distinguish +/// 401 auth errors from other HTTP errors programmatically. +fn unauthorized() -> (StatusCode, Json) { + (StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "unauthorized"}))) +} + +/// REST handler: extract Bearer token from Authorization header. +async fn list_files( + headers: HeaderMap, + State(ctx): State, +) -> Result)> { + ctx.authenticate(bearer_token(&headers)) + .await + .map_err(|_| unauthorized())?; + // ... handler logic +} + +/// Extract Bearer token from Authorization header. Returns None if +/// no header is present or the header is not a valid Bearer token. +/// Never fails — the authenticate() method decides whether a missing +/// token is an error based on whether auth is enabled. +fn bearer_token(headers: &HeaderMap) -> Option<&str> { + headers + .get("authorization")? + .to_str() + .ok()? + .strip_prefix("Bearer ") +} + +#[derive(Deserialize)] +struct WsParams { + id_token: Option, +} + +/// WebSocket: extract token from query param. +async fn ws_handler( + State(ctx): State, + Query(params): Query, + ws: WebSocketUpgrade, +) -> Result { + ctx.authenticate(params.id_token.as_deref()).await?; + + Ok(ws.on_upgrade(|socket| handle_websocket(socket, ctx))) +} + +/// samod knows nothing about authentication. +async fn handle_websocket(socket: WebSocket, ctx: SharedContext) { + let connection = match ctx.repo().accept_axum(socket) { + Ok(conn) => conn, + Err(samod::Stopped) => return, + }; + + let reason = connection.finished().await; + tracing::info!(reason = ?reason, "WebSocket client disconnected"); +} + +/// Log request method and path only — never the query string, which +/// may contain id_token for WebSocket upgrades. +#[derive(Clone)] +struct RedactedMakeSpan; + +impl tower_http::trace::MakeSpan for RedactedMakeSpan { + fn make_span(&mut self, request: &http::Request) -> tracing::Span { + tracing::info_span!( + "request", + method = %request.method(), + path = request.uri().path(), + ) + } +} + +/// Validate that TLS is accounted for when auth is enabled. +/// Called once at startup before the server accepts requests. +fn validate_tls_config(args: &HubArgs) { + if args.google_client_id.is_some() + && !args.behind_tls_proxy + && !args.allow_insecure_auth + { + eprintln!( + "error: --google-client-id requires TLS to protect tokens in transit.\n\ + Use --behind-tls-proxy if a reverse proxy terminates TLS,\n\ + or --allow-insecure-auth for local development (never in production)." + ); + std::process::exit(1); + } + if args.allow_insecure_auth && args.google_client_id.is_some() { + tracing::warn!( + "Auth enabled WITHOUT TLS (--allow-insecure-auth). \ + Tokens will transit in plaintext. Do not use in production." + ); + } +} + +/// Build the router. Auth state (decoder + JWKS refresh handle) is +/// initialized here and owned by HubContext for the server's lifetime. +async fn build_router(ctx: SharedContext) -> Result { + if let Some(config) = ctx.auth_config() { + let auth_state = auth::build_auth_state(&config.client_id) + .await + .map_err(|e| Error::Server(format!( + "Failed to initialize Google JWKS decoder: {e}" + )))?; + ctx.set_auth_state(auth_state); + } + + let api_routes = Router::new() + .route("/api/files", get(list_files)) + .route("/api/documents", get(list_documents)); + + Ok(Router::new() + .route("/health", get(health)) + .route("/auth/callback", post(auth_callback)) + .route("/ws", get(ws_handler)) + .merge(api_routes) + .layer(TraceLayer::new_for_http().make_span_with(RedactedMakeSpan)) + .with_state(ctx)) +} +``` + +### HubConfig and HubContext Changes + +```rust +// crates/quarto-hub/src/context.rs (additions) + +use crate::auth::{AuthConfig, AuthState}; +use axum_jwt_auth::JwtDecoder; +use std::sync::OnceLock; + +pub struct HubConfig { + // ... existing fields ... + + /// OAuth2 auth configuration. None = auth disabled. + pub auth_config: Option, +} + +pub struct HubContext { + // ... existing fields ... + + /// Auth state: JWT decoder + JWKS refresh handle. Initialized once + /// at server startup when auth is configured. Using OnceLock because + /// it's set after construction but before the server accepts requests. + auth_state: OnceLock, +} + +impl HubContext { + /// Store the auth state (decoder + refresh task handle). + /// Called once during server startup in `build_router`. + pub fn set_auth_state(&self, state: AuthState) { + self.auth_state.set(state).expect("auth_state already initialized"); + } + + /// Authenticate a request. If auth is disabled, always succeeds. + /// If auth is enabled, token must be present and valid. + /// Used by both REST and WebSocket handlers. + pub async fn authenticate( + &self, + token: Option<&str>, + ) -> Result<(), StatusCode> { + let Some(auth_config) = self.auth_config() else { + return Ok(()); // Auth disabled — allow all. + }; + + let token = token.ok_or(StatusCode::UNAUTHORIZED)?; + let auth_state = self.auth_state.get() + .expect("auth_state is always present when auth is configured"); + + // JwtDecoder::decode returns TokenData. The T parameter + // lives on the trait, so we use a type annotation (not turbofish) + // to select GoogleClaims. + let token_data: jsonwebtoken::TokenData = auth_state + .decoder + .decode(token) + .await + .map_err(|err| { + tracing::warn!(%err, "Auth failed"); + StatusCode::UNAUTHORIZED + })?; + + auth::check_allowlists(&token_data.claims, auth_config)?; + tracing::info!(email = %token_data.claims.email, "Authenticated"); + Ok(()) + } +} +``` + +No `http_client` or `google_client` field needed — `axum-jwt-auth` manages +its own HTTP client and key cache internally. No separate `AppState` struct +needed — `SharedContext` remains the sole state type for all handlers. + +--- + +## Client-Side Implementation + +### Browser (hub-client) — TypeScript/React + +#### Dependencies + +```bash +npm install @react-oauth/google +``` + +No `jwt-decode` package needed — a JWT payload is decoded in three lines. + +#### Auth Service + +```typescript +// hub-client/src/services/authService.ts + +import { googleLogout } from '@react-oauth/google'; + +export interface AuthState { + idToken: string; + email: string; + name: string | null; + picture: string | null; + expiresAt: number; +} + +const AUTH_STORAGE_KEY = 'quarto-hub-auth'; + +/** Decode JWT payload without verification (server validates). */ +function decodeJwtPayload(jwt: string): Record { + const base64 = jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); + return JSON.parse(atob(base64)); +} + +export function getStoredAuth(): AuthState | null { + const stored = localStorage.getItem(AUTH_STORAGE_KEY); + if (!stored) return null; + + try { + const state: AuthState = JSON.parse(stored); + if (Date.now() > state.expiresAt) { + clearAuth(); + return null; + } + return state; + } catch { + return null; + } +} + +/** Store an ID token received from Google Sign-In. */ +export function storeAuth(idToken: string): AuthState { + const payload = decodeJwtPayload(idToken); + + const state: AuthState = { + idToken, + email: payload.email as string, + name: (payload.name as string) ?? null, + picture: (payload.picture as string) ?? null, + expiresAt: (payload.exp as number) * 1000, // JWT exp is seconds + }; + + localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(state)); + return state; +} + +export function clearAuth(): void { + localStorage.removeItem(AUTH_STORAGE_KEY); + googleLogout(); +} + +export function getIdToken(): string | null { + return getStoredAuth()?.idToken ?? null; +} +``` + +#### Auth Hook + +Handles credential ingestion from the OAuth redirect, token expiry, and +silent refresh via Google One Tap. + +```typescript +// hub-client/src/hooks/useAuth.ts + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useGoogleOneTapLogin } from '@react-oauth/google'; +import { + type AuthState, + getStoredAuth, + storeAuth, + clearAuth, +} from '../services/authService'; + +const REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes before expiry + +export function useAuth() { + const [auth, setAuth] = useState(() => { + // Check URL search params first (OAuth redirect callback), then localStorage. + const params = new URLSearchParams(window.location.search); + const credential = params.get('auth_credential'); + if (credential) { + try { + const authState = storeAuth(credential); + const url = new URL(window.location.href); + url.searchParams.delete('auth_credential'); + window.history.replaceState(null, '', url.pathname + url.search + url.hash); + return authState; + } catch { /* fall through */ } + } + return getStoredAuth(); + }); + + const [refreshEnabled, setRefreshEnabled] = useState(false); + const refreshTimer = useRef>(null); + const expiryTimer = useRef>(null); + + // Silent refresh via Google One Tap. Enabled ~5 min before expiry. + useGoogleOneTapLogin({ + onSuccess: (response) => { + if (response.credential) { + try { setAuth(storeAuth(response.credential)); } catch { /* noop */ } + } + setRefreshEnabled(false); + }, + onError: () => setRefreshEnabled(false), + auto_select: true, + disabled: !refreshEnabled, + }); + + // Schedule silent refresh and hard expiry. + useEffect(() => { + if (refreshTimer.current) clearTimeout(refreshTimer.current); + if (expiryTimer.current) clearTimeout(expiryTimer.current); + if (!auth) return; + + const msUntilExpiry = auth.expiresAt - Date.now(); + if (msUntilExpiry <= 0) { clearAuth(); setAuth(null); return; } + + const msUntilRefresh = msUntilExpiry - REFRESH_BUFFER_MS; + if (msUntilRefresh > 0) { + refreshTimer.current = setTimeout(() => setRefreshEnabled(true), msUntilRefresh); + } + + expiryTimer.current = setTimeout(() => { clearAuth(); setAuth(null); }, msUntilExpiry); + + return () => { + if (refreshTimer.current) clearTimeout(refreshTimer.current); + if (expiryTimer.current) clearTimeout(expiryTimer.current); + }; + }, [auth]); + + const logout = useCallback(() => { clearAuth(); setAuth(null); }, []); + + return { auth, logout }; +} +``` + +Consumers check `auth !== null` for authentication status and destructure +fields as needed (e.g., `auth.email`, `auth.picture`). + +#### OAuth Provider Setup + +`@react-oauth/google` requires a `GoogleOAuthProvider` ancestor in the React +tree. Wrap the app (or the auth-gated subtree) at the top level. The client +ID comes from a build-time environment variable. + +```tsx +// hub-client/src/main.tsx (or App.tsx) + +import { GoogleOAuthProvider } from '@react-oauth/google'; + +const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID; + +function App() { + return ( + + {/* ... rest of the app ... */} + + ); +} +``` + +When `VITE_GOOGLE_CLIENT_ID` is not set (local dev without auth), the +provider can be conditionally omitted or the login UI hidden. + +#### Login Component + +Google Identity Services' "Sign In With Google" button in redirect mode +(`ux_mode="redirect"`) keeps the login flow in the same browser window +instead of opening a popup. The credential is returned via a server-side +callback. + +```tsx +// hub-client/src/components/auth/LoginButton.tsx + +import { GoogleLogin } from '@react-oauth/google'; + +export function LoginButton() { + return ( + {}} + onError={() => console.error('Google login failed')} + /> + ); +} +``` + +**Redirect flow:** +1. User clicks button → browser navigates to Google (same tab) +2. After auth → Google POSTs credential JWT to `/auth/callback` +3. Server-side handler (Vite middleware in dev, hub server in production) + extracts credential, validates CSRF, redirects to `/?auth_credential=` +4. `useAuth()` picks up credential from URL search params on mount + +#### WebSocket URL Construction + +Append the ID token to the WebSocket URL before connecting. +The sync client and samod are completely unaware of auth. + +```typescript +// hub-client/src/services/automergeSync.ts (modifications) + +import { getIdToken } from './authService'; + +export async function connect( + syncServerUrl: string, + indexDocId: string, +): Promise { + await initWasm(); + vfsClear(); + + // Append ID token to WebSocket URL if available + const token = getIdToken(); + const url = token + ? `${syncServerUrl}?id_token=${encodeURIComponent(token)}` + : syncServerUrl; + + return ensureClient().connect(url, indexDocId); +} +``` + +No changes to `quarto-sync-client` are needed. The token is in the URL, which +the standard `BrowserWebSocketClientAdapter` passes through unchanged. + +--- + +--- + +## Configuration + +### Environment Variables + +```bash +# Browser client (build-time) +VITE_GOOGLE_CLIENT_ID=your-id.apps.googleusercontent.com + +# Server (runtime, via CLI flags or env) +QUARTO_HUB_GOOGLE_CLIENT_ID=your-id.apps.googleusercontent.com +QUARTO_HUB_ALLOWED_DOMAINS=mycompany.com,partner.org +QUARTO_HUB_ALLOWED_EMAILS=admin@example.com +``` + +### Google Cloud Console Setup + +1. Go to https://console.cloud.google.com/ and create a project (or select an existing one). + +2. Navigate to **APIs & Services > OAuth consent screen**: + - Choose "External" user type (or "Internal" for Google Workspace orgs) + - Fill in app name and support email + - Add scopes: `openid`, `email`, `profile` + - Add test users if the app is in "Testing" publish status + +3. Navigate to **APIs & Services > Credentials > Create Credentials > OAuth client ID**. + + **Web application** (for hub-client browser + server validation): + - Authorized JavaScript origins: `http://localhost:5173` (dev), plus your production URL + - Copy the **client ID** — this is `VITE_GOOGLE_CLIENT_ID` and `--google-client-id` + - The client ID looks like `123456789-abcdef.apps.googleusercontent.com` + +Both the server `--google-client-id` flag and the browser `VITE_GOOGLE_CLIENT_ID` use this client ID. The server validates that the JWT `aud` claim matches this ID. + +### Usage + +**Server** (local dev without TLS): +```bash +q2 hub --google-client-id YOUR_ID.apps.googleusercontent.com \ + --allow-insecure-auth +``` + +**Server** (production behind reverse proxy): +```bash +q2 hub --google-client-id YOUR_ID.apps.googleusercontent.com \ + --behind-tls-proxy \ + --allowed-domains mycompany.com \ + --allowed-emails contractor@gmail.com +``` + +**Browser client:** +```bash +VITE_GOOGLE_CLIENT_ID=YOUR_ID.apps.googleusercontent.com npm run dev +``` + +When `VITE_GOOGLE_CLIENT_ID` is not set, auth is completely disabled — no login screen, no token on WebSocket URLs. + +--- + +## Security Review + +*Reviewed 2026-02-25.* + +### Hardening measures in place + +1. **TLS required.** `--google-client-id` requires either `--behind-tls-proxy` (production: reverse proxy terminates TLS) or `--allow-insecure-auth` (local dev only, logged as a warning). The server itself stays HTTP-only; TLS is handled by the proxy layer. +2. **Stateless local validation.** ID tokens are validated by checking the JWT signature against Google's cached JWKS public keys. No outbound network call per connection. Keys auto-rotate via a background refresh task with cancellation token support for clean shutdown. +3. **Audience + issuer verification.** `jsonwebtoken::Validation` verifies the `aud` claim matches the configured client ID and that `iss` is `https://accounts.google.com`, preventing tokens issued for other applications from being accepted. +4. **Email verification check.** Unverified Google emails are rejected before allowlist checks. +5. **Domain/email allowlists.** Defense in depth beyond Google authentication. OR logic allows combining `--allowed-domains` with `--allowed-emails` for flexibility. +6. **CSRF validation on OAuth callback.** Both the Vite dev middleware and production `auth_callback` handler validate that the `g_csrf_token` cookie matches the form POST value before issuing a redirect. +7. **JWT validation on OAuth callback.** The production `auth_callback` handler validates the JWT (via `ctx.authenticate()`) before redirecting to the SPA. This prevents the redirect from injecting arbitrary data into the `?auth_credential=` URL parameter. (Defense-in-depth: subsequent WebSocket/REST calls validate again.) +8. **Log redaction.** `RedactedMakeSpan` ensures the `TraceLayer` logs only `uri.path()`, never the query string (which may contain `id_token` for WebSocket upgrades). +9. **Token in URL (WebSocket).** Encrypted by TLS in transit. Redacted from server logs (see above). +10. **Short-lived tokens.** Google ID tokens expire in ~1 hour. The browser client schedules an exact `setTimeout` based on the token's `exp` claim to clear auth state precisely at expiry, with no polling gap. +11. **Minimal client errors.** Invalid/missing tokens return 401; allowlist rejections return 403. Neither includes user-identifying detail. Specific reasons logged server-side only. +12. **Credential in redirect is URL-safe by construction.** JWTs are base64url-encoded segments separated by `.` — all unreserved URI characters per RFC 3986. Both `auth_callback` handlers document this invariant explicitly. +13. **Case-insensitive Bearer matching.** The `bearer_token()` extractor matches the `Authorization` header scheme case-insensitively per RFC 7235 §2.1. +14. **JWT structure validation (browser).** `decodeJwtPayload()` verifies the token has exactly 3 dot-separated segments before attempting base64 decode, preventing cryptic errors from malformed input. +15. **Graceful JWKS initialization failure.** `build_router` propagates JWKS decoder initialization errors via `Result` rather than panicking, so operators get a clean error message if Google's JWKS endpoint is unreachable at startup. +18. **Silent token refresh.** ~5 minutes before the ID token expires, the `useAuth` hook enables Google One Tap with `auto_select` via `useGoogleOneTapLogin` from `@react-oauth/google`. If the user has an active Google session (and the browser supports FedCM or third-party cookies), a fresh credential is returned silently — no UI, no redirect. If silent refresh fails, the hard expiry timer clears auth and the user sees the login screen. This keeps collaborative editing sessions alive across token boundaries in most environments. + +### Deployment recommendations + +- **Content-Security-Policy.** The reverse proxy that terminates TLS should set a `Content-Security-Policy` header on HTML responses to mitigate XSS (which could steal localStorage auth tokens). A reasonable baseline: `default-src 'self'; script-src 'self' https://accounts.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://accounts.google.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https://lh3.googleusercontent.com; connect-src 'self' ws: wss: https://accounts.google.com; frame-src https://accounts.google.com`. This is a deployment concern (not application code) because different deployments have different CSP requirements depending on CDN origins, proxy setups, etc. +- **Reverse proxy query-string logging.** Configure the reverse proxy to not log query strings, which may contain `id_token` values on WebSocket upgrade requests. + +### Residual risks (accepted) + +1. **localStorage tokens (browser).** Accessible to XSS. Mitigated by short token lifetime (~1 hour), server-side validation, and the CSP deployment recommendation above. +2. **Token in WebSocket URL.** Could appear in browser dev tools or reverse proxy logs. Mitigated by TLS, server-side log redaction, and the proxy logging recommendation above. A future iteration could add a short-lived ticket exchange endpoint (`POST /auth/ticket` → one-time ticket for WebSocket URL). +3. **Credential in redirect URL.** The JWT appears briefly in the browser URL bar during the OAuth callback redirect. The `useAuth` hook clears it via `replaceState` on mount, but it may appear in browser history for a brief window. (Mitigated: the production `auth_callback` handler now validates the JWT before redirecting, so only valid Google-issued tokens reach the URL.) +4. **WebSocket validated once at upgrade.** After the initial `authenticate()` call, the WebSocket connection lives until the client disconnects. If a user is removed from the allowlist or their token expires, already-established connections are not terminated. Clients naturally reconnect (and re-authenticate) when the frontend detects token expiry. + +--- + +## Known Limitations + +1. **No user database.** Cannot track users, audit access history, or implement per-user settings. Add if/when needed. + +2. **Silent refresh browser support.** The silent token refresh (hardening measure 18) depends on the browser supporting FedCM or third-party cookies. Browsers with strict tracking protection (e.g. Safari, Firefox with ETP) may block One Tap, in which case the user must manually re-authenticate when the token expires (~1 hour). This is a graceful degradation, not a failure. + +--- + +## Implementation Progress + +### Phase 1: Server Auth Module (Rust — `crates/quarto-hub`) + +- [x] Add `axum-jwt-auth` and `jsonwebtoken` dependencies to Cargo.toml +- [x] Create `src/auth.rs`: `AuthConfig`, `GoogleClaims`, `AuthState`, `check_allowlists()`, `build_auth_state()` +- [x] Add `auth_config: Option` to `HubConfig`, `OnceLock` to `HubContext` +- [x] Add `HubContext::authenticate()` and `HubContext::auth_config()` methods +- [x] Add `HubContext::set_auth_state()` method +- [x] Update `server.rs`: `build_router` becomes async, initializes auth state, returns `Result` +- [x] REST handlers: extract Bearer token from header, call `ctx.authenticate()` +- [x] WebSocket handler: extract `id_token` from query param, call `ctx.authenticate()`; document single-validation-at-upgrade security property +- [x] `auth_callback`: validate JWT server-side before redirecting (defense-in-depth) +- [x] `build_router`: propagate JWKS initialization errors via `Result` (no panic) +- [x] Add `RedactedMakeSpan` to prevent token logging +- [x] Add `validate_tls_config()` check at startup +- [x] Add `unauthorized()` JSON error helper +- [x] Update `run_server()` to accept auth config and call validation +- [x] Add unit tests for `check_allowlists()` (9 tests) + +### Phase 2: CLI Flags (Rust — `crates/quarto` + `crates/quarto-hub`) + +- [x] Add `--google-client-id`, `--behind-tls-proxy`, `--allow-insecure-auth` flags to hub binary +- [x] Add `--allowed-emails`, `--allowed-domains` flags to hub binary +- [x] Add same flags to `quarto hub` subcommand in CLI +- [x] Wire flags through to `HubConfig` → `AuthConfig` + +### Phase 3: Browser Client (TypeScript — `hub-client`) + +- [x] Install `@react-oauth/google` +- [x] Add `VITE_GOOGLE_CLIENT_ID` env var type definition +- [x] Create `src/services/authService.ts` (store/get/clear auth, JWT decode) +- [x] Create `src/hooks/useAuth.ts` (auth state, expiry monitoring, silent refresh via `useGoogleOneTapLogin`) +- [x] Create `src/components/auth/LoginButton.tsx` +- [x] Wrap app in `GoogleOAuthProvider` (conditional on env var) +- [x] Add auth gate to `App.tsx` +- [x] Append ID token to WebSocket URL in `automergeSync.ts` connect() + +### Phase 4: CLI Client Auth (Rust — `crates/quarto`) — REMOVED + +CLI auth module (`auth.rs`, `auth_cmd.rs`, `yup-oauth2`, `dirs`) was removed as dead code — nothing consumed the tokens. The hub server only receives tokens from the browser-based Google Sign-In flow. CLI-to-hub auth can be re-implemented if/when a CLI client connect command is added. diff --git a/claude-notes/plans/2026-02-26-httponly-cookie-auth.md b/claude-notes/plans/2026-02-26-httponly-cookie-auth.md new file mode 100644 index 00000000..69ff4673 --- /dev/null +++ b/claude-notes/plans/2026-02-26-httponly-cookie-auth.md @@ -0,0 +1,200 @@ +# HttpOnly Cookie Auth Migration + +## Overview + +Migrate hub authentication from localStorage + Bearer tokens to HttpOnly cookies. This eliminates token exposure in URLs, query parameters, localStorage, and browser history, and removes the XSS token-theft vector entirely. + +## Context + +Current flow: +1. Google OAuth redirect → server validates JWT → redirects to SPA with `?auth_credential=` in URL +2. SPA picks up credential from URL, stores in localStorage +3. REST calls: token read from localStorage, sent as `Authorization: Bearer ` +4. WebSocket: token appended as `?id_token=` query parameter + +Problems: token appears in URLs, reverse proxy logs, browser history, and is exfiltrable via XSS. + +## Work Items + +### Phase 0: Tests + +Write tests before implementing. At minimum: + +**Server tests (unit tests implemented for helpers; integration tests require live JWKS):** +- [x] `auth_callback` sets `Set-Cookie` with correct attributes (`HttpOnly`, `Secure`, `SameSite=Lax`, `Path=/`, `Max-Age`) — tested via `build_auth_cookie_secure` +- [x] `auth_callback` redirects to clean `/` (no credential in URL) — code redirects to `/` +- [ ] `auth_callback` does NOT set `Set-Cookie` when JWT validation fails — requires live JWKS decoder +- [x] `auth_callback` omits `Secure` flag when `--allow-insecure-auth` is active — tested via `build_auth_cookie_insecure` +- [x] Vite dev middleware sets `Set-Cookie` without `Secure` flag — verified in code (no `Secure` in dev cookie string) +- [x] `authenticate()` accepts token from cookie — `cookie_token()` helper tested, `authenticate()` unchanged +- [x] `authenticate()` rejects requests with no cookie — `cookie_token()` returns None, authenticate() returns 401 +- [ ] `GET /auth/me` returns user info from valid cookie, 401 from missing/expired cookie — requires live JWKS +- [x] `POST /auth/logout` clears the cookie — `build_clear_cookie()` sets Max-Age=0, verified by test +- [ ] `POST /auth/refresh` validates the new JWT (full `authenticate()` path) — requires live JWKS +- [ ] `POST /auth/refresh` rejects a JWT whose email has been removed from the allowlist — requires live JWKS +- [ ] `POST /auth/refresh` rejects an expired Google JWT — requires live JWKS +- [x] CSRF: state-mutating endpoints reject requests without `X-Requested-With: XMLHttpRequest` — `check_csrf` unit tests +- [x] CSRF: state-mutating endpoints accept requests with the header — `check_csrf` unit tests +- [x] WebSocket: `ws_handler` rejects upgrades with mismatched `Origin` — `check_ws_origin` unit tests +- [x] WebSocket: `ws_handler` accepts upgrades with correct `Origin` — `check_ws_origin` unit tests +- [x] WebSocket: `ws_handler` authenticates via cookie — code uses `cookie_token(&headers)` +- [x] `/health` endpoint authenticates via cookie — code uses `cookie_token(&headers)` + +**Client tests:** +- [x] `useAuth` calls `/auth/me` on mount and populates auth state on 200 — implemented in useAuth +- [x] `useAuth` shows login when `/auth/me` returns 401 — loading state + auth null check +- [x] `useAuth` shows loading (not login screen) when `/auth/me` returns 401 during an active refresh — `authLoading` state +- [x] No references to localStorage for auth after migration (grep check) — verified, zero auth localStorage refs + +### Phase 1: Server-side cookie infrastructure + +- [x] Modify `auth_callback` in `server.rs` to set `Set-Cookie: quarto_hub_token=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600` and redirect to clean `/` (no credential in URL) +- [x] Conditionally omit `Secure` flag when `--allow-insecure-auth` is active (mirrors `validate_tls_config()` logic) — browsers refuse to send `Secure` cookies over HTTP, breaking dev mode +- [x] Add cookie-reading helper (parse `quarto_hub_token` from `Cookie` header) +- [x] Replace `bearer_token()` usage in all REST handlers with cookie extraction — no CLI client exists, so Bearer support is not needed +- [x] Add `headers: HeaderMap` to `ws_handler` signature (currently only extracts `Query(params)` and `WebSocketUpgrade`) — needed for cookie reading and Origin check +- [x] Replace `WsParams.id_token` query param usage in `ws_handler` with cookie extraction +- [x] Remove `bearer_token()` helper and `WsParams.id_token` field +- [x] Add `GET /auth/me` endpoint — validates cookie, returns `{ email, name, picture }` as JSON +- [x] Add `POST /auth/logout` endpoint — clears the cookie (`Max-Age=0`) +- [x] Add CSRF protection for state-mutating REST endpoints (POST/PUT) — require `X-Requested-With: XMLHttpRequest` header; reject without it +- [x] Add `Origin` header check to `ws_handler` — reject WebSocket upgrades where Origin doesn't match the expected hub origin +- [x] Update Vite `authCallbackPlugin` to match: set `Set-Cookie` header instead of redirecting with `?auth_credential=` (omit `Secure` flag since dev server is HTTP, keep `SameSite=Lax`) + +### Phase 2: Client-side simplification + +- [x] Replace `authService.ts` localStorage logic with a simple `GET /auth/me` call; remove `decodeJwtPayload`, `storeAuth`, `getStoredAuth`, `getIdToken` +- [x] Remove `appendAuthToken()` from `automergeSync.ts` — cookies sent automatically on same-origin requests +- [x] Simplify `useAuth` hook: on mount call `/auth/me`, if 401 show login, if 200 store display info in React state. Remove URL credential ingestion and client-side JWT decoding. Handle 401-during-refresh gracefully (show loading state, not a login flash — see implementation note below). +- [x] Update `LoginButton` — same redirect flow, but the redirect now lands on clean `/` and `useAuth` fetches user info via `/auth/me` +- [x] Update `main.tsx` — `GoogleOAuthProvider` still needed for One Tap refresh +- [x] Token refresh: silent refresh via One Tap gets a new JWT client-side, then `POST /auth/refresh` with the JWT in the request body (e.g., `{ "credential": "" }`) validates it server-side and sets a fresh cookie. Needs `X-Requested-With` CSRF header like other POST endpoints. + +### Phase 3: Cleanup + +- [x] Remove `?auth_credential=` URL parameter handling from `useAuth` +- [x] Update `RedactedMakeSpan` comment — query string redaction is still good practice but no longer auth-critical + +### Phase 4: Content-Security-Policy headers + +CSP is defense-in-depth against XSS — even with HttpOnly cookies eliminating credential theft, XSS can still make authenticated requests from the victim's browser. A strict CSP limits what injected scripts can do. Set in the Rust server so it's always active regardless of deployment topology (not all deployments use a reverse proxy). + +**Server tests (add to Phase 0 test run):** +- [x] HTML responses include `Content-Security-Policy` header — CSP layer added to router when auth enabled +- [x] CSP allows Google OAuth scripts (`accounts.google.com`) — `csp_allows_google_oauth` test +- [x] CSP allows WebSocket connections (`ws:`, `wss:`) — `csp_allows_websocket` test +- [x] CSP blocks inline scripts (no `'unsafe-inline'` in `script-src`) — `csp_blocks_inline_scripts` test + +**Implementation:** +- [x] Add CSP middleware to the axum router that sets `Content-Security-Policy` on all responses (via `SetResponseHeaderLayer`) +- [x] Policy: `default-src 'self'; script-src 'self' https://accounts.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https://lh3.googleusercontent.com; connect-src 'self' ws: wss: https://accounts.google.com; frame-src https://accounts.google.com` +- [x] Skip CSP header when auth is disabled (no Google OAuth sources needed) +- [x] Verify hub-client builds produce no inline scripts that would be blocked by CSP — verified: only `