From 169d9de789b4b7e719d24c15bb6e60edb738d385 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:09:46 +0000 Subject: [PATCH 01/29] Hub google oauth2 concept --- Cargo.lock | 932 +++++++++++++++++- .../2026-02-24-oauth2-middleware-design.md | 920 +++++++++++++++++ .../plans/2026-02-24-oauth2-middleware.md | 46 + crates/quarto-hub/Cargo.toml | 6 + crates/quarto-hub/src/auth.rs | 252 +++++ crates/quarto-hub/src/context.rs | 66 +- crates/quarto-hub/src/lib.rs | 1 + crates/quarto-hub/src/main.rs | 42 +- crates/quarto-hub/src/server.rs | 117 ++- crates/quarto/Cargo.toml | 4 + crates/quarto/src/auth.rs | 96 ++ crates/quarto/src/commands/auth_cmd.rs | 37 + crates/quarto/src/commands/hub.rs | 24 +- crates/quarto/src/commands/mod.rs | 1 + crates/quarto/src/main.rs | 55 ++ hub-client/src/App.tsx | 22 + .../src/components/auth/LoginButton.tsx | 23 + hub-client/src/hooks/useAuth.ts | 52 + hub-client/src/main.tsx | 13 +- hub-client/src/services/authService.ts | 66 ++ hub-client/src/services/automergeSync.ts | 14 +- hub-client/src/vite-env.d.ts | 2 + package-lock.json | 11 + package.json | 1 + 24 files changed, 2768 insertions(+), 35 deletions(-) create mode 100644 claude-notes/plans/2026-02-24-oauth2-middleware-design.md create mode 100644 claude-notes/plans/2026-02-24-oauth2-middleware.md create mode 100644 crates/quarto-hub/src/auth.rs create mode 100644 crates/quarto/src/auth.rs create mode 100644 crates/quarto/src/commands/auth_cmd.rs create mode 100644 hub-client/src/components/auth/LoginButton.tsx create mode 100644 hub-client/src/hooks/useAuth.ts create mode 100644 hub-client/src/services/authService.ts diff --git a/Cargo.lock b/Cargo.lock index 406f3b4c..0f8c79fd 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" @@ -682,6 +764,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -757,6 +849,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 +880,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 +920,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 +1061,27 @@ 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", + "serde_core", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -951,7 +1117,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 +1129,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 +1183,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1038,12 +1206,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 +1322,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 +1352,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 +1552,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1331,9 +1575,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 +1626,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" @@ -1389,6 +1646,25 @@ dependencies = [ "crc32fast", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1418,6 +1694,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 +1740,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" @@ -1513,6 +1831,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1522,6 +1841,25 @@ 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-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", ] [[package]] @@ -1530,13 +1868,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 +2136,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 +2214,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 +2287,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 +2378,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" @@ -2139,10 +2533,10 @@ dependencies = [ "libc", "log", "openssl", - "openssl-probe", + "openssl-probe 0.1.6", "openssl-sys", "schannel", - "security-framework", + "security-framework 2.11.1", "security-framework-sys", "tempfile", ] @@ -2202,7 +2596,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 +2610,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 +2641,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 +2659,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", ] [[package]] @@ -2278,6 +2715,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "openssl-sys" version = "0.9.111" @@ -2311,6 +2754,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 +2864,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" @@ -2557,7 +3043,28 @@ checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" name = "pin-utils" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +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" @@ -2582,6 +3089,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 +3124,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" @@ -2662,6 +3184,7 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "dirs", "pampa", "pollster", "quarto-core", @@ -2679,6 +3202,7 @@ dependencies = [ "tracing", "tracing-subscriber", "walkdir", + "yup-oauth2", ] [[package]] @@ -2833,11 +3357,14 @@ dependencies = [ "anyhow", "automerge", "axum", + "axum-jwt-auth", "clap", "fs2", "futures", "hex", + "http", "infer", + "jsonwebtoken", "notify", "notify-debouncer-mini", "samod", @@ -2848,6 +3375,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-tungstenite 0.27.0", + "tokio-util", "tower 0.5.3", "tower-http", "tracing", @@ -3049,6 +3577,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 +3785,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 +3833,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 +3857,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 +3974,63 @@ 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-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.6.0", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[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 +4121,26 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[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" @@ -3421,7 +4148,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3429,9 +4169,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 +4392,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 +4414,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 +4482,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 +4558,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 +4597,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 +4648,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 +4733,39 @@ 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", + "libc", + "num-conv", + "num_threads", + "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 +4842,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 +4930,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 +4956,7 @@ dependencies = [ "async-trait", "auto_impl", "bytes", - "dashmap", + "dashmap 5.5.3", "futures", "httparse", "lsp-types", @@ -4254,6 +5087,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 +5326,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 +5429,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 +5493,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]] @@ -4937,6 +5804,33 @@ dependencies = [ "synstructure", ] +[[package]] +name = "yup-oauth2" +version = "11.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ed5f19242090128c5809f6535cc7b8d4e2c32433f6c6005800bbc20a644a7f0" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "log", + "percent-encoding", + "rustls", + "rustls-pemfile", + "seahash", + "serde", + "serde_json", + "time", + "tokio", + "url", +] + [[package]] name = "zerocopy" version = "0.8.39" @@ -4978,6 +5872,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 +5888,7 @@ dependencies = [ "asynchronous-codec", "bytes", "crossbeam-queue", - "dashmap", + "dashmap 5.5.3", "futures", "log", "num-traits", 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..f1434541 --- /dev/null +++ b/claude-notes/plans/2026-02-24-oauth2-middleware-design.md @@ -0,0 +1,920 @@ +# 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: no auth required │ │ +│ └──────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ (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/*`) | `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 | +| Health (`/health`) | None | Always open for monitoring | + +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) -> Router { + if let Some(config) = ctx.auth_config() { + let auth_state = auth::build_auth_state(&config.client_id) + .await + .expect("Failed to initialize Google JWKS decoder"); + ctx.set_auth_state(auth_state); + } + + let api_routes = Router::new() + .route("/api/files", get(list_files)) + .route("/api/documents", get(list_documents)); + + Router::new() + .route("/health", get(health)) + .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 + +Token expiry monitoring is built in — no separate hook needed. + +```typescript +// hub-client/src/hooks/useAuth.ts + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + type AuthState, + getStoredAuth, + storeAuth, + clearAuth, +} from '../services/authService'; + +export function useAuth() { + const [auth, setAuth] = useState(getStoredAuth); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const expiryTimer = useRef>(null); + + // Start expiry monitor on mount + useEffect(() => { + setIsLoading(false); + + expiryTimer.current = setInterval(() => { + // getStoredAuth() returns null for expired tokens (and clears storage). + // Sync React state if the stored auth has been cleared. + if (!getStoredAuth()) setAuth(null); + }, 60_000); + + return () => { + if (expiryTimer.current) clearInterval(expiryTimer.current); + }; + }, []); + + const handleCredentialResponse = useCallback((credential: string) => { + try { + setAuth(storeAuth(credential)); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Authentication failed'); + } + }, []); + + const logout = useCallback(() => { + clearAuth(); + setAuth(null); + }, []); + + return { auth, isLoading, error, handleCredentialResponse, 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 returns an ID token +directly — no separate userinfo API call needed. + +```tsx +// hub-client/src/components/auth/LoginButton.tsx + +import { GoogleLogin } from '@react-oauth/google'; + +export function LoginButton({ + onCredential, +}: { + onCredential: (credential: string) => void; +}) { + return ( + { + if (response.credential) onCredential(response.credential); + }} + onError={() => console.error('Google login failed')} + /> + ); +} +``` + +The component is now a pure UI element. The parent calls `useAuth()` and +passes `handleCredentialResponse` as the `onCredential` prop. + +#### 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. + +--- + +### CLI Client (Rust) + +The CLI uses `yup-oauth2` for the installed application flow (opens browser, +receives callback). By requesting `openid` scopes, the token response includes +an `id_token` field which is what the server validates. + +#### Dependencies + +Add to `crates/quarto/Cargo.toml`: + +```toml +[dependencies] +yup-oauth2 = "11" +dirs = "6" +``` + +#### CLI Auth Module + +```rust +// crates/quarto/src/auth.rs + +use anyhow::{Context, Result}; +use std::path::PathBuf; +use yup_oauth2::{InstalledFlowAuthenticator, InstalledFlowReturnMethod}; + +/// Request openid scopes so the token response includes an id_token. +const SCOPES: &[&str] = &[ + "openid", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", +]; + +fn token_cache_path() -> PathBuf { + dirs::cache_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("quarto") + .join("oauth2_tokens.json") +} + +fn client_secret_path() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("quarto") + .join("client_secret.json") +} + +/// Get a Google ID token for hub authentication. +/// Opens browser on first use, uses cached/refreshed tokens subsequently. +pub async fn get_id_token() -> Result { + let secret_path = client_secret_path(); + if !secret_path.exists() { + anyhow::bail!( + "OAuth2 client secret not found at: {}\n\ + Download client_secret.json from Google Cloud Console.", + secret_path.display() + ); + } + + let secret = yup_oauth2::read_application_secret(&secret_path) + .await + .context("Failed to read client secret")?; + + let cache = token_cache_path(); + if let Some(parent) = cache.parent() { + std::fs::create_dir_all(parent)?; + } + + let auth = InstalledFlowAuthenticator::builder( + secret, + InstalledFlowReturnMethod::HTTPRedirect, + ) + .persist_tokens_to_disk(&cache) + .build() + .await + .context("Failed to create authenticator")?; + + // id_token() is a method on Authenticator (not on Token). + // It returns Result, Error>. + // Requires "openid" in SCOPES for Google to include the ID token. + auth.id_token(SCOPES) + .await + .context("Failed to get ID token")? + .ok_or_else(|| anyhow::anyhow!( + "No ID token in response. Ensure 'openid' scope is granted." + )) +} + +pub fn clear_tokens() -> Result<()> { + let path = token_cache_path(); + if path.exists() { std::fs::remove_file(&path)?; } + Ok(()) +} + +pub fn has_cached_tokens() -> bool { + token_cache_path().exists() +} +``` + +#### CLI Commands and Hub Server Flags + +```rust +// crates/quarto/src/commands/auth.rs + +#[derive(Subcommand)] +pub enum AuthCommands { + /// Authenticate with Google for hub access. + Login, + /// Clear cached tokens. + Logout, + /// Show authentication status. + Status, +} +``` + +```rust +// crates/quarto/src/commands/hub.rs (additions) + +#[derive(Parser)] +pub struct HubArgs { + // ... existing fields ... + + /// Google OAuth2 client ID. Presence enables auth. + /// Requires --behind-tls-proxy (or --allow-insecure-auth for local dev). + #[arg(long)] + pub google_client_id: Option, + + /// Acknowledge that a TLS-terminating reverse proxy (nginx, Caddy, + /// cloud LB) sits in front of the hub. Required when auth is enabled. + #[arg(long)] + pub behind_tls_proxy: bool, + + /// Allow auth without TLS (local development only). Tokens will + /// transit in plaintext — never use this in production. + #[arg(long)] + pub allow_insecure_auth: bool, + + /// Allowed email addresses (comma-separated). + #[arg(long, value_delimiter = ',')] + pub allowed_emails: Option>, + + /// Allowed email domains (comma-separated). + #[arg(long, value_delimiter = ',')] + pub allowed_domains: Option>, +} +``` + +#### CLI Client Connection + +```rust +// crates/quarto/src/commands/hub.rs (client connection) + +pub async fn connect_to_hub(url: &str, require_auth: bool) -> Result<()> { + let ws_url = if require_auth { + let token = crate::auth::get_id_token().await?; + format!("{}?id_token={}", url, urlencoding::encode(&token)) + } else { + url.to_string() + }; + + // Connect to hub with ws_url — samod sees a normal WebSocket + // ... + + Ok(()) +} +``` + +--- + +## 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**. + Create **two** credentials: + + **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` + + **Desktop application** (for CLI `q2 auth login`): + - Download the JSON credentials file + - Save as `~/.config/quarto/client_secret.json` + +Both the server `--google-client-id` flag and the browser `VITE_GOOGLE_CLIENT_ID` use the **web application** client ID. The server validates that the JWT `aud` claim matches this ID. The CLI uses the desktop credential to obtain tokens through the browser redirect flow. + +### 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. + +**CLI client:** +```bash +q2 auth login # Opens browser, gets Google ID token +q2 auth status # Shows token cache and client secret paths +q2 auth logout # Clears cached tokens +``` + +--- + +## Security Considerations + +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. **Local validation.** ID tokens are validated by checking the JWT signature against Google's cached public keys. No outbound network call per connection. +3. **Token in URL (WebSocket).** Encrypted by TLS in transit. `RedactedMakeSpan` ensures the `TraceLayer` logs only `uri.path()`, never the query string containing the token. +4. **Short-lived tokens.** Google ID tokens expire in ~1 hour. Limits exposure window. +5. **Audience check.** The `jsonwebtoken::Validation` config verifies the `aud` claim matches the configured client ID, preventing tokens issued for other applications from being accepted. +6. **Domain/email allowlists.** Defense in depth beyond Google authentication. +7. **Minimal client errors.** Invalid/missing tokens return 401; allowlist rejections return 403. Neither includes user-identifying detail. Specific reasons logged server-side only. +8. **localStorage tokens (browser).** Accessible to XSS. Acceptable for v1; mitigate with Content-Security-Policy headers. + +--- + +## Known Limitations + +1. **No silent token refresh.** Google Identity Services' Sign In button does not provide refresh tokens. When the ID token expires (~1hr), the user must re-authenticate. The auth hook detects this proactively. + +2. **Token in WebSocket URL.** Could appear in server access logs. Mitigated by TLS and log configuration. A future iteration could add a short-lived ticket exchange endpoint (`POST /auth/ticket` → one-time ticket for WebSocket URL). + +3. **No user database.** Cannot track users, audit access history, or implement per-user settings. Add if/when needed. + +4. **CLI ID token availability.** `yup-oauth2`'s `Authenticator::id_token()` method returns the ID token when the `openid` scope is requested. The ID token is stored alongside the access token in the token cache, so refreshed tokens also include it. However, the `id_token()` method is separate from `token()` (which only returns the access token). + +--- + +## Implementation Checklist + +### Phase 1: Server Auth Module (Rust — `crates/quarto-hub`) + +- [ ] Add `axum-jwt-auth` and `jsonwebtoken` dependencies +- [ ] Create `src/auth.rs`: `AuthConfig`, `GoogleClaims`, `AuthState`, `check_allowlists()`, `build_auth_state()` +- [ ] Add `auth_config: Option` to `HubConfig`, `OnceLock` to `HubContext` +- [ ] Add `HubContext::authenticate()` helper (JWT decode + allowlist check) +- [ ] REST handlers: extract Bearer token from header, call `ctx.authenticate()` +- [ ] WebSocket handler: extract `id_token` from query param, call `ctx.authenticate()` +- [ ] Integration test: valid token → sync works, invalid/missing token → 401 + +### Phase 2: Browser Client (TypeScript — `hub-client`) + +- [ ] Install `@react-oauth/google` +- [ ] Auth service (`storeAuth` decodes JWT payload inline, localStorage storage) +- [ ] `useAuth` hook (returns `AuthState | null`, includes token expiry monitoring) +- [ ] Login UI using `GoogleLogin` component (returns ID token directly) +- [ ] Append ID token to WebSocket URL in `automergeSync.connect()` + +### Phase 3: CLI Client (Rust — `crates/quarto`) + +- [ ] Add `yup-oauth2` dependency +- [ ] CLI auth module (`get_id_token`, `clear_tokens`) +- [ ] `quarto auth login/logout/status` subcommands +- [ ] `--google-client-id`, `--allowed-emails`, `--allowed-domains` flags +- [ ] Append ID token to WebSocket URL when connecting as client diff --git a/claude-notes/plans/2026-02-24-oauth2-middleware.md b/claude-notes/plans/2026-02-24-oauth2-middleware.md new file mode 100644 index 00000000..905684ab --- /dev/null +++ b/claude-notes/plans/2026-02-24-oauth2-middleware.md @@ -0,0 +1,46 @@ +# OAuth2 Middleware Implementation Plan + +*2026-02-24* + +Google OAuth2 authentication for quarto-hub. Design doc: `claude-notes/plans/2026-02-24-oauth2-middleware-design.md` + +## 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 +- [x] REST handlers: extract Bearer token from header, call `ctx.authenticate()` +- [x] WebSocket handler: extract `id_token` from query param, call `ctx.authenticate()` +- [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) +- [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`) + +- [x] Add `yup-oauth2` and `dirs` dependencies +- [x] Create `crates/quarto/src/auth.rs` (get_id_token, clear_tokens, status) +- [x] Add `quarto auth login/logout/status` subcommands +- [ ] Append ID token to WebSocket URL when connecting as client (deferred: no client connect command exists yet) diff --git a/crates/quarto-hub/Cargo.toml b/crates/quarto-hub/Cargo.toml index e8d476fc..62f595fe 100644 --- a/crates/quarto-hub/Cargo.toml +++ b/crates/quarto-hub/Cargo.toml @@ -24,6 +24,12 @@ tokio = { version = "1", features = ["full"] } axum = { version = "0.8", features = ["ws"] } tower = "0.5" tower-http = { version = "0.6", features = ["trace", "cors"] } +http = "1" + +# OAuth2 / JWT authentication +axum-jwt-auth = "0.6" +jsonwebtoken = "10" +tokio-util = { workspace = true } # Automerge (via samod for JS compatibility) automerge = "0.7" diff --git a/crates/quarto-hub/src/auth.rs b/crates/quarto-hub/src/auth.rs new file mode 100644 index 00000000..1feaf9ae --- /dev/null +++ b/crates/quarto-hub/src/auth.rs @@ -0,0 +1,252 @@ +//! Google OAuth2 authentication for quarto-hub. +//! +//! All auth code lives in this module. Authentication is optional — disabled +//! by default and enabled with `--google-client-id `. +//! +//! Uses Google ID tokens (JWTs) validated locally against Google's cached +//! public keys via `axum-jwt-auth`. No per-connection HTTP call to Google. + +use axum::http::StatusCode; +use axum_jwt_auth::RemoteJwksDecoder; +use jsonwebtoken::{Algorithm, Validation}; +use serde::{Deserialize, Serialize}; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + +/// 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<()>, + /// Cancellation token to stop the JWKS refresh task. + _cancellation_token: CancellationToken, +} + +impl std::fmt::Debug for AuthState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AuthState") + .field("decoder", &"") + .finish() + } +} + +/// 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, +) -> std::result::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".to_string()) + .validation(validation) + .build()?; + + // Fetch the initial JWKS keys from Google before accepting requests. + decoder.initialize().await?; + + // 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 cancellation_token = CancellationToken::new(); + let refresh_decoder = decoder.clone(); + let token = cancellation_token.clone(); + let refresh_handle = tokio::spawn(async move { + refresh_decoder.refresh_keys_periodically(token).await; + }); + + Ok(AuthState { + decoder, + _refresh_handle: refresh_handle, + _cancellation_token: cancellation_token, + }) +} + +/// Validate that TLS is accounted for when auth is enabled. +/// Called once at startup before the server accepts requests. +pub fn validate_tls_config( + google_client_id: Option<&str>, + behind_tls_proxy: bool, + allow_insecure_auth: bool, +) { + if google_client_id.is_some() && !behind_tls_proxy && !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 allow_insecure_auth && google_client_id.is_some() { + tracing::warn!( + "Auth enabled WITHOUT TLS (--allow-insecure-auth). \ + Tokens will transit in plaintext. Do not use in production." + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_claims(email: &str, verified: bool) -> GoogleClaims { + GoogleClaims { + sub: "123".to_string(), + email: email.to_string(), + email_verified: verified, + name: Some("Test User".to_string()), + picture: None, + } + } + + fn make_config( + emails: Option>, + domains: Option>, + ) -> AuthConfig { + AuthConfig { + client_id: "test-client-id".to_string(), + allowed_emails: emails.map(|v| v.into_iter().map(String::from).collect()), + allowed_domains: domains.map(|v| v.into_iter().map(String::from).collect()), + } + } + + #[test] + fn unverified_email_returns_unauthorized() { + let claims = make_claims("user@example.com", false); + let config = make_config(None, None); + assert_eq!(check_allowlists(&claims, &config), Err(StatusCode::UNAUTHORIZED)); + } + + #[test] + fn no_allowlists_allows_all_verified() { + let claims = make_claims("user@example.com", true); + let config = make_config(None, None); + assert_eq!(check_allowlists(&claims, &config), Ok(())); + } + + #[test] + fn email_allowlist_match() { + let claims = make_claims("admin@example.com", true); + let config = make_config(Some(vec!["admin@example.com"]), None); + assert_eq!(check_allowlists(&claims, &config), Ok(())); + } + + #[test] + fn email_allowlist_no_match() { + let claims = make_claims("other@example.com", true); + let config = make_config(Some(vec!["admin@example.com"]), None); + assert_eq!(check_allowlists(&claims, &config), Err(StatusCode::FORBIDDEN)); + } + + #[test] + fn domain_allowlist_match() { + let claims = make_claims("user@company.com", true); + let config = make_config(None, Some(vec!["company.com"])); + assert_eq!(check_allowlists(&claims, &config), Ok(())); + } + + #[test] + fn domain_allowlist_no_match() { + let claims = make_claims("user@other.com", true); + let config = make_config(None, Some(vec!["company.com"])); + assert_eq!(check_allowlists(&claims, &config), Err(StatusCode::FORBIDDEN)); + } + + #[test] + fn combined_lists_or_logic_email_match() { + let claims = make_claims("contractor@gmail.com", true); + let config = make_config( + Some(vec!["contractor@gmail.com"]), + Some(vec!["company.com"]), + ); + assert_eq!(check_allowlists(&claims, &config), Ok(())); + } + + #[test] + fn combined_lists_or_logic_domain_match() { + let claims = make_claims("employee@company.com", true); + let config = make_config( + Some(vec!["contractor@gmail.com"]), + Some(vec!["company.com"]), + ); + assert_eq!(check_allowlists(&claims, &config), Ok(())); + } + + #[test] + fn combined_lists_or_logic_no_match() { + let claims = make_claims("random@other.com", true); + let config = make_config( + Some(vec!["contractor@gmail.com"]), + Some(vec!["company.com"]), + ); + assert_eq!(check_allowlists(&claims, &config), Err(StatusCode::FORBIDDEN)); + } +} diff --git a/crates/quarto-hub/src/context.rs b/crates/quarto-hub/src/context.rs index b5269d43..76343829 100644 --- a/crates/quarto-hub/src/context.rs +++ b/crates/quarto-hub/src/context.rs @@ -3,14 +3,17 @@ //! Contains the automerge repo and storage manager. use std::path::Path; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use automerge::{Automerge, ObjType, ROOT, transaction::Transactable}; +use axum::http::StatusCode; +use axum_jwt_auth::JwtDecoder; use samod::Repo; use samod::storage::TokioFilesystemStorage; use tokio::sync::{Mutex, RwLock}; use tracing::{debug, info, warn}; +use crate::auth::{self, AuthConfig, AuthState, GoogleClaims}; use crate::discovery::ProjectFiles; use crate::error::Result; use crate::index::{IndexDocument, load_or_create_index}; @@ -45,6 +48,9 @@ pub struct HubConfig { /// Debounce duration for filesystem events in milliseconds. /// Default: 500ms. pub watch_debounce_ms: u64, + + /// OAuth2 auth configuration. None = auth disabled. + pub auth_config: Option, } impl Default for HubConfig { @@ -56,6 +62,7 @@ impl Default for HubConfig { sync_interval_secs: Some(30), watch_enabled: true, watch_debounce_ms: 500, + auth_config: None, } } } @@ -84,6 +91,14 @@ pub struct HubContext { /// Sync state for filesystem synchronization (protected by Mutex for interior mutability) sync_state: Mutex, + + /// OAuth2 auth configuration (immutable after startup). None = auth disabled. + auth_config: Option, + + /// 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 { @@ -93,7 +108,7 @@ impl HubContext { /// 1. Initializes the samod Repo with filesystem storage at `.quarto/hub/automerge/` /// 2. Loads or creates the index document /// 3. Reconciles discovered .qmd files with the index - pub async fn new(mut storage: StorageManager, config: HubConfig) -> Result { + pub async fn new(mut storage: StorageManager, mut config: HubConfig) -> Result { // Discover project files let project_files = ProjectFiles::discover(storage.project_root()); @@ -152,6 +167,10 @@ impl HubContext { "Initial filesystem sync complete" ); + // Extract auth_config from HubConfig — it's immutable after startup + // and stored separately to avoid holding the RwLock during auth checks. + let auth_config = config.auth_config.take(); + Ok(Self { storage, config: RwLock::new(config), @@ -159,6 +178,8 @@ impl HubContext { repo, index, sync_state: Mutex::new(sync_state_guard), + auth_config, + auth_state: OnceLock::new(), }) } @@ -219,6 +240,47 @@ impl HubContext { ) .await } + + /// Get the auth configuration, if auth is enabled. + pub fn auth_config(&self) -> Option<&AuthConfig> { + self.auth_config.as_ref() + } + + /// 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>) -> std::result::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(()) + } } /// Type alias for the shared context used in axum handlers. diff --git a/crates/quarto-hub/src/lib.rs b/crates/quarto-hub/src/lib.rs index 8a967db0..c283bf52 100644 --- a/crates/quarto-hub/src/lib.rs +++ b/crates/quarto-hub/src/lib.rs @@ -6,6 +6,7 @@ //! - WebSocket sync protocol for real-time collaboration //! - REST API for document operations +pub mod auth; pub mod context; pub mod discovery; pub mod error; diff --git a/crates/quarto-hub/src/main.rs b/crates/quarto-hub/src/main.rs index 7240e1ba..f007cd9b 100644 --- a/crates/quarto-hub/src/main.rs +++ b/crates/quarto-hub/src/main.rs @@ -6,7 +6,7 @@ use clap::Parser; use tracing::info; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -use quarto_hub::{StorageManager, context::HubConfig, server}; +use quarto_hub::{StorageManager, auth, context::HubConfig, server}; #[derive(Parser, Debug)] #[command(name = "hub")] @@ -45,6 +45,29 @@ struct Args { /// Default: 500ms. #[arg(long, default_value = "500")] watch_debounce: u64, + + /// Google OAuth2 client ID. Presence enables auth. + /// Requires --behind-tls-proxy (or --allow-insecure-auth for local dev). + #[arg(long)] + google_client_id: Option, + + /// Acknowledge that a TLS-terminating reverse proxy (nginx, Caddy, + /// cloud LB) sits in front of the hub. Required when auth is enabled. + #[arg(long)] + behind_tls_proxy: bool, + + /// Allow auth without TLS (local development only). Tokens will + /// transit in plaintext — never use this in production. + #[arg(long)] + allow_insecure_auth: bool, + + /// Allowed email addresses (comma-separated). + #[arg(long, value_delimiter = ',')] + allowed_emails: Option>, + + /// Allowed email domains (comma-separated). + #[arg(long, value_delimiter = ',')] + allowed_domains: Option>, } #[tokio::main] @@ -88,6 +111,22 @@ async fn main() -> anyhow::Result<()> { stored_peers }; + // Validate TLS configuration when auth is enabled + auth::validate_tls_config( + args.google_client_id.as_deref(), + args.behind_tls_proxy, + args.allow_insecure_auth, + ); + + // Build auth config if Google client ID is provided + let auth_config = args.google_client_id.map(|client_id| { + auth::AuthConfig { + client_id, + allowed_emails: args.allowed_emails, + allowed_domains: args.allowed_domains, + } + }); + // Configure and run server let sync_interval_secs = if args.sync_interval == 0 { None @@ -102,6 +141,7 @@ async fn main() -> anyhow::Result<()> { sync_interval_secs, watch_enabled: !args.no_watch, watch_debounce_ms: args.watch_debounce, + auth_config, }; server::run_server(storage, config).await?; diff --git a/crates/quarto-hub/src/server.rs b/crates/quarto-hub/src/server.rs index cc7c7e55..d767c902 100644 --- a/crates/quarto-hub/src/server.rs +++ b/crates/quarto-hub/src/server.rs @@ -7,10 +7,10 @@ use std::time::Duration; use axum::{ Json, Router, extract::{ - Path, State, + Path, Query, State, ws::{WebSocket, WebSocketUpgrade}, }, - http::StatusCode, + http::{HeaderMap, StatusCode}, response::IntoResponse, routing::get, }; @@ -21,6 +21,7 @@ use tokio::sync::watch; use tower_http::trace::TraceLayer; use tracing::{debug, info}; +use crate::auth; use crate::context::{HubConfig, HubContext, SharedContext}; use crate::error::Result; use crate::storage::StorageManager; @@ -77,6 +78,48 @@ struct UpdateDocumentRequest { value: String, } +/// WebSocket query parameters. +#[derive(Deserialize)] +struct WsParams { + id_token: Option, +} + +/// 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"})), + ) +} + +/// 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 ") +} + +/// 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(), + ) + } +} + /// Health check endpoint async fn health(State(ctx): State) -> impl IntoResponse { let response = HealthResponse { @@ -89,7 +132,13 @@ async fn health(State(ctx): State) -> impl IntoResponse { } /// List discovered files (from filesystem) -async fn list_files(State(ctx): State) -> impl IntoResponse { +async fn list_files( + headers: HeaderMap, + State(ctx): State, +) -> std::result::Result)> { + ctx.authenticate(bearer_token(&headers)) + .await + .map_err(|_| unauthorized())?; let response = FilesResponse { qmd_files: ctx .project_files() @@ -98,11 +147,17 @@ async fn list_files(State(ctx): State) -> impl IntoResponse { .map(|p| p.display().to_string()) .collect(), }; - Json(response) + Ok(Json(response)) } /// List all documents from the index -async fn list_documents(State(ctx): State) -> impl IntoResponse { +async fn list_documents( + headers: HeaderMap, + State(ctx): State, +) -> std::result::Result)> { + ctx.authenticate(bearer_token(&headers)) + .await + .map_err(|_| unauthorized())?; let files = ctx.index().get_all_files(); let documents: Vec = files @@ -110,14 +165,25 @@ async fn list_documents(State(ctx): State) -> impl IntoResponse { .map(|(path, document_id)| DocumentEntry { path, document_id }) .collect(); - Json(DocumentsResponse { documents }) + Ok(Json(DocumentsResponse { documents })) } /// Get a single document by ID async fn get_document( + headers: HeaderMap, State(ctx): State, Path(doc_id_str): Path, ) -> impl IntoResponse { + if let Err(status) = ctx.authenticate(bearer_token(&headers)).await { + return ( + status, + Json(ErrorResponse { + error: "unauthorized".to_string(), + }), + ) + .into_response(); + } + // Validate the document ID format let doc_id = match DocumentId::from_str(&doc_id_str) { Ok(id) => id, @@ -171,12 +237,23 @@ async fn get_document( /// This is a simple endpoint that puts a key-value pair into the document. /// In a real implementation, the document schema would be more structured. async fn update_document( + headers: HeaderMap, State(ctx): State, Path(doc_id_str): Path, Json(request): Json, ) -> impl IntoResponse { use automerge::{ROOT, transaction::Transactable}; + if let Err(status) = ctx.authenticate(bearer_token(&headers)).await { + return ( + status, + Json(ErrorResponse { + error: "unauthorized".to_string(), + }), + ) + .into_response(); + } + // Validate the document ID format let doc_id = match DocumentId::from_str(&doc_id_str) { Ok(id) => id, @@ -244,8 +321,16 @@ async fn not_found() -> impl IntoResponse { /// WebSocket upgrade handler for automerge sync. /// /// Clients connect here to sync documents in real-time. -async fn ws_handler(ws: WebSocketUpgrade, State(ctx): State) -> impl IntoResponse { - ws.on_upgrade(|socket| handle_websocket(socket, ctx)) +/// Auth token is passed via `?id_token=` query parameter +/// (browsers can't set custom headers on WebSocket upgrade). +async fn ws_handler( + State(ctx): State, + Query(params): Query, + ws: WebSocketUpgrade, +) -> std::result::Result { + ctx.authenticate(params.id_token.as_deref()).await?; + + Ok(ws.on_upgrade(|socket| handle_websocket(socket, ctx))) } /// Handle an upgraded WebSocket connection. @@ -265,8 +350,16 @@ async fn handle_websocket(socket: WebSocket, ctx: SharedContext) { } } -/// Build the axum router -fn build_router(ctx: SharedContext) -> Router { +/// Build the axum 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) -> Router { + if let Some(config) = ctx.auth_config() { + let auth_state = auth::build_auth_state(&config.client_id) + .await + .expect("Failed to initialize Google JWKS decoder"); + ctx.set_auth_state(auth_state); + } + Router::new() .route("/health", get(health)) .route("/api/files", get(list_files)) @@ -281,7 +374,7 @@ fn build_router(ctx: SharedContext) -> Router { .route("/", get(ws_handler)) .route("/ws", get(ws_handler)) .fallback(not_found) - .layer(TraceLayer::new_for_http()) + .layer(TraceLayer::new_for_http().make_span_with(RedactedMakeSpan)) .with_state(ctx) } @@ -306,7 +399,7 @@ pub async fn run_server(storage: StorageManager, config: HubConfig) -> Result<() let ctx_for_watch = ctx.clone(); let ctx_for_shutdown = ctx.clone(); - let router = build_router(ctx); + let router = build_router(ctx).await; let listener = TcpListener::bind(&addr).await?; info!(%addr, "Hub server listening"); diff --git a/crates/quarto/Cargo.toml b/crates/quarto/Cargo.toml index 847ffe47..0df36e40 100644 --- a/crates/quarto/Cargo.toml +++ b/crates/quarto/Cargo.toml @@ -30,6 +30,10 @@ quarto-sass.workspace = true quarto-test.workspace = true serde_yaml.workspace = true +# OAuth2 (CLI authentication for hub) +yup-oauth2 = "11" +dirs = "6" + [build-dependencies] [dev-dependencies] diff --git a/crates/quarto/src/auth.rs b/crates/quarto/src/auth.rs new file mode 100644 index 00000000..5d36b68c --- /dev/null +++ b/crates/quarto/src/auth.rs @@ -0,0 +1,96 @@ +//! OAuth2 authentication for the Quarto CLI. +//! +//! Uses `yup-oauth2` for the installed application flow (opens browser, +//! receives callback). By requesting `openid` scopes, the token response +//! includes an `id_token` field which is what the hub server validates. + +use anyhow::{Context, Result}; +use std::path::PathBuf; +use yup_oauth2::{InstalledFlowAuthenticator, InstalledFlowReturnMethod}; + +/// Request openid scopes so the token response includes an id_token. +const SCOPES: &[&str] = &[ + "openid", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", +]; + +fn token_cache_path() -> PathBuf { + dirs::cache_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("quarto") + .join("oauth2_tokens.json") +} + +fn client_secret_path() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("quarto") + .join("client_secret.json") +} + +/// Get a Google ID token for hub authentication. +/// Opens browser on first use, uses cached/refreshed tokens subsequently. +pub async fn get_id_token() -> Result { + let secret_path = client_secret_path(); + if !secret_path.exists() { + anyhow::bail!( + "OAuth2 client secret not found at: {}\n\ + Download client_secret.json from Google Cloud Console.", + secret_path.display() + ); + } + + let secret = yup_oauth2::read_application_secret(&secret_path) + .await + .context("Failed to read client secret")?; + + let cache = token_cache_path(); + if let Some(parent) = cache.parent() { + std::fs::create_dir_all(parent)?; + } + + let auth = InstalledFlowAuthenticator::builder( + secret, + InstalledFlowReturnMethod::HTTPRedirect, + ) + .persist_tokens_to_disk(&cache) + .build() + .await + .context("Failed to create authenticator")?; + + // id_token() returns Result, Error>. + // Requires "openid" in SCOPES for Google to include the ID token. + auth.id_token(SCOPES) + .await + .context("Failed to get ID token")? + .ok_or_else(|| { + anyhow::anyhow!("No ID token in response. Ensure 'openid' scope is granted.") + }) +} + +pub fn clear_tokens() -> Result<()> { + let path = token_cache_path(); + if path.exists() { + std::fs::remove_file(&path)?; + } + Ok(()) +} + +/// Show authentication status. +pub fn status() { + let cache = token_cache_path(); + let secret = client_secret_path(); + + if secret.exists() { + println!("Client secret: {}", secret.display()); + } else { + println!("Client secret: not found (expected at {})", secret.display()); + } + + if cache.exists() { + println!("Token cache: {} (cached)", cache.display()); + } else { + println!("Token cache: not logged in"); + } +} diff --git a/crates/quarto/src/commands/auth_cmd.rs b/crates/quarto/src/commands/auth_cmd.rs new file mode 100644 index 00000000..4463c1d2 --- /dev/null +++ b/crates/quarto/src/commands/auth_cmd.rs @@ -0,0 +1,37 @@ +//! Auth command - manage authentication for hub access +//! +//! Provides login, logout, and status subcommands for Google OAuth2 +//! authentication used when connecting to authenticated hub servers. + +use anyhow::Result; + +use crate::auth; + +/// Execute the auth login command. +pub fn login() -> Result<()> { + let runtime = tokio::runtime::Runtime::new()?; + runtime.block_on(async { + let token = auth::get_id_token().await?; + // Truncate for display + let display = if token.len() > 20 { + format!("{}...{}", &token[..10], &token[token.len() - 10..]) + } else { + token.clone() + }; + println!("Authenticated successfully. ID token: {display}"); + Ok(()) + }) +} + +/// Execute the auth logout command. +pub fn logout() -> Result<()> { + auth::clear_tokens()?; + println!("Logged out. Token cache cleared."); + Ok(()) +} + +/// Execute the auth status command. +pub fn status() -> Result<()> { + auth::status(); + Ok(()) +} diff --git a/crates/quarto/src/commands/hub.rs b/crates/quarto/src/commands/hub.rs index 981b17ee..f495e1c9 100644 --- a/crates/quarto/src/commands/hub.rs +++ b/crates/quarto/src/commands/hub.rs @@ -6,7 +6,7 @@ use std::path::PathBuf; use anyhow::Result; -use quarto_hub::{StorageManager, context::HubConfig, server}; +use quarto_hub::{StorageManager, auth, context::HubConfig, server}; use tracing::info; /// Arguments for the hub command. @@ -18,6 +18,11 @@ pub struct HubArgs { pub sync_interval: u64, pub no_watch: bool, pub watch_debounce: u64, + pub google_client_id: Option, + pub behind_tls_proxy: bool, + pub allow_insecure_auth: bool, + pub allowed_emails: Option>, + pub allowed_domains: Option>, } /// Execute the hub command. @@ -66,6 +71,22 @@ async fn run_hub(args: HubArgs) -> Result<()> { stored_peers }; + // Validate TLS configuration when auth is enabled + auth::validate_tls_config( + args.google_client_id.as_deref(), + args.behind_tls_proxy, + args.allow_insecure_auth, + ); + + // Build auth config if Google client ID is provided + let auth_config = args.google_client_id.map(|client_id| { + auth::AuthConfig { + client_id, + allowed_emails: args.allowed_emails, + allowed_domains: args.allowed_domains, + } + }); + // Configure and run server let sync_interval_secs = if args.sync_interval == 0 { None @@ -80,6 +101,7 @@ async fn run_hub(args: HubArgs) -> Result<()> { sync_interval_secs, watch_enabled: !args.no_watch, watch_debounce_ms: args.watch_debounce, + auth_config, }; server::run_server(storage, config).await?; diff --git a/crates/quarto/src/commands/mod.rs b/crates/quarto/src/commands/mod.rs index 6cddb5d2..d3f25634 100644 --- a/crates/quarto/src/commands/mod.rs +++ b/crates/quarto/src/commands/mod.rs @@ -4,6 +4,7 @@ //! quarto-core for actual implementation. pub mod add; +pub mod auth_cmd; pub mod call; pub mod check; pub mod convert; diff --git a/crates/quarto/src/main.rs b/crates/quarto/src/main.rs index 00900596..3d3fbd06 100644 --- a/crates/quarto/src/main.rs +++ b/crates/quarto/src/main.rs @@ -6,6 +6,7 @@ use anyhow::Result; use clap::{Parser, Subcommand}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +mod auth; mod commands; #[derive(Parser)] @@ -319,6 +320,12 @@ enum Commands { /// Start the Quarto Language Server Protocol server Lsp, + /// Manage authentication for hub access + Auth { + #[command(subcommand)] + action: AuthAction, + }, + /// Start collaborative hub server for real-time editing Hub { /// Project root directory (defaults to current directory) @@ -352,9 +359,42 @@ enum Commands { /// Debounce duration for filesystem events in milliseconds. #[arg(long, default_value = "500")] watch_debounce: u64, + + /// Google OAuth2 client ID. Presence enables auth. + /// Requires --behind-tls-proxy (or --allow-insecure-auth for local dev). + #[arg(long)] + google_client_id: Option, + + /// Acknowledge that a TLS-terminating reverse proxy (nginx, Caddy, + /// cloud LB) sits in front of the hub. Required when auth is enabled. + #[arg(long)] + behind_tls_proxy: bool, + + /// Allow auth without TLS (local development only). Tokens will + /// transit in plaintext — never use this in production. + #[arg(long)] + allow_insecure_auth: bool, + + /// Allowed email addresses (comma-separated). + #[arg(long, value_delimiter = ',')] + allowed_emails: Option>, + + /// Allowed email domains (comma-separated). + #[arg(long, value_delimiter = ',')] + allowed_domains: Option>, }, } +#[derive(Subcommand)] +enum AuthAction { + /// Authenticate with Google for hub access. + Login, + /// Clear cached authentication tokens. + Logout, + /// Show authentication status. + Status, +} + fn main() -> Result<()> { // Initialize logging tracing_subscriber::registry() @@ -403,6 +443,11 @@ fn main() -> Result<()> { Commands::Check { .. } => commands::check::execute(), Commands::Call { function, args } => commands::call::execute(function, args), Commands::Lsp => commands::lsp::execute(), + Commands::Auth { action } => match action { + AuthAction::Login => commands::auth_cmd::login(), + AuthAction::Logout => commands::auth_cmd::logout(), + AuthAction::Status => commands::auth_cmd::status(), + }, Commands::Hub { project, port, @@ -411,6 +456,11 @@ fn main() -> Result<()> { sync_interval, no_watch, watch_debounce, + google_client_id, + behind_tls_proxy, + allow_insecure_auth, + allowed_emails, + allowed_domains, } => commands::hub::execute(commands::hub::HubArgs { project, port, @@ -419,6 +469,11 @@ fn main() -> Result<()> { sync_interval, no_watch, watch_debounce, + google_client_id, + behind_tls_proxy, + allow_insecure_auth, + allowed_emails, + allowed_domains, }), } } diff --git a/hub-client/src/App.tsx b/hub-client/src/App.tsx index c595688f..ad3dd6c8 100644 --- a/hub-client/src/App.tsx +++ b/hub-client/src/App.tsx @@ -4,6 +4,7 @@ import ProjectSelector from './components/ProjectSelector'; import Editor from './components/Editor'; import Toast from './components/Toast'; import { ViewModeProvider } from './components/ViewModeContext'; +import { LoginButton } from './components/auth/LoginButton'; import { connect, disconnect, @@ -15,9 +16,13 @@ import { import type { ProjectFile } from './services/wasmRenderer'; import * as projectStorage from './services/projectStorage'; import { useRouting } from './hooks/useRouting'; +import { useAuth } from './hooks/useAuth'; import type { Route, ShareRoute } from './utils/routing'; import './App.css'; +/** Whether auth is configured (build-time env var). */ +const AUTH_ENABLED = !!import.meta.env.VITE_GOOGLE_CLIENT_ID; + /** * Data extracted from a shareable link, used to pre-fill the connect dialog. */ @@ -31,6 +36,8 @@ export interface PendingShareData { } function App() { + const { auth, isLoading: authLoading, error: authError, handleCredentialResponse } = useAuth(); + const [project, setProject] = useState(null); const [files, setFiles] = useState([]); const [isConnecting, setIsConnecting] = useState(false); @@ -384,6 +391,21 @@ function App() { setPendingShareData(null); }, []); + // Auth gate: when auth is enabled, require login before showing the app. + if (AUTH_ENABLED && !authLoading && !auth) { + return ( +
+

Quarto Hub

+

Sign in with Google to continue

+ + {authError &&

{authError}

} +
+ ); + } + return ( <> {!project ? ( diff --git a/hub-client/src/components/auth/LoginButton.tsx b/hub-client/src/components/auth/LoginButton.tsx new file mode 100644 index 00000000..8a636ce8 --- /dev/null +++ b/hub-client/src/components/auth/LoginButton.tsx @@ -0,0 +1,23 @@ +/** + * Google Sign-In button wrapper. + * + * Uses Google Identity Services' "Sign In With Google" button which + * returns an ID token directly — no separate userinfo API call needed. + */ + +import { GoogleLogin } from '@react-oauth/google'; + +export function LoginButton({ + onCredential, +}: { + onCredential: (credential: string) => void; +}) { + return ( + { + if (response.credential) onCredential(response.credential); + }} + onError={() => console.error('Google login failed')} + /> + ); +} diff --git a/hub-client/src/hooks/useAuth.ts b/hub-client/src/hooks/useAuth.ts new file mode 100644 index 00000000..ff3f4851 --- /dev/null +++ b/hub-client/src/hooks/useAuth.ts @@ -0,0 +1,52 @@ +/** + * useAuth Hook + * + * Manages authentication state for the hub client. Handles Google + * credential responses, token expiry monitoring, and logout. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + type AuthState, + getStoredAuth, + storeAuth, + clearAuth, +} from '../services/authService'; + +export function useAuth() { + const [auth, setAuth] = useState(getStoredAuth); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const expiryTimer = useRef>(null); + + // Start expiry monitor on mount + useEffect(() => { + setIsLoading(false); + + expiryTimer.current = setInterval(() => { + // getStoredAuth() returns null for expired tokens (and clears storage). + // Sync React state if the stored auth has been cleared. + if (!getStoredAuth()) setAuth(null); + }, 60_000); + + return () => { + if (expiryTimer.current) clearInterval(expiryTimer.current); + }; + }, []); + + const handleCredentialResponse = useCallback((credential: string) => { + try { + setAuth(storeAuth(credential)); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Authentication failed'); + } + }, []); + + const logout = useCallback(() => { + clearAuth(); + setAuth(null); + }, []); + + return { auth, isLoading, error, handleCredentialResponse, logout }; +} diff --git a/hub-client/src/main.tsx b/hub-client/src/main.tsx index bef5202a..dc606953 100644 --- a/hub-client/src/main.tsx +++ b/hub-client/src/main.tsx @@ -1,10 +1,19 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { GoogleOAuthProvider } from '@react-oauth/google' import './index.css' import App from './App.tsx' -createRoot(document.getElementById('root')!).render( +const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID; + +const root = ( - , + +); + +createRoot(document.getElementById('root')!).render( + GOOGLE_CLIENT_ID + ? {root} + : root, ) diff --git a/hub-client/src/services/authService.ts b/hub-client/src/services/authService.ts new file mode 100644 index 00000000..f4332d4f --- /dev/null +++ b/hub-client/src/services/authService.ts @@ -0,0 +1,66 @@ +/** + * Auth Service + * + * Manages Google OAuth2 authentication state. Stores ID tokens in + * localStorage, decodes JWT payloads client-side (server validates), + * and handles token expiry detection. + */ + +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; +} diff --git a/hub-client/src/services/automergeSync.ts b/hub-client/src/services/automergeSync.ts index 0a56a58d..54012ee9 100644 --- a/hub-client/src/services/automergeSync.ts +++ b/hub-client/src/services/automergeSync.ts @@ -19,6 +19,7 @@ import { } from '@quarto/quarto-sync-client'; import { vfsAddFile, vfsAddBinaryFile, vfsRemoveFile, vfsClear, initWasm } from './wasmRenderer'; +import { getIdToken } from './authService'; // Re-export types for use in other components export type { Patch, FileEntry, CreateBinaryFileResult, CreateProjectOptions, CreateProjectResult }; @@ -99,11 +100,22 @@ function ensureClient(): SyncClient { /** * Connect to a sync server and load a project. + * + * When auth is enabled, appends the ID token to the WebSocket URL + * as a query parameter. The sync client and samod are completely + * unaware of authentication. */ export async function connect(syncServerUrl: string, indexDocId: string): Promise { await initWasm(); vfsClear(); - return ensureClient().connect(syncServerUrl, indexDocId); + + // Append ID token to WebSocket URL if available + const token = getIdToken(); + const url = token + ? `${syncServerUrl}${syncServerUrl.includes('?') ? '&' : '?'}id_token=${encodeURIComponent(token)}` + : syncServerUrl; + + return ensureClient().connect(url, indexDocId); } /** diff --git a/hub-client/src/vite-env.d.ts b/hub-client/src/vite-env.d.ts index 385cb856..2c9e818e 100644 --- a/hub-client/src/vite-env.d.ts +++ b/hub-client/src/vite-env.d.ts @@ -3,6 +3,8 @@ interface ImportMetaEnv { /** Default Automerge sync server URL (set at build time) */ readonly VITE_DEFAULT_SYNC_SERVER?: string + /** Google OAuth2 client ID. When set, enables authentication. */ + readonly VITE_GOOGLE_CLIENT_ID?: string } interface ImportMeta { diff --git a/package-lock.json b/package-lock.json index 0de13081..3fc3c393 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "q2-demos/*" ], "dependencies": { + "@react-oauth/google": "^0.13.4", "@types/katex": "^0.16.8", "@types/morphdom": "^2.3.0", "html2canvas": "^1.4.1", @@ -2782,6 +2783,16 @@ "resolved": "ts-packages/quarto-sync-client", "link": true }, + "node_modules/@react-oauth/google": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.13.4.tgz", + "integrity": "sha512-hGKyNEH+/PK8M0sFEuo3MAEk0txtHpgs94tDQit+s2LXg7b6z53NtzHfqDvoB2X8O6lGB+FRg80hY//X6hfD+w==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", diff --git a/package.json b/package.json index 79be002b..30476572 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "typecheck": "npm run typecheck --workspaces --if-present" }, "dependencies": { + "@react-oauth/google": "^0.13.4", "@types/katex": "^0.16.8", "@types/morphdom": "^2.3.0", "html2canvas": "^1.4.1", From 3874901e550d3804f2958a2fca06fc6ba5c8c8fb Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:55:08 +0000 Subject: [PATCH 02/29] Apply styling to client login screen --- hub-client/src/App.tsx | 25 ++++++--- .../src/components/auth/LoginScreen.css | 56 +++++++++++++++++++ 2 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 hub-client/src/components/auth/LoginScreen.css diff --git a/hub-client/src/App.tsx b/hub-client/src/App.tsx index ad3dd6c8..f77dba5c 100644 --- a/hub-client/src/App.tsx +++ b/hub-client/src/App.tsx @@ -5,6 +5,7 @@ import Editor from './components/Editor'; import Toast from './components/Toast'; import { ViewModeProvider } from './components/ViewModeContext'; import { LoginButton } from './components/auth/LoginButton'; +import './components/auth/LoginScreen.css'; import { connect, disconnect, @@ -392,16 +393,22 @@ function App() { }, []); // Auth gate: when auth is enabled, require login before showing the app. - if (AUTH_ENABLED && !authLoading && !auth) { + if (AUTH_ENABLED && !auth) { return ( -
-

Quarto Hub

-

Sign in with Google to continue

- - {authError &&

{authError}

} +
+
+ Quarto +

Quarto Hub

+ {authLoading ? ( +

Loading...

+ ) : ( + <> +

Sign in with Google to continue

+ + {authError &&

{authError}

} + + )} +
); } diff --git a/hub-client/src/components/auth/LoginScreen.css b/hub-client/src/components/auth/LoginScreen.css new file mode 100644 index 00000000..6d7f646b --- /dev/null +++ b/hub-client/src/components/auth/LoginScreen.css @@ -0,0 +1,56 @@ +.login-screen { + position: fixed; + inset: 0; + background: #242424; + display: flex; + align-items: center; + justify-content: center; +} + +.login-card { + background: #1a1a2e; + border-radius: 12px; + padding: 48px 32px; + max-width: 400px; + width: 90%; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.login-logo { + width: 48px; + height: 48px; + margin-bottom: 8px; +} + +.login-card h2 { + margin: 0; + font-size: 24px; + color: #fff; + font-weight: 600; +} + +.login-card .login-subtitle { + margin: 0 0 16px; + color: #888; + font-size: 14px; +} + +.login-card .login-error { + background: #3d2020; + color: #f87171; + padding: 12px; + border-radius: 6px; + font-size: 13px; + width: 100%; + box-sizing: border-box; + text-align: center; +} + +.login-card .login-loading { + color: #888; + font-size: 14px; +} From 390c2c9e414ff79bc3ea8a4b305f7b44d0e0a831 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Tue, 24 Feb 2026 17:54:57 +0000 Subject: [PATCH 03/29] Fix auth when creating new projects --- hub-client/src/services/automergeSync.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/hub-client/src/services/automergeSync.ts b/hub-client/src/services/automergeSync.ts index 54012ee9..989a691f 100644 --- a/hub-client/src/services/automergeSync.ts +++ b/hub-client/src/services/automergeSync.ts @@ -206,7 +206,14 @@ export function isConnected(): boolean { export async function createNewProject(options: CreateProjectOptions): Promise { await initWasm(); vfsClear(); - return ensureClient().createNewProject(options); + + // Append ID token to WebSocket URL if available (same as connect()) + const token = getIdToken(); + const syncServer = token + ? `${options.syncServer}${options.syncServer.includes('?') ? '&' : '?'}id_token=${encodeURIComponent(token)}` + : options.syncServer; + + return ensureClient().createNewProject({ ...options, syncServer }); } /** From c4aec00a4581b165f5719947a8b238e7f1c8285d Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Tue, 24 Feb 2026 22:14:44 +0000 Subject: [PATCH 04/29] Refactor review --- crates/quarto-hub/src/auth.rs | 12 ++-- crates/quarto-hub/src/main.rs | 3 +- crates/quarto/src/commands/hub.rs | 3 +- hub-client/src/App.tsx | 78 ++++++++---------------- hub-client/src/hooks/useAuth.ts | 5 +- hub-client/src/services/automergeSync.ts | 27 ++++---- 6 files changed, 51 insertions(+), 77 deletions(-) diff --git a/crates/quarto-hub/src/auth.rs b/crates/quarto-hub/src/auth.rs index 1feaf9ae..9533817c 100644 --- a/crates/quarto-hub/src/auth.rs +++ b/crates/quarto-hub/src/auth.rs @@ -132,18 +132,21 @@ pub async fn build_auth_state( /// Validate that TLS is accounted for when auth is enabled. /// Called once at startup before the server accepts requests. +/// +/// Returns an error if auth is enabled without TLS protection. +/// Logs a warning if `--allow-insecure-auth` is used (local dev). pub fn validate_tls_config( google_client_id: Option<&str>, behind_tls_proxy: bool, allow_insecure_auth: bool, -) { +) -> std::result::Result<(), String> { if google_client_id.is_some() && !behind_tls_proxy && !allow_insecure_auth { - eprintln!( - "error: --google-client-id requires TLS to protect tokens in transit.\n\ + return Err( + "--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)." + .to_string(), ); - std::process::exit(1); } if allow_insecure_auth && google_client_id.is_some() { tracing::warn!( @@ -151,6 +154,7 @@ pub fn validate_tls_config( Tokens will transit in plaintext. Do not use in production." ); } + Ok(()) } #[cfg(test)] diff --git a/crates/quarto-hub/src/main.rs b/crates/quarto-hub/src/main.rs index f007cd9b..d6dd8079 100644 --- a/crates/quarto-hub/src/main.rs +++ b/crates/quarto-hub/src/main.rs @@ -116,7 +116,8 @@ async fn main() -> anyhow::Result<()> { args.google_client_id.as_deref(), args.behind_tls_proxy, args.allow_insecure_auth, - ); + ) + .map_err(|e| anyhow::anyhow!(e))?; // Build auth config if Google client ID is provided let auth_config = args.google_client_id.map(|client_id| { diff --git a/crates/quarto/src/commands/hub.rs b/crates/quarto/src/commands/hub.rs index f495e1c9..9581d794 100644 --- a/crates/quarto/src/commands/hub.rs +++ b/crates/quarto/src/commands/hub.rs @@ -76,7 +76,8 @@ async fn run_hub(args: HubArgs) -> Result<()> { args.google_client_id.as_deref(), args.behind_tls_proxy, args.allow_insecure_auth, - ); + ) + .map_err(|e| anyhow::anyhow!(e))?; // Build auth config if Google client ID is provided let auth_config = args.google_client_id.map(|client_id| { diff --git a/hub-client/src/App.tsx b/hub-client/src/App.tsx index f77dba5c..fcb25123 100644 --- a/hub-client/src/App.tsx +++ b/hub-client/src/App.tsx @@ -21,6 +21,23 @@ import { useAuth } from './hooks/useAuth'; import type { Route, ShareRoute } from './utils/routing'; import './App.css'; +/** + * Connect to a sync server and load all file contents into a Map. + * Shared by every code path that opens a project. + */ +async function connectAndLoadContents( + syncServer: string, + indexDocId: string, +): Promise<{ files: FileEntry[]; contents: Map }> { + const files = await connect(syncServer, indexDocId); + const contents = new Map(); + for (const file of files) { + const content = getFileContent(file.path); + if (content !== null) contents.set(file.path, content); + } + return { files, contents }; +} + /** Whether auth is configured (build-time env var). */ const AUTH_ENABLED = !!import.meta.env.VITE_GOOGLE_CLIENT_ID; @@ -37,7 +54,7 @@ export interface PendingShareData { } function App() { - const { auth, isLoading: authLoading, error: authError, handleCredentialResponse } = useAuth(); + const { auth, error: authError, handleCredentialResponse } = useAuth(); const [project, setProject] = useState(null); const [files, setFiles] = useState([]); @@ -96,25 +113,15 @@ function App() { // Different project - need to load it const targetProject = await projectStorage.getProject(route.projectId); if (targetProject) { - // Connect to the project setIsConnecting(true); setConnectionError(null); try { - const loadedFiles = await connect(targetProject.syncServer, targetProject.indexDocId); + const { files: loadedFiles, contents } = await connectAndLoadContents(targetProject.syncServer, targetProject.indexDocId); setProject(targetProject); setFiles(loadedFiles); - - const contents = new Map(); - for (const file of loadedFiles) { - const content = getFileContent(file.path); - if (content !== null) { - contents.set(file.path, content); - } - } setFileContents(contents); } catch (err) { setConnectionError(err instanceof Error ? err.message : String(err)); - // Navigate back to project selector on error navigateToProjectSelector({ replace: true }); } finally { setIsConnecting(false); @@ -154,24 +161,14 @@ function App() { const existingProject = await projectStorage.getProjectByIndexDocId(normalizedIndexDocId); if (existingProject) { - // Project exists locally - connect to it setIsConnecting(true); setConnectionError(null); try { - const loadedFiles = await connect(existingProject.syncServer, existingProject.indexDocId); + const { files: loadedFiles, contents } = await connectAndLoadContents(existingProject.syncServer, existingProject.indexDocId); setProject(existingProject); setFiles(loadedFiles); - - const contents = new Map(); - for (const file of loadedFiles) { - const content = getFileContent(file.path); - if (content !== null) { - contents.set(file.path, content); - } - } setFileContents(contents); - // Navigate to the project (and optionally file) using local ID if (shareRoute.filePath) { navigateToFile(existingProject.id, shareRoute.filePath, { replace: true }); } else { @@ -200,21 +197,12 @@ function App() { setIsConnecting(true); setConnectionError(null); try { - const loadedFiles = await connect(targetProject.syncServer, targetProject.indexDocId); + const { files: loadedFiles, contents } = await connectAndLoadContents(targetProject.syncServer, targetProject.indexDocId); setProject(targetProject); setFiles(loadedFiles); - - const contents = new Map(); - for (const file of loadedFiles) { - const content = getFileContent(file.path); - if (content !== null) { - contents.set(file.path, content); - } - } setFileContents(contents); } catch (err) { setConnectionError(err instanceof Error ? err.message : String(err)); - // Navigate to project selector on error navigateToProjectSelector({ replace: true }); } finally { setIsConnecting(false); @@ -289,21 +277,11 @@ function App() { setConnectionError(null); try { - const loadedFiles = await connect(selectedProject.syncServer, selectedProject.indexDocId); + const { files: loadedFiles, contents } = await connectAndLoadContents(selectedProject.syncServer, selectedProject.indexDocId); setProject(selectedProject); setFiles(loadedFiles); - - // Initialize file contents from automerge - const contents = new Map(); - for (const file of loadedFiles) { - const content = getFileContent(file.path); - if (content !== null) { - contents.set(file.path, content); - } - } setFileContents(contents); - // Update URL to reflect the selected project (and optionally a specific file) if (filePathOverride) { navigateToFile(selectedProject.id, filePathOverride, { replace: true }); } else { @@ -399,15 +377,9 @@ function App() {
Quarto

Quarto Hub

- {authLoading ? ( -

Loading...

- ) : ( - <> -

Sign in with Google to continue

- - {authError &&

{authError}

} - - )} +

Sign in with Google to continue

+ + {authError &&

{authError}

}
); diff --git a/hub-client/src/hooks/useAuth.ts b/hub-client/src/hooks/useAuth.ts index ff3f4851..d2cefb2a 100644 --- a/hub-client/src/hooks/useAuth.ts +++ b/hub-client/src/hooks/useAuth.ts @@ -15,14 +15,11 @@ import { export function useAuth() { const [auth, setAuth] = useState(getStoredAuth); - const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const expiryTimer = useRef>(null); // Start expiry monitor on mount useEffect(() => { - setIsLoading(false); - expiryTimer.current = setInterval(() => { // getStoredAuth() returns null for expired tokens (and clears storage). // Sync React state if the stored auth has been cleared. @@ -48,5 +45,5 @@ export function useAuth() { setAuth(null); }, []); - return { auth, isLoading, error, handleCredentialResponse, logout }; + return { auth, error, handleCredentialResponse, logout }; } diff --git a/hub-client/src/services/automergeSync.ts b/hub-client/src/services/automergeSync.ts index 989a691f..11420039 100644 --- a/hub-client/src/services/automergeSync.ts +++ b/hub-client/src/services/automergeSync.ts @@ -21,6 +21,14 @@ import { import { vfsAddFile, vfsAddBinaryFile, vfsRemoveFile, vfsClear, initWasm } from './wasmRenderer'; import { getIdToken } from './authService'; +/** Append the stored ID token to a URL as a query parameter (if available). */ +function appendAuthToken(url: string): string { + const token = getIdToken(); + if (!token) return url; + const sep = url.includes('?') ? '&' : '?'; + return `${url}${sep}id_token=${encodeURIComponent(token)}`; +} + // Re-export types for use in other components export type { Patch, FileEntry, CreateBinaryFileResult, CreateProjectOptions, CreateProjectResult }; @@ -109,13 +117,7 @@ export async function connect(syncServerUrl: string, indexDocId: string): Promis await initWasm(); vfsClear(); - // Append ID token to WebSocket URL if available - const token = getIdToken(); - const url = token - ? `${syncServerUrl}${syncServerUrl.includes('?') ? '&' : '?'}id_token=${encodeURIComponent(token)}` - : syncServerUrl; - - return ensureClient().connect(url, indexDocId); + return ensureClient().connect(appendAuthToken(syncServerUrl), indexDocId); } /** @@ -207,13 +209,10 @@ export async function createNewProject(options: CreateProjectOptions): Promise Date: Tue, 24 Feb 2026 22:38:31 +0000 Subject: [PATCH 05/29] Remove RwLock on HubConfig --- crates/quarto-hub/src/context.rs | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/crates/quarto-hub/src/context.rs b/crates/quarto-hub/src/context.rs index 76343829..f36b6418 100644 --- a/crates/quarto-hub/src/context.rs +++ b/crates/quarto-hub/src/context.rs @@ -10,7 +10,7 @@ use axum::http::StatusCode; use axum_jwt_auth::JwtDecoder; use samod::Repo; use samod::storage::TokioFilesystemStorage; -use tokio::sync::{Mutex, RwLock}; +use tokio::sync::Mutex; use tracing::{debug, info, warn}; use crate::auth::{self, AuthConfig, AuthState, GoogleClaims}; @@ -24,7 +24,7 @@ use crate::sync::{SyncAllResult, SyncResult, sync_all_documents, sync_file_by_pa use crate::sync_state::SyncState; /// Configuration for the hub. -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct HubConfig { /// Port to listen on pub port: u16, @@ -76,9 +76,6 @@ pub struct HubContext { /// Storage manager (holds lockfile, manages directories) storage: StorageManager, - /// Hub configuration - config: RwLock, - /// Discovered project files project_files: ProjectFiles, @@ -167,13 +164,10 @@ impl HubContext { "Initial filesystem sync complete" ); - // Extract auth_config from HubConfig — it's immutable after startup - // and stored separately to avoid holding the RwLock during auth checks. let auth_config = config.auth_config.take(); Ok(Self { storage, - config: RwLock::new(config), project_files, repo, index, @@ -188,11 +182,6 @@ impl HubContext { &self.storage } - /// Get the current configuration. - pub async fn config(&self) -> HubConfig { - self.config.read().await.clone() - } - /// Get discovered project files. pub fn project_files(&self) -> &ProjectFiles { &self.project_files From c70b11aea44dccfbdc91560dd3784ed2a657523a Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:00:46 +0000 Subject: [PATCH 06/29] Use brand styling --- hub-client/src/App.tsx | 15 ++--- .../src/components/auth/LoginScreen.css | 56 ------------------- 2 files changed, 8 insertions(+), 63 deletions(-) delete mode 100644 hub-client/src/components/auth/LoginScreen.css diff --git a/hub-client/src/App.tsx b/hub-client/src/App.tsx index fcb25123..9f49b2fb 100644 --- a/hub-client/src/App.tsx +++ b/hub-client/src/App.tsx @@ -5,7 +5,6 @@ import Editor from './components/Editor'; import Toast from './components/Toast'; import { ViewModeProvider } from './components/ViewModeContext'; import { LoginButton } from './components/auth/LoginButton'; -import './components/auth/LoginScreen.css'; import { connect, disconnect, @@ -373,13 +372,15 @@ function App() { // Auth gate: when auth is enabled, require login before showing the app. if (AUTH_ENABLED && !auth) { return ( -
-
- Quarto -

Quarto Hub

-

Sign in with Google to continue

+
+
+ Quarto +

Quarto Hub

+

+ Sign in with Google to continue +

- {authError &&

{authError}

} + {authError &&
{authError}
}
); diff --git a/hub-client/src/components/auth/LoginScreen.css b/hub-client/src/components/auth/LoginScreen.css deleted file mode 100644 index 6d7f646b..00000000 --- a/hub-client/src/components/auth/LoginScreen.css +++ /dev/null @@ -1,56 +0,0 @@ -.login-screen { - position: fixed; - inset: 0; - background: #242424; - display: flex; - align-items: center; - justify-content: center; -} - -.login-card { - background: #1a1a2e; - border-radius: 12px; - padding: 48px 32px; - max-width: 400px; - width: 90%; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; -} - -.login-logo { - width: 48px; - height: 48px; - margin-bottom: 8px; -} - -.login-card h2 { - margin: 0; - font-size: 24px; - color: #fff; - font-weight: 600; -} - -.login-card .login-subtitle { - margin: 0 0 16px; - color: #888; - font-size: 14px; -} - -.login-card .login-error { - background: #3d2020; - color: #f87171; - padding: 12px; - border-radius: 6px; - font-size: 13px; - width: 100%; - box-sizing: border-box; - text-align: center; -} - -.login-card .login-loading { - color: #888; - font-size: 14px; -} From 5016b04360481e36291b6d3a0c96996c1402c06d Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:11:49 +0000 Subject: [PATCH 07/29] Read QUARTO_HUB_* env vars --- Cargo.toml | 2 +- crates/quarto-hub/src/main.rs | 6 +++--- crates/quarto/src/main.rs | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) 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/crates/quarto-hub/src/main.rs b/crates/quarto-hub/src/main.rs index d6dd8079..de4bb698 100644 --- a/crates/quarto-hub/src/main.rs +++ b/crates/quarto-hub/src/main.rs @@ -48,7 +48,7 @@ struct Args { /// Google OAuth2 client ID. Presence enables auth. /// Requires --behind-tls-proxy (or --allow-insecure-auth for local dev). - #[arg(long)] + #[arg(long, env = "QUARTO_HUB_GOOGLE_CLIENT_ID")] google_client_id: Option, /// Acknowledge that a TLS-terminating reverse proxy (nginx, Caddy, @@ -62,11 +62,11 @@ struct Args { allow_insecure_auth: bool, /// Allowed email addresses (comma-separated). - #[arg(long, value_delimiter = ',')] + #[arg(long, env = "QUARTO_HUB_ALLOWED_EMAILS", value_delimiter = ',')] allowed_emails: Option>, /// Allowed email domains (comma-separated). - #[arg(long, value_delimiter = ',')] + #[arg(long, env = "QUARTO_HUB_ALLOWED_DOMAINS", value_delimiter = ',')] allowed_domains: Option>, } diff --git a/crates/quarto/src/main.rs b/crates/quarto/src/main.rs index 3d3fbd06..d2ff6196 100644 --- a/crates/quarto/src/main.rs +++ b/crates/quarto/src/main.rs @@ -362,7 +362,7 @@ enum Commands { /// Google OAuth2 client ID. Presence enables auth. /// Requires --behind-tls-proxy (or --allow-insecure-auth for local dev). - #[arg(long)] + #[arg(long, env = "QUARTO_HUB_GOOGLE_CLIENT_ID")] google_client_id: Option, /// Acknowledge that a TLS-terminating reverse proxy (nginx, Caddy, @@ -376,11 +376,11 @@ enum Commands { allow_insecure_auth: bool, /// Allowed email addresses (comma-separated). - #[arg(long, value_delimiter = ',')] + #[arg(long, env = "QUARTO_HUB_ALLOWED_EMAILS", value_delimiter = ',')] allowed_emails: Option>, /// Allowed email domains (comma-separated). - #[arg(long, value_delimiter = ',')] + #[arg(long, env = "QUARTO_HUB_ALLOWED_DOMAINS", value_delimiter = ',')] allowed_domains: Option>, }, } From c7f7a3bfb3bc40d7b715997e5ac84d4f24716471 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:57:47 +0000 Subject: [PATCH 08/29] Optimize tracing --- crates/quarto-hub/src/auth.rs | 3 +++ crates/quarto-hub/src/context.rs | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/quarto-hub/src/auth.rs b/crates/quarto-hub/src/auth.rs index 9533817c..54f42054 100644 --- a/crates/quarto-hub/src/auth.rs +++ b/crates/quarto-hub/src/auth.rs @@ -53,6 +53,9 @@ pub fn check_allowlists(claims: &GoogleClaims, config: &AuthConfig) -> Result<() return Ok(()); } + // Case-sensitive comparison is intentional: Google normalizes emails + // to lowercase in ID token claims. If we add non-Google identity + // providers in the future, revisit this to normalize both sides. let email_ok = config .allowed_emails .as_ref() diff --git a/crates/quarto-hub/src/context.rs b/crates/quarto-hub/src/context.rs index f36b6418..db71a33f 100644 --- a/crates/quarto-hub/src/context.rs +++ b/crates/quarto-hub/src/context.rs @@ -267,7 +267,7 @@ impl HubContext { })?; auth::check_allowlists(&token_data.claims, auth_config)?; - tracing::info!(email = %token_data.claims.email, "Authenticated"); + tracing::debug!(email = %token_data.claims.email, "Authenticated"); Ok(()) } } From 900ebfbf9315c4d2f3e26dca9fa78f3fbbcdd052 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:25:29 +0000 Subject: [PATCH 09/29] Consolidate plan documents --- .../2026-02-24-oauth2-middleware-design.md | 66 +++++++++++-------- .../plans/2026-02-24-oauth2-middleware.md | 46 ------------- 2 files changed, 40 insertions(+), 72 deletions(-) delete mode 100644 claude-notes/plans/2026-02-24-oauth2-middleware.md diff --git a/claude-notes/plans/2026-02-24-oauth2-middleware-design.md b/claude-notes/plans/2026-02-24-oauth2-middleware-design.md index f1434541..59654bc5 100644 --- a/claude-notes/plans/2026-02-24-oauth2-middleware-design.md +++ b/claude-notes/plans/2026-02-24-oauth2-middleware-design.md @@ -2,8 +2,7 @@ *2026-02-24* -Google OAuth2 authentication for quarto-hub, enforced at the middleware layer. -The sync protocol (samod/automerge) is completely unaware of authentication. +Google OAuth2 authentication for quarto-hub, enforced at the middleware layer. The sync protocol (samod/automerge) is completely unaware of authentication. ## Design Principles @@ -891,30 +890,45 @@ q2 auth logout # Clears cached tokens --- -## Implementation Checklist +## Implementation Progress ### Phase 1: Server Auth Module (Rust — `crates/quarto-hub`) -- [ ] Add `axum-jwt-auth` and `jsonwebtoken` dependencies -- [ ] Create `src/auth.rs`: `AuthConfig`, `GoogleClaims`, `AuthState`, `check_allowlists()`, `build_auth_state()` -- [ ] Add `auth_config: Option` to `HubConfig`, `OnceLock` to `HubContext` -- [ ] Add `HubContext::authenticate()` helper (JWT decode + allowlist check) -- [ ] REST handlers: extract Bearer token from header, call `ctx.authenticate()` -- [ ] WebSocket handler: extract `id_token` from query param, call `ctx.authenticate()` -- [ ] Integration test: valid token → sync works, invalid/missing token → 401 - -### Phase 2: Browser Client (TypeScript — `hub-client`) - -- [ ] Install `@react-oauth/google` -- [ ] Auth service (`storeAuth` decodes JWT payload inline, localStorage storage) -- [ ] `useAuth` hook (returns `AuthState | null`, includes token expiry monitoring) -- [ ] Login UI using `GoogleLogin` component (returns ID token directly) -- [ ] Append ID token to WebSocket URL in `automergeSync.connect()` - -### Phase 3: CLI Client (Rust — `crates/quarto`) - -- [ ] Add `yup-oauth2` dependency -- [ ] CLI auth module (`get_id_token`, `clear_tokens`) -- [ ] `quarto auth login/logout/status` subcommands -- [ ] `--google-client-id`, `--allowed-emails`, `--allowed-domains` flags -- [ ] Append ID token to WebSocket URL when connecting as client +- [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 +- [x] REST handlers: extract Bearer token from header, call `ctx.authenticate()` +- [x] WebSocket handler: extract `id_token` from query param, call `ctx.authenticate()` +- [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) +- [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`) + +- [x] Add `yup-oauth2` and `dirs` dependencies +- [x] Create `crates/quarto/src/auth.rs` (get_id_token, clear_tokens, status) +- [x] Add `quarto auth login/logout/status` subcommands +- [ ] Append ID token to WebSocket URL when connecting as client (deferred: no client connect command exists yet) diff --git a/claude-notes/plans/2026-02-24-oauth2-middleware.md b/claude-notes/plans/2026-02-24-oauth2-middleware.md deleted file mode 100644 index 905684ab..00000000 --- a/claude-notes/plans/2026-02-24-oauth2-middleware.md +++ /dev/null @@ -1,46 +0,0 @@ -# OAuth2 Middleware Implementation Plan - -*2026-02-24* - -Google OAuth2 authentication for quarto-hub. Design doc: `claude-notes/plans/2026-02-24-oauth2-middleware-design.md` - -## 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 -- [x] REST handlers: extract Bearer token from header, call `ctx.authenticate()` -- [x] WebSocket handler: extract `id_token` from query param, call `ctx.authenticate()` -- [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) -- [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`) - -- [x] Add `yup-oauth2` and `dirs` dependencies -- [x] Create `crates/quarto/src/auth.rs` (get_id_token, clear_tokens, status) -- [x] Add `quarto auth login/logout/status` subcommands -- [ ] Append ID token to WebSocket URL when connecting as client (deferred: no client connect command exists yet) From 679181a1c7f860c9039fdbc71717e3784f1aef19 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:59:35 +0000 Subject: [PATCH 10/29] Use same-window login flow; add sign out to project selector --- .../2026-02-24-oauth2-middleware-design.md | 26 ++++---- crates/quarto-hub/src/server.rs | 52 ++++++++++++++- hub-client/src/App.tsx | 7 +- hub-client/src/components/ProjectSelector.css | 40 +++++++++++- hub-client/src/components/ProjectSelector.tsx | 37 +++++++++-- .../src/components/auth/LoginButton.tsx | 25 +++++--- hub-client/src/hooks/useAuth.ts | 38 +++++++---- hub-client/vite.config.ts | 64 ++++++++++++++++++- 8 files changed, 241 insertions(+), 48 deletions(-) diff --git a/claude-notes/plans/2026-02-24-oauth2-middleware-design.md b/claude-notes/plans/2026-02-24-oauth2-middleware-design.md index 59654bc5..ce77f207 100644 --- a/claude-notes/plans/2026-02-24-oauth2-middleware-design.md +++ b/claude-notes/plans/2026-02-24-oauth2-middleware-design.md @@ -568,32 +568,34 @@ provider can be conditionally omitted or the login UI hidden. #### Login Component -Google Identity Services' "Sign In With Google" button returns an ID token -directly — no separate userinfo API call needed. +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({ - onCredential, -}: { - onCredential: (credential: string) => void; -}) { +export function LoginButton() { return ( { - if (response.credential) onCredential(response.credential); - }} + ux_mode="redirect" + login_uri={window.location.origin + '/auth/callback'} + onSuccess={() => {}} onError={() => console.error('Google login failed')} /> ); } ``` -The component is now a pure UI element. The parent calls `useAuth()` and -passes `handleCredentialResponse` as the `onCredential` prop. +**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 diff --git a/crates/quarto-hub/src/server.rs b/crates/quarto-hub/src/server.rs index d767c902..3e9c7be3 100644 --- a/crates/quarto-hub/src/server.rs +++ b/crates/quarto-hub/src/server.rs @@ -7,12 +7,12 @@ use std::time::Duration; use axum::{ Json, Router, extract::{ - Path, Query, State, + Form, Path, Query, State, ws::{WebSocket, WebSocketUpgrade}, }, http::{HeaderMap, StatusCode}, - response::IntoResponse, - routing::get, + response::{IntoResponse, Redirect}, + routing::{get, post}, }; use samod::DocumentId; use serde::{Deserialize, Serialize}; @@ -313,6 +313,49 @@ async fn update_document( } } +/// Google OAuth2 redirect callback form data. +/// +/// When `GoogleLogin` uses `ux_mode="redirect"`, Google POSTs the credential +/// JWT and a CSRF token to the `login_uri` after the user authenticates. +#[derive(Deserialize)] +struct AuthCallbackForm { + credential: String, + g_csrf_token: String, +} + +/// Handle Google OAuth2 redirect callback. +/// +/// Receives the credential JWT from Google's POST, validates the CSRF token, +/// and redirects to the SPA with the credential as a URL search parameter. +/// The hub-client's `useAuth` hook picks up the credential on mount. +async fn auth_callback( + headers: HeaderMap, + Form(form): Form, +) -> impl IntoResponse { + // Validate CSRF: g_csrf_token cookie must match the form value. + // Google sets this cookie and includes the same value in the POST body. + let cookie_csrf = headers + .get("cookie") + .and_then(|v| v.to_str().ok()) + .and_then(|cookies| { + cookies + .split(';') + .map(|c| c.trim()) + .find(|c| c.starts_with("g_csrf_token=")) + .map(|c| &c["g_csrf_token=".len()..]) + }); + + if cookie_csrf != Some(form.g_csrf_token.as_str()) { + return StatusCode::FORBIDDEN.into_response(); + } + + // Redirect to the SPA root with the credential as a search parameter. + // In a reverse-proxy deployment, this relative redirect resolves to the + // proxy origin (where the SPA is served). + let redirect_url = format!("/?auth_credential={}", form.credential); + Redirect::to(&redirect_url).into_response() +} + /// 404 handler async fn not_found() -> impl IntoResponse { (StatusCode::NOT_FOUND, "Not found") @@ -368,6 +411,9 @@ async fn build_router(ctx: SharedContext) -> Router { "/api/documents/{id}", get(get_document).put(update_document), ) + // Google OAuth2 redirect callback (production). + // In development, the Vite auth-callback plugin handles this instead. + .route("/auth/callback", post(auth_callback)) // WebSocket endpoint for automerge sync // Root path "/" is the standard location used by sync.automerge.org // "/ws" is kept for backward compatibility diff --git a/hub-client/src/App.tsx b/hub-client/src/App.tsx index 9f49b2fb..b1ddec24 100644 --- a/hub-client/src/App.tsx +++ b/hub-client/src/App.tsx @@ -53,7 +53,7 @@ export interface PendingShareData { } function App() { - const { auth, error: authError, handleCredentialResponse } = useAuth(); + const { auth, error: authError, logout } = useAuth(); const [project, setProject] = useState(null); const [files, setFiles] = useState([]); @@ -379,7 +379,7 @@ function App() {

Sign in with Google to continue

- + {authError &&
{authError}
}
@@ -396,6 +396,9 @@ function App() { error={connectionError} pendingShareData={pendingShareData} onClearPendingShare={handleClearPendingShare} + onSignOut={AUTH_ENABLED ? logout : undefined} + authEmail={auth?.email} + authPicture={auth?.picture} /> ) : ( diff --git a/hub-client/src/components/ProjectSelector.css b/hub-client/src/components/ProjectSelector.css index db5e8458..0fd5bb41 100644 --- a/hub-client/src/components/ProjectSelector.css +++ b/hub-client/src/components/ProjectSelector.css @@ -123,8 +123,46 @@ line-height: 1; } -.theme-toggle { +.header-actions { margin-left: auto; + display: flex; + align-items: center; + gap: 8px; +} + +.sign-out-btn { + display: flex; + align-items: center; + gap: 6px; + background: none; + border: 1px solid var(--border-color); + border-radius: 16px; + padding: 4px 12px 4px 4px; + font-size: 12px; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.sign-out-btn:hover { + color: var(--text-primary); + border-color: var(--posit-blue); + background: var(--input-bg-alpha); +} + +.auth-avatar { + width: 22px; + height: 22px; + border-radius: 50%; +} + +/* When there's no avatar, add left padding to balance the pill shape */ +.sign-out-btn:not(:has(.auth-avatar)) { + padding-left: 12px; +} + +.theme-toggle { background: var(--input-bg-alpha); border: 1px solid var(--border-color); border-radius: 50%; diff --git a/hub-client/src/components/ProjectSelector.tsx b/hub-client/src/components/ProjectSelector.tsx index 24837bdd..6736ebf3 100644 --- a/hub-client/src/components/ProjectSelector.tsx +++ b/hub-client/src/components/ProjectSelector.tsx @@ -24,6 +24,12 @@ interface Props { pendingShareData?: PendingShareData | null; /** Called when pending share data should be cleared (e.g., user cancels) */ onClearPendingShare?: () => void; + /** Called when user signs out of Google. Only passed when auth is enabled. */ + onSignOut?: () => void; + /** Authenticated user's email (for display). */ + authEmail?: string; + /** Authenticated user's Google avatar URL. */ + authPicture?: string | null; } // Curated color palette for user selection (10 colors, single row) @@ -40,6 +46,9 @@ export default function ProjectSelector({ onProjectCreated, pendingShareData, onClearPendingShare, + onSignOut, + authEmail, + authPicture, }: Props) { const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); @@ -360,13 +369,27 @@ export default function ProjectSelector({

Quarto Hub

Multiplayer editing for your Quarto projects

- +
+ {onSignOut && ( + + )} + +
{connectionError &&
{connectionError}
} diff --git a/hub-client/src/components/auth/LoginButton.tsx b/hub-client/src/components/auth/LoginButton.tsx index 8a636ce8..276b83d4 100644 --- a/hub-client/src/components/auth/LoginButton.tsx +++ b/hub-client/src/components/auth/LoginButton.tsx @@ -1,21 +1,28 @@ /** * Google Sign-In button wrapper. * - * Uses Google Identity Services' "Sign In With Google" button which - * returns an ID token directly — no separate userinfo API call needed. + * Uses Google Identity Services' "Sign In With Google" button in redirect + * mode so the login flow stays within the same browser window (no popup). + * + * Flow: + * 1. User clicks the button → browser navigates to Google (same tab) + * 2. After authentication → Google POSTs the credential to login_uri + * 3. The server at login_uri (Vite middleware in dev, hub server in + * production) extracts the credential and redirects back to the SPA + * with ?auth_credential= + * 4. useAuth() picks up the credential from the URL on mount */ import { GoogleLogin } from '@react-oauth/google'; -export function LoginButton({ - onCredential, -}: { - onCredential: (credential: string) => void; -}) { +export function LoginButton() { return ( { - if (response.credential) onCredential(response.credential); + ux_mode="redirect" + login_uri={window.location.origin + '/auth/callback'} + onSuccess={() => { + // Not called in redirect mode — credential arrives via URL parameter + // after the server-side redirect callback. }} onError={() => console.error('Google login failed')} /> diff --git a/hub-client/src/hooks/useAuth.ts b/hub-client/src/hooks/useAuth.ts index d2cefb2a..cd0f7892 100644 --- a/hub-client/src/hooks/useAuth.ts +++ b/hub-client/src/hooks/useAuth.ts @@ -2,7 +2,13 @@ * useAuth Hook * * Manages authentication state for the hub client. Handles Google - * credential responses, token expiry monitoring, and logout. + * credential responses (from OAuth redirect callback), token expiry + * monitoring, and logout. + * + * Credential ingestion: after Google redirects through the auth callback + * endpoint, the SPA loads with ?auth_credential= in the URL. This + * hook detects the parameter on mount, stores the credential, and cleans + * the URL. */ import { useCallback, useEffect, useRef, useState } from 'react'; @@ -14,7 +20,24 @@ import { } from '../services/authService'; export function useAuth() { - const [auth, setAuth] = useState(getStoredAuth); + 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); + // Clean the URL — remove the credential parameter without triggering navigation. + 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 to localStorage check + } + } + return getStoredAuth(); + }); const [error, setError] = useState(null); const expiryTimer = useRef>(null); @@ -31,19 +54,10 @@ export function useAuth() { }; }, []); - const handleCredentialResponse = useCallback((credential: string) => { - try { - setAuth(storeAuth(credential)); - setError(null); - } catch (err) { - setError(err instanceof Error ? err.message : 'Authentication failed'); - } - }, []); - const logout = useCallback(() => { clearAuth(); setAuth(null); }, []); - return { auth, error, handleCredentialResponse, logout }; + return { auth, error, logout }; } diff --git a/hub-client/vite.config.ts b/hub-client/vite.config.ts index 14c10812..23556490 100644 --- a/hub-client/vite.config.ts +++ b/hub-client/vite.config.ts @@ -1,8 +1,9 @@ -import { defineConfig } from 'vite' +import { defineConfig, type Plugin } from 'vite' import react from '@vitejs/plugin-react' import wasm from 'vite-plugin-wasm' import path from 'path' import { execSync } from 'child_process' +import type { IncomingMessage, ServerResponse } from 'http' function getGitInfo() { try { @@ -16,10 +17,69 @@ function getGitInfo() { const gitInfo = getGitInfo() +/** + * Vite dev server middleware for handling the Google OAuth2 redirect callback. + * + * When GoogleLogin uses ux_mode="redirect", Google POSTs the credential JWT + * to login_uri after authentication. This plugin intercepts that POST at + * /auth/callback, extracts the credential, validates the CSRF token, and + * redirects back to the SPA with the credential as a URL search parameter. + * + * In production, the equivalent handler lives on the hub server + * (POST /auth/callback in server.rs). + */ +function authCallbackPlugin(): Plugin { + return { + name: 'auth-callback', + configureServer(server) { + server.middlewares.use('/auth/callback', (req: IncomingMessage, res: ServerResponse, next: () => void) => { + if (req.method !== 'POST') { + next(); + return; + } + + let body = ''; + req.on('data', (chunk: Buffer) => { body += chunk.toString(); }); + req.on('end', () => { + const params = new URLSearchParams(body); + const credential = params.get('credential'); + + if (!credential) { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end('Missing credential'); + return; + } + + // Validate CSRF: g_csrf_token cookie must match the form value. + // Google sets this cookie and includes the same value in the POST body. + const formCsrf = params.get('g_csrf_token'); + const cookieHeader = req.headers.cookie ?? ''; + const cookieCsrf = cookieHeader + .split(';') + .map(c => c.trim()) + .find(c => c.startsWith('g_csrf_token=')) + ?.slice('g_csrf_token='.length); + + if (!formCsrf || formCsrf !== cookieCsrf) { + res.writeHead(403, { 'Content-Type': 'text/plain' }); + res.end('CSRF validation failed'); + return; + } + + // Redirect to the SPA root with the credential as a search parameter. + // The useAuth hook picks it up on mount. + res.writeHead(302, { Location: `/?auth_credential=${credential}` }); + res.end(); + }); + }); + }, + }; +} + // https://vite.dev/config/ export default defineConfig({ base: './', - plugins: [react(), wasm()], + plugins: [authCallbackPlugin(), react(), wasm()], define: { __GIT_COMMIT_HASH__: JSON.stringify(gitInfo.commitHash), __GIT_COMMIT_DATE__: JSON.stringify(gitInfo.commitDate), From 826a5a664648301741530b473805da26a461f16c Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Wed, 25 Feb 2026 12:14:58 +0000 Subject: [PATCH 11/29] Remove unused error state --- hub-client/src/App.tsx | 3 +-- hub-client/src/hooks/useAuth.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/hub-client/src/App.tsx b/hub-client/src/App.tsx index b1ddec24..f87a8b89 100644 --- a/hub-client/src/App.tsx +++ b/hub-client/src/App.tsx @@ -53,7 +53,7 @@ export interface PendingShareData { } function App() { - const { auth, error: authError, logout } = useAuth(); + const { auth, logout } = useAuth(); const [project, setProject] = useState(null); const [files, setFiles] = useState([]); @@ -380,7 +380,6 @@ function App() { Sign in with Google to continue

- {authError &&
{authError}
} ); diff --git a/hub-client/src/hooks/useAuth.ts b/hub-client/src/hooks/useAuth.ts index cd0f7892..ffd8e9a3 100644 --- a/hub-client/src/hooks/useAuth.ts +++ b/hub-client/src/hooks/useAuth.ts @@ -38,7 +38,6 @@ export function useAuth() { } return getStoredAuth(); }); - const [error, setError] = useState(null); const expiryTimer = useRef>(null); // Start expiry monitor on mount @@ -59,5 +58,5 @@ export function useAuth() { setAuth(null); }, []); - return { auth, error, logout }; + return { auth, logout }; } From 8ac863851c28edf98506f658fc6cbba320b9255e Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:50:56 +0000 Subject: [PATCH 12/29] Security best practices review --- .../2026-02-24-oauth2-middleware-design.md | 46 +++++++++++++------ crates/quarto-hub/src/auth.rs | 5 ++ crates/quarto-hub/src/server.rs | 17 +++++-- crates/quarto/src/auth.rs | 39 ++++++++++++++-- crates/quarto/src/commands/auth_cmd.rs | 10 +--- hub-client/src/hooks/useAuth.ts | 29 ++++++++---- hub-client/src/services/authService.ts | 6 ++- hub-client/vite.config.ts | 2 + 8 files changed, 114 insertions(+), 40 deletions(-) diff --git a/claude-notes/plans/2026-02-24-oauth2-middleware-design.md b/claude-notes/plans/2026-02-24-oauth2-middleware-design.md index ce77f207..d5eddf87 100644 --- a/claude-notes/plans/2026-02-24-oauth2-middleware-design.md +++ b/claude-notes/plans/2026-02-24-oauth2-middleware-design.md @@ -867,28 +867,48 @@ q2 auth logout # Clears cached tokens --- -## Security Considerations +## 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. **Local validation.** ID tokens are validated by checking the JWT signature against Google's cached public keys. No outbound network call per connection. -3. **Token in URL (WebSocket).** Encrypted by TLS in transit. `RedactedMakeSpan` ensures the `TraceLayer` logs only `uri.path()`, never the query string containing the token. -4. **Short-lived tokens.** Google ID tokens expire in ~1 hour. Limits exposure window. -5. **Audience check.** The `jsonwebtoken::Validation` config verifies the `aud` claim matches the configured client ID, preventing tokens issued for other applications from being accepted. -6. **Domain/email allowlists.** Defense in depth beyond Google authentication. -7. **Minimal client errors.** Invalid/missing tokens return 401; allowlist rejections return 403. Neither includes user-identifying detail. Specific reasons logged server-side only. -8. **localStorage tokens (browser).** Accessible to XSS. Acceptable for v1; mitigate with Content-Security-Policy headers. +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. **Log redaction.** `RedactedMakeSpan` ensures the `TraceLayer` logs only `uri.path()`, never the query string (which may contain `id_token` for WebSocket upgrades). +8. **Token in URL (WebSocket).** Encrypted by TLS in transit. Redacted from server logs (see above). +9. **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. +10. **Minimal client errors.** Invalid/missing tokens return 401; allowlist rejections return 403. Neither includes user-identifying detail. Specific reasons logged server-side only. +11. **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. +12. **Case-insensitive Bearer matching.** The `bearer_token()` extractor matches the `Authorization` header scheme case-insensitively per RFC 7235 §2.1. +13. **JWT structure validation (browser).** `decodeJwtPayload()` verifies the token has exactly 3 dot-separated segments before attempting base64 decode, preventing cryptic errors from malformed input. +14. **Restrictive file permissions (CLI).** The token cache directory is created with 0700 and the token cache file is set to 0600 (Unix only), preventing other users on shared machines from reading cached credentials. +15. **No token leakage in CLI output.** `q2 auth login` prints only "Authenticated successfully." without any token content. + +### 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. --- ## Known Limitations -1. **No silent token refresh.** Google Identity Services' Sign In button does not provide refresh tokens. When the ID token expires (~1hr), the user must re-authenticate. The auth hook detects this proactively. - -2. **Token in WebSocket URL.** Could appear in server access logs. Mitigated by TLS and log configuration. A future iteration could add a short-lived ticket exchange endpoint (`POST /auth/ticket` → one-time ticket for WebSocket URL). +1. **No silent token refresh.** Google Identity Services' Sign In button does not provide refresh tokens. When the ID token expires (~1hr), the user must re-authenticate. The auth hook detects this via an exact-expiry timeout. -3. **No user database.** Cannot track users, audit access history, or implement per-user settings. Add if/when needed. +2. **No user database.** Cannot track users, audit access history, or implement per-user settings. Add if/when needed. -4. **CLI ID token availability.** `yup-oauth2`'s `Authenticator::id_token()` method returns the ID token when the `openid` scope is requested. The ID token is stored alongside the access token in the token cache, so refreshed tokens also include it. However, the `id_token()` method is separate from `token()` (which only returns the access token). +3. **CLI ID token availability.** `yup-oauth2`'s `Authenticator::id_token()` method returns the ID token when the `openid` scope is requested. The ID token is stored alongside the access token in the token cache, so refreshed tokens also include it. However, the `id_token()` method is separate from `token()` (which only returns the access token). --- diff --git a/crates/quarto-hub/src/auth.rs b/crates/quarto-hub/src/auth.rs index 54f42054..b274583f 100644 --- a/crates/quarto-hub/src/auth.rs +++ b/crates/quarto-hub/src/auth.rs @@ -138,6 +138,11 @@ pub async fn build_auth_state( /// /// Returns an error if auth is enabled without TLS protection. /// Logs a warning if `--allow-insecure-auth` is used (local dev). +/// +/// **Deployment note**: The reverse proxy that terminates TLS should also +/// 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; ...` pub fn validate_tls_config( google_client_id: Option<&str>, behind_tls_proxy: bool, diff --git a/crates/quarto-hub/src/server.rs b/crates/quarto-hub/src/server.rs index 3e9c7be3..a75f8b63 100644 --- a/crates/quarto-hub/src/server.rs +++ b/crates/quarto-hub/src/server.rs @@ -97,12 +97,16 @@ fn unauthorized() -> (StatusCode, Json) { /// 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. +/// +/// The auth-scheme match is case-insensitive per RFC 7235 §2.1. fn bearer_token(headers: &HeaderMap) -> Option<&str> { - headers - .get("authorization")? - .to_str() - .ok()? - .strip_prefix("Bearer ") + let value = headers.get("authorization")?.to_str().ok()?; + // "Bearer " is 7 bytes; check prefix case-insensitively, return the rest as-is. + if value.len() > 7 && value[..7].eq_ignore_ascii_case("bearer ") { + Some(&value[7..]) + } else { + None + } } /// Log request method and path only — never the query string, which @@ -352,6 +356,9 @@ async fn auth_callback( // Redirect to the SPA root with the credential as a search parameter. // In a reverse-proxy deployment, this relative redirect resolves to the // proxy origin (where the SPA is served). + // + // No URL-encoding needed: JWTs are base64url + dots, all unreserved + // URI characters per RFC 3986. let redirect_url = format!("/?auth_credential={}", form.credential); Redirect::to(&redirect_url).into_response() } diff --git a/crates/quarto/src/auth.rs b/crates/quarto/src/auth.rs index 5d36b68c..613fbbd3 100644 --- a/crates/quarto/src/auth.rs +++ b/crates/quarto/src/auth.rs @@ -5,7 +5,7 @@ //! includes an `id_token` field which is what the hub server validates. use anyhow::{Context, Result}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use yup_oauth2::{InstalledFlowAuthenticator, InstalledFlowReturnMethod}; /// Request openid scopes so the token response includes an id_token. @@ -29,6 +29,36 @@ fn client_secret_path() -> PathBuf { .join("client_secret.json") } +/// Restrict a file's permissions to owner-only read/write (0600). +/// No-op on non-Unix platforms. +fn restrict_permissions(path: &Path) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if path.exists() { + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); + } + } + #[cfg(not(unix))] + { + let _ = path; + } +} + +/// Create the cache directory with restrictive permissions (0700 on Unix). +fn create_cache_dir(path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)); + } + } + Ok(()) +} + /// Get a Google ID token for hub authentication. /// Opens browser on first use, uses cached/refreshed tokens subsequently. pub async fn get_id_token() -> Result { @@ -46,9 +76,7 @@ pub async fn get_id_token() -> Result { .context("Failed to read client secret")?; let cache = token_cache_path(); - if let Some(parent) = cache.parent() { - std::fs::create_dir_all(parent)?; - } + create_cache_dir(&cache)?; let auth = InstalledFlowAuthenticator::builder( secret, @@ -59,6 +87,9 @@ pub async fn get_id_token() -> Result { .await .context("Failed to create authenticator")?; + // Restrict permissions on the token cache file (written by yup-oauth2). + restrict_permissions(&cache); + // id_token() returns Result, Error>. // Requires "openid" in SCOPES for Google to include the ID token. auth.id_token(SCOPES) diff --git a/crates/quarto/src/commands/auth_cmd.rs b/crates/quarto/src/commands/auth_cmd.rs index 4463c1d2..60b284ae 100644 --- a/crates/quarto/src/commands/auth_cmd.rs +++ b/crates/quarto/src/commands/auth_cmd.rs @@ -11,14 +11,8 @@ use crate::auth; pub fn login() -> Result<()> { let runtime = tokio::runtime::Runtime::new()?; runtime.block_on(async { - let token = auth::get_id_token().await?; - // Truncate for display - let display = if token.len() > 20 { - format!("{}...{}", &token[..10], &token[token.len() - 10..]) - } else { - token.clone() - }; - println!("Authenticated successfully. ID token: {display}"); + let _token = auth::get_id_token().await?; + println!("Authenticated successfully."); Ok(()) }) } diff --git a/hub-client/src/hooks/useAuth.ts b/hub-client/src/hooks/useAuth.ts index ffd8e9a3..521e6af5 100644 --- a/hub-client/src/hooks/useAuth.ts +++ b/hub-client/src/hooks/useAuth.ts @@ -38,20 +38,31 @@ export function useAuth() { } return getStoredAuth(); }); - const expiryTimer = useRef>(null); + const expiryTimer = useRef>(null); - // Start expiry monitor on mount + // Schedule exact expiry based on the token's exp claim. useEffect(() => { - expiryTimer.current = setInterval(() => { - // getStoredAuth() returns null for expired tokens (and clears storage). - // Sync React state if the stored auth has been cleared. - if (!getStoredAuth()) setAuth(null); - }, 60_000); + if (expiryTimer.current) clearTimeout(expiryTimer.current); + + if (!auth) return; + + const msUntilExpiry = auth.expiresAt - Date.now(); + if (msUntilExpiry <= 0) { + // Already expired + clearAuth(); + setAuth(null); + return; + } + + expiryTimer.current = setTimeout(() => { + clearAuth(); + setAuth(null); + }, msUntilExpiry); return () => { - if (expiryTimer.current) clearInterval(expiryTimer.current); + if (expiryTimer.current) clearTimeout(expiryTimer.current); }; - }, []); + }, [auth]); const logout = useCallback(() => { clearAuth(); diff --git a/hub-client/src/services/authService.ts b/hub-client/src/services/authService.ts index f4332d4f..35c363d3 100644 --- a/hub-client/src/services/authService.ts +++ b/hub-client/src/services/authService.ts @@ -20,7 +20,11 @@ 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, '/'); + const parts = jwt.split('.'); + if (parts.length !== 3) { + throw new Error('Invalid JWT: expected 3 segments'); + } + const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); return JSON.parse(atob(base64)); } diff --git a/hub-client/vite.config.ts b/hub-client/vite.config.ts index 23556490..027b4c36 100644 --- a/hub-client/vite.config.ts +++ b/hub-client/vite.config.ts @@ -68,6 +68,8 @@ function authCallbackPlugin(): Plugin { // Redirect to the SPA root with the credential as a search parameter. // The useAuth hook picks it up on mount. + // No URL-encoding needed: JWTs are base64url + dots, all unreserved + // URI characters per RFC 3986. res.writeHead(302, { Location: `/?auth_credential=${credential}` }); res.end(); }); From 82c5bdd9beb042c9c52d29c72026a161720dddf0 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:40:19 +0000 Subject: [PATCH 13/29] Further security hardening --- .../2026-02-24-oauth2-middleware-design.md | 134 +++++++++++------- crates/quarto-hub/src/server.rs | 40 ++++-- hub-client/src/hooks/useAuth.ts | 48 ++++++- 3 files changed, 162 insertions(+), 60 deletions(-) diff --git a/claude-notes/plans/2026-02-24-oauth2-middleware-design.md b/claude-notes/plans/2026-02-24-oauth2-middleware-design.md index d5eddf87..b59f9f9a 100644 --- a/claude-notes/plans/2026-02-24-oauth2-middleware-design.md +++ b/claude-notes/plans/2026-02-24-oauth2-middleware-design.md @@ -314,11 +314,13 @@ fn validate_tls_config(args: &HubArgs) { /// 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) -> Router { +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 - .expect("Failed to initialize Google JWKS decoder"); + .map_err(|e| Error::Server(format!( + "Failed to initialize Google JWKS decoder: {e}" + )))?; ctx.set_auth_state(auth_state); } @@ -326,12 +328,13 @@ async fn build_router(ctx: SharedContext) -> Router { .route("/api/files", get(list_files)) .route("/api/documents", get(list_documents)); - Router::new() + 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) + .with_state(ctx)) } ``` @@ -486,12 +489,14 @@ export function getIdToken(): string | null { #### Auth Hook -Token expiry monitoring is built in — no separate hook needed. +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, @@ -499,42 +504,67 @@ import { clearAuth, } from '../services/authService'; -export function useAuth() { - const [auth, setAuth] = useState(getStoredAuth); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const expiryTimer = useRef>(null); +const REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes before expiry - // Start expiry monitor on mount +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(() => { - setIsLoading(false); + if (refreshTimer.current) clearTimeout(refreshTimer.current); + if (expiryTimer.current) clearTimeout(expiryTimer.current); + if (!auth) return; - expiryTimer.current = setInterval(() => { - // getStoredAuth() returns null for expired tokens (and clears storage). - // Sync React state if the stored auth has been cleared. - if (!getStoredAuth()) setAuth(null); - }, 60_000); + 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 (expiryTimer.current) clearInterval(expiryTimer.current); + if (refreshTimer.current) clearTimeout(refreshTimer.current); + if (expiryTimer.current) clearTimeout(expiryTimer.current); }; - }, []); - - const handleCredentialResponse = useCallback((credential: string) => { - try { - setAuth(storeAuth(credential)); - setError(null); - } catch (err) { - setError(err instanceof Error ? err.message : 'Authentication failed'); - } - }, []); + }, [auth]); - const logout = useCallback(() => { - clearAuth(); - setAuth(null); - }, []); + const logout = useCallback(() => { clearAuth(); setAuth(null); }, []); - return { auth, isLoading, error, handleCredentialResponse, logout }; + return { auth, logout }; } ``` @@ -879,15 +909,18 @@ q2 auth logout # Clears cached tokens 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. **Log redaction.** `RedactedMakeSpan` ensures the `TraceLayer` logs only `uri.path()`, never the query string (which may contain `id_token` for WebSocket upgrades). -8. **Token in URL (WebSocket).** Encrypted by TLS in transit. Redacted from server logs (see above). -9. **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. -10. **Minimal client errors.** Invalid/missing tokens return 401; allowlist rejections return 403. Neither includes user-identifying detail. Specific reasons logged server-side only. -11. **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. -12. **Case-insensitive Bearer matching.** The `bearer_token()` extractor matches the `Authorization` header scheme case-insensitively per RFC 7235 §2.1. -13. **JWT structure validation (browser).** `decodeJwtPayload()` verifies the token has exactly 3 dot-separated segments before attempting base64 decode, preventing cryptic errors from malformed input. -14. **Restrictive file permissions (CLI).** The token cache directory is created with 0700 and the token cache file is set to 0600 (Unix only), preventing other users on shared machines from reading cached credentials. -15. **No token leakage in CLI output.** `q2 auth login` prints only "Authenticated successfully." without any token content. +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. **Restrictive file permissions (CLI).** The token cache directory is created with 0700 and the token cache file is set to 0600 (Unix only), preventing other users on shared machines from reading cached credentials. +16. **No token leakage in CLI output.** `q2 auth login` prints only "Authenticated successfully." without any token content. +17. **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 @@ -898,17 +931,18 @@ q2 auth logout # Clears cached tokens 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. +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 silent token refresh.** Google Identity Services' Sign In button does not provide refresh tokens. When the ID token expires (~1hr), the user must re-authenticate. The auth hook detects this via an exact-expiry timeout. +1. **No user database.** Cannot track users, audit access history, or implement per-user settings. Add if/when needed. -2. **No user database.** Cannot track users, audit access history, or implement per-user settings. Add if/when needed. +2. **CLI ID token expiry.** The CLI obtains a Google ID token via the `yup-oauth2` installed-app flow (`quarto auth login`). Like the browser flow, the ID token expires in ~1 hour. Unlike the browser, there is no silent refresh — `yup-oauth2` can refresh the *access* token automatically, but the refreshed response does not always include a new ID token. If a long-running CLI session needs to re-authenticate, the user must run `quarto auth login` again. This is not an issue today because no long-lived CLI-to-hub connection exists yet. -3. **CLI ID token availability.** `yup-oauth2`'s `Authenticator::id_token()` method returns the ID token when the `openid` scope is requested. The ID token is stored alongside the access token in the token cache, so refreshed tokens also include it. However, the `id_token()` method is separate from `token()` (which only returns the access token). +3. **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. --- @@ -921,9 +955,11 @@ q2 auth logout # Clears cached tokens - [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 +- [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()` +- [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 @@ -942,7 +978,7 @@ q2 auth logout # Clears cached tokens - [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) +- [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` diff --git a/crates/quarto-hub/src/server.rs b/crates/quarto-hub/src/server.rs index a75f8b63..7fba369b 100644 --- a/crates/quarto-hub/src/server.rs +++ b/crates/quarto-hub/src/server.rs @@ -329,10 +329,15 @@ struct AuthCallbackForm { /// Handle Google OAuth2 redirect callback. /// -/// Receives the credential JWT from Google's POST, validates the CSRF token, -/// and redirects to the SPA with the credential as a URL search parameter. -/// The hub-client's `useAuth` hook picks up the credential on mount. +/// Receives the credential JWT from Google's POST, validates the CSRF token +/// and the JWT itself, then redirects to the SPA with the credential as a +/// URL search parameter. The hub-client's `useAuth` hook picks up the +/// credential on mount. +/// +/// Validating the JWT here (not just in subsequent API calls) prevents the +/// redirect from injecting arbitrary data into the SPA's URL. async fn auth_callback( + State(ctx): State, headers: HeaderMap, Form(form): Form, ) -> impl IntoResponse { @@ -353,6 +358,13 @@ async fn auth_callback( return StatusCode::FORBIDDEN.into_response(); } + // Validate the JWT before redirecting. This is defense-in-depth: + // subsequent API/WebSocket calls validate too, but checking here + // ensures we never redirect with a bogus credential in the URL. + if let Err(status) = ctx.authenticate(Some(&form.credential)).await { + return status.into_response(); + } + // Redirect to the SPA root with the credential as a search parameter. // In a reverse-proxy deployment, this relative redirect resolves to the // proxy origin (where the SPA is served). @@ -373,6 +385,14 @@ async fn not_found() -> impl IntoResponse { /// Clients connect here to sync documents in real-time. /// Auth token is passed via `?id_token=` query parameter /// (browsers can't set custom headers on WebSocket upgrade). +/// +/// **Security note**: the token is validated once at upgrade time. After +/// that, the connection lives until the client disconnects. If a user is +/// removed from the allowlist or their token expires, already-established +/// connections are **not** terminated. This is a deliberate trade-off: +/// re-validating on every message would add latency to every sync +/// operation. Clients naturally reconnect (and re-authenticate) when the +/// frontend detects token expiry. async fn ws_handler( State(ctx): State, Query(params): Query, @@ -402,15 +422,19 @@ async fn handle_websocket(socket: WebSocket, ctx: SharedContext) { /// Build the axum 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) -> Router { +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 - .expect("Failed to initialize Google JWKS decoder"); + .map_err(|e| { + crate::error::Error::Server(format!( + "Failed to initialize Google JWKS decoder: {e}" + )) + })?; ctx.set_auth_state(auth_state); } - Router::new() + Ok(Router::new() .route("/health", get(health)) .route("/api/files", get(list_files)) .route("/api/documents", get(list_documents)) @@ -428,7 +452,7 @@ async fn build_router(ctx: SharedContext) -> Router { .route("/ws", get(ws_handler)) .fallback(not_found) .layer(TraceLayer::new_for_http().make_span_with(RedactedMakeSpan)) - .with_state(ctx) + .with_state(ctx)) } /// Run the hub server. @@ -452,7 +476,7 @@ pub async fn run_server(storage: StorageManager, config: HubConfig) -> Result<() let ctx_for_watch = ctx.clone(); let ctx_for_shutdown = ctx.clone(); - let router = build_router(ctx).await; + let router = build_router(ctx).await?; let listener = TcpListener::bind(&addr).await?; info!(%addr, "Hub server listening"); diff --git a/hub-client/src/hooks/useAuth.ts b/hub-client/src/hooks/useAuth.ts index 521e6af5..ec4b3671 100644 --- a/hub-client/src/hooks/useAuth.ts +++ b/hub-client/src/hooks/useAuth.ts @@ -3,15 +3,21 @@ * * Manages authentication state for the hub client. Handles Google * credential responses (from OAuth redirect callback), token expiry - * monitoring, and logout. + * monitoring, silent token refresh, and logout. * * Credential ingestion: after Google redirects through the auth callback * endpoint, the SPA loads with ?auth_credential= in the URL. This * hook detects the parameter on mount, stores the credential, and cleans * the URL. + * + * Token refresh: ~5 minutes before the token expires, the hook enables + * Google One Tap with `auto_select` to silently obtain a fresh credential. + * If the user has an active Google session, the token is renewed without + * any UI. If silent refresh fails, auth is cleared at expiry. */ import { useCallback, useEffect, useRef, useState } from 'react'; +import { useGoogleOneTapLogin } from '@react-oauth/google'; import { type AuthState, getStoredAuth, @@ -19,6 +25,9 @@ import { clearAuth, } from '../services/authService'; +/** Buffer before expiry at which we attempt silent refresh (5 minutes). */ +const REFRESH_BUFFER_MS = 5 * 60 * 1000; + export function useAuth() { const [auth, setAuth] = useState(() => { // Check URL search params first (OAuth redirect callback), then localStorage. @@ -38,28 +47,61 @@ export function useAuth() { } return getStoredAuth(); }); + + // Enable One Tap silent refresh when approaching token expiry. + const [refreshEnabled, setRefreshEnabled] = useState(false); + const refreshTimer = useRef>(null); const expiryTimer = useRef>(null); - // Schedule exact expiry based on the token's exp claim. + // One Tap: disabled until refreshEnabled is set. When enabled with + // auto_select, it silently returns a credential if the user has an + // active Google session — no UI shown. + useGoogleOneTapLogin({ + onSuccess: (response) => { + if (response.credential) { + try { + setAuth(storeAuth(response.credential)); + } catch { + // Invalid credential — let hard expiry handle it. + } + } + setRefreshEnabled(false); + }, + onError: () => setRefreshEnabled(false), + auto_select: true, + disabled: !refreshEnabled, + }); + + // Schedule silent refresh and hard expiry based on the token's exp claim. 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) { - // Already expired clearAuth(); setAuth(null); return; } + // Schedule silent refresh attempt before expiry. + const msUntilRefresh = msUntilExpiry - REFRESH_BUFFER_MS; + if (msUntilRefresh > 0) { + refreshTimer.current = setTimeout(() => { + setRefreshEnabled(true); + }, msUntilRefresh); + } + + // Hard expiry: clear auth when the token actually expires. expiryTimer.current = setTimeout(() => { clearAuth(); setAuth(null); }, msUntilExpiry); return () => { + if (refreshTimer.current) clearTimeout(refreshTimer.current); if (expiryTimer.current) clearTimeout(expiryTimer.current); }; }, [auth]); From 7959fcdfec3a31d1963940e3418c8667dec89b90 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Wed, 25 Feb 2026 21:22:41 +0000 Subject: [PATCH 14/29] Update lockfile --- package-lock.json | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3fc3c393..1b30e140 100644 --- a/package-lock.json +++ b/package-lock.json @@ -283,7 +283,8 @@ "hub-client/node_modules/@types/trusted-types": { "version": "2.0.7", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "hub-client/node_modules/@typescript-eslint/eslint-plugin": { "version": "8.50.1", @@ -324,7 +325,6 @@ "version": "8.50.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", @@ -531,7 +531,6 @@ "version": "8.15.0", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -630,6 +629,7 @@ "hub-client/node_modules/dompurify": { "version": "3.2.7", "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -662,7 +662,6 @@ "version": "9.39.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -1054,6 +1053,7 @@ "hub-client/node_modules/marked": { "version": "14.0.0", "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -1371,7 +1371,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1839,7 +1838,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1863,7 +1861,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1885,7 +1882,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -3252,7 +3248,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3565,6 +3562,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -3668,7 +3666,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3960,7 +3957,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/eastasianwidth": { "version": "0.2.0", @@ -4541,6 +4539,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -4862,6 +4861,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -4886,7 +4886,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4896,7 +4895,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4909,7 +4907,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.18.0", @@ -5444,7 +5443,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5519,7 +5517,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5628,7 +5625,6 @@ "integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.17", "@vitest/mocker": "4.0.17", @@ -5901,7 +5897,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -5957,7 +5952,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From 10289762cc300c0a81e4d547d6b90164e232a27e Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:02:38 +0000 Subject: [PATCH 15/29] Authenticate health endpoint as well --- crates/quarto-hub/src/server.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/quarto-hub/src/server.rs b/crates/quarto-hub/src/server.rs index 7fba369b..c52de984 100644 --- a/crates/quarto-hub/src/server.rs +++ b/crates/quarto-hub/src/server.rs @@ -125,14 +125,20 @@ impl tower_http::trace::MakeSpan for RedactedMakeSpan { } /// Health check endpoint -async fn health(State(ctx): State) -> impl IntoResponse { +async fn health( + headers: HeaderMap, + State(ctx): State, +) -> std::result::Result)> { + ctx.authenticate(bearer_token(&headers)) + .await + .map_err(|_| unauthorized())?; let response = HealthResponse { status: "ok", project_root: ctx.storage().project_root().display().to_string(), qmd_file_count: ctx.project_files().qmd_files.len(), index_document_id: ctx.index().document_id(), }; - Json(response) + Ok(Json(response)) } /// List discovered files (from filesystem) From 9fb4736349bf7482eff9f0d278861f922bb23545 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:15:16 +0000 Subject: [PATCH 16/29] Validation cleanups --- crates/quarto-hub/src/context.rs | 4 ++-- crates/quarto-hub/src/server.rs | 18 ++++++++++-------- hub-client/src/services/authService.ts | 20 +++++++++++++++----- 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/crates/quarto-hub/src/context.rs b/crates/quarto-hub/src/context.rs index db71a33f..a814d735 100644 --- a/crates/quarto-hub/src/context.rs +++ b/crates/quarto-hub/src/context.rs @@ -237,10 +237,10 @@ 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) { + pub fn set_auth_state(&self, state: AuthState) -> std::result::Result<(), &'static str> { self.auth_state .set(state) - .expect("auth_state already initialized"); + .map_err(|_| "auth_state already initialized") } /// Authenticate a request. If auth is disabled, always succeeds. diff --git a/crates/quarto-hub/src/server.rs b/crates/quarto-hub/src/server.rs index c52de984..30cb57e1 100644 --- a/crates/quarto-hub/src/server.rs +++ b/crates/quarto-hub/src/server.rs @@ -101,12 +101,13 @@ fn unauthorized() -> (StatusCode, Json) { /// The auth-scheme match is case-insensitive per RFC 7235 §2.1. fn bearer_token(headers: &HeaderMap) -> Option<&str> { let value = headers.get("authorization")?.to_str().ok()?; - // "Bearer " is 7 bytes; check prefix case-insensitively, return the rest as-is. - if value.len() > 7 && value[..7].eq_ignore_ascii_case("bearer ") { - Some(&value[7..]) - } else { - None - } + // Case-insensitive prefix match per RFC 7235 §2.1. + let token = value.get(..7).and_then(|prefix| { + prefix + .eq_ignore_ascii_case("bearer ") + .then(|| &value[7..]) + })?; + (!token.is_empty()).then_some(token) } /// Log request method and path only — never the query string, which @@ -360,7 +361,7 @@ async fn auth_callback( .map(|c| &c["g_csrf_token=".len()..]) }); - if cookie_csrf != Some(form.g_csrf_token.as_str()) { + if form.g_csrf_token.is_empty() || cookie_csrf != Some(form.g_csrf_token.as_str()) { return StatusCode::FORBIDDEN.into_response(); } @@ -437,7 +438,8 @@ async fn build_router(ctx: SharedContext) -> Result { "Failed to initialize Google JWKS decoder: {e}" )) })?; - ctx.set_auth_state(auth_state); + ctx.set_auth_state(auth_state) + .map_err(|e| crate::error::Error::Server(e.to_string()))?; } Ok(Router::new() diff --git a/hub-client/src/services/authService.ts b/hub-client/src/services/authService.ts index 35c363d3..944d5073 100644 --- a/hub-client/src/services/authService.ts +++ b/hub-client/src/services/authService.ts @@ -44,16 +44,26 @@ export function getStoredAuth(): AuthState | null { } } -/** Store an ID token received from Google Sign-In. */ +/** Store an ID token received from Google Sign-In. + * Throws if the JWT payload is missing required fields or has wrong types. */ export function storeAuth(idToken: string): AuthState { const payload = decodeJwtPayload(idToken); + if (typeof payload.email !== 'string' || !payload.email) { + throw new Error('Invalid JWT: missing or invalid email claim'); + } + if (typeof payload.exp !== 'number' || !Number.isFinite(payload.exp) || payload.exp <= 0) { + throw new Error('Invalid JWT: missing or invalid exp claim'); + } + 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 + email: payload.email, + name: typeof payload.name === 'string' ? payload.name : null, + picture: typeof payload.picture === 'string' && payload.picture.startsWith('https://') + ? payload.picture + : null, + expiresAt: payload.exp * 1000, // JWT exp is seconds }; localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(state)); From f45dcd239f1366bfa91913956d619d2112f3574a Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:05:23 +0000 Subject: [PATCH 17/29] Remove quarto CLI auth command to simplify implementation --- Cargo.lock | 124 +---------- .../2026-02-24-oauth2-middleware-design.md | 200 +----------------- crates/quarto/Cargo.toml | 4 - crates/quarto/src/auth.rs | 127 ----------- crates/quarto/src/commands/auth_cmd.rs | 31 --- crates/quarto/src/commands/mod.rs | 1 - crates/quarto/src/main.rs | 22 -- 7 files changed, 10 insertions(+), 499 deletions(-) delete mode 100644 crates/quarto/src/auth.rs delete mode 100644 crates/quarto/src/commands/auth_cmd.rs diff --git a/Cargo.lock b/Cargo.lock index 0f8c79fd..ffe7987a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -764,16 +764,6 @@ dependencies = [ "libc", ] -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1079,7 +1069,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", - "serde_core", ] [[package]] @@ -1646,25 +1635,6 @@ dependencies = [ "crc32fast", ] -[[package]] -name = "h2" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.14.5" @@ -1831,7 +1801,6 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", "http", "http-body", "httparse", @@ -1854,7 +1823,6 @@ dependencies = [ "hyper", "hyper-util", "rustls", - "rustls-native-certs", "rustls-pki-types", "tokio", "tokio-rustls", @@ -2533,10 +2501,10 @@ dependencies = [ "libc", "log", "openssl", - "openssl-probe 0.1.6", + "openssl-probe", "openssl-sys", "schannel", - "security-framework 2.11.1", + "security-framework", "security-framework-sys", "tempfile", ] @@ -2662,15 +2630,6 @@ dependencies = [ "libm", ] -[[package]] -name = "num_threads" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" -dependencies = [ - "libc", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -2715,12 +2674,6 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - [[package]] name = "openssl-sys" version = "0.9.111" @@ -3184,7 +3137,6 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", - "dirs", "pampa", "pollster", "quarto-core", @@ -3202,7 +3154,6 @@ dependencies = [ "tracing", "tracing-subscriber", "walkdir", - "yup-oauth2", ] [[package]] @@ -3991,27 +3942,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-native-certs" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" -dependencies = [ - "openssl-probe 0.2.1", - "rustls-pki-types", - "schannel", - "security-framework 3.6.0", -] - -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -4121,12 +4051,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "seahash" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" - [[package]] name = "sec1" version = "0.7.3" @@ -4148,20 +4072,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.10.0", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework" -version = "3.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" -dependencies = [ - "bitflags 2.10.0", - "core-foundation 0.10.1", + "core-foundation", "core-foundation-sys", "libc", "security-framework-sys", @@ -4741,9 +4652,7 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", - "libc", "num-conv", - "num_threads", "powerfmt", "serde_core", "time-core", @@ -5804,33 +5713,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "yup-oauth2" -version = "11.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ed5f19242090128c5809f6535cc7b8d4e2c32433f6c6005800bbc20a644a7f0" -dependencies = [ - "anyhow", - "async-trait", - "base64 0.22.1", - "futures", - "http", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "log", - "percent-encoding", - "rustls", - "rustls-pemfile", - "seahash", - "serde", - "serde_json", - "time", - "tokio", - "url", -] - [[package]] name = "zerocopy" version = "0.8.39" diff --git a/claude-notes/plans/2026-02-24-oauth2-middleware-design.md b/claude-notes/plans/2026-02-24-oauth2-middleware-design.md index b59f9f9a..c015ddda 100644 --- a/claude-notes/plans/2026-02-24-oauth2-middleware-design.md +++ b/claude-notes/plans/2026-02-24-oauth2-middleware-design.md @@ -27,7 +27,7 @@ Google OAuth2 authentication for quarto-hub, enforced at the middleware layer. T │ │ │ │ │ │ REST: Authorization: Bearer → authenticate() → 401│ │ │ │ WebSocket: ?id_token= → authenticate() → 401 │ │ -│ │ /health: no auth required │ │ +│ │ /health: authenticated (same as REST) │ │ │ └──────────────────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ (authenticated) │ @@ -64,9 +64,8 @@ Google OAuth2 authentication for quarto-hub, enforced at the middleware layer. T | Endpoint | Token location | Rationale | |----------|---------------|-----------| -| REST (`/api/*`) | `Authorization: Bearer ` | Standard HTTP auth header; extracted and decoded via `HubContext::authenticate()` | +| 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 | -| Health (`/health`) | None | Always open for monitoring | 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. @@ -659,172 +658,6 @@ the standard `BrowserWebSocketClientAdapter` passes through unchanged. --- -### CLI Client (Rust) - -The CLI uses `yup-oauth2` for the installed application flow (opens browser, -receives callback). By requesting `openid` scopes, the token response includes -an `id_token` field which is what the server validates. - -#### Dependencies - -Add to `crates/quarto/Cargo.toml`: - -```toml -[dependencies] -yup-oauth2 = "11" -dirs = "6" -``` - -#### CLI Auth Module - -```rust -// crates/quarto/src/auth.rs - -use anyhow::{Context, Result}; -use std::path::PathBuf; -use yup_oauth2::{InstalledFlowAuthenticator, InstalledFlowReturnMethod}; - -/// Request openid scopes so the token response includes an id_token. -const SCOPES: &[&str] = &[ - "openid", - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/userinfo.profile", -]; - -fn token_cache_path() -> PathBuf { - dirs::cache_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("quarto") - .join("oauth2_tokens.json") -} - -fn client_secret_path() -> PathBuf { - dirs::config_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("quarto") - .join("client_secret.json") -} - -/// Get a Google ID token for hub authentication. -/// Opens browser on first use, uses cached/refreshed tokens subsequently. -pub async fn get_id_token() -> Result { - let secret_path = client_secret_path(); - if !secret_path.exists() { - anyhow::bail!( - "OAuth2 client secret not found at: {}\n\ - Download client_secret.json from Google Cloud Console.", - secret_path.display() - ); - } - - let secret = yup_oauth2::read_application_secret(&secret_path) - .await - .context("Failed to read client secret")?; - - let cache = token_cache_path(); - if let Some(parent) = cache.parent() { - std::fs::create_dir_all(parent)?; - } - - let auth = InstalledFlowAuthenticator::builder( - secret, - InstalledFlowReturnMethod::HTTPRedirect, - ) - .persist_tokens_to_disk(&cache) - .build() - .await - .context("Failed to create authenticator")?; - - // id_token() is a method on Authenticator (not on Token). - // It returns Result, Error>. - // Requires "openid" in SCOPES for Google to include the ID token. - auth.id_token(SCOPES) - .await - .context("Failed to get ID token")? - .ok_or_else(|| anyhow::anyhow!( - "No ID token in response. Ensure 'openid' scope is granted." - )) -} - -pub fn clear_tokens() -> Result<()> { - let path = token_cache_path(); - if path.exists() { std::fs::remove_file(&path)?; } - Ok(()) -} - -pub fn has_cached_tokens() -> bool { - token_cache_path().exists() -} -``` - -#### CLI Commands and Hub Server Flags - -```rust -// crates/quarto/src/commands/auth.rs - -#[derive(Subcommand)] -pub enum AuthCommands { - /// Authenticate with Google for hub access. - Login, - /// Clear cached tokens. - Logout, - /// Show authentication status. - Status, -} -``` - -```rust -// crates/quarto/src/commands/hub.rs (additions) - -#[derive(Parser)] -pub struct HubArgs { - // ... existing fields ... - - /// Google OAuth2 client ID. Presence enables auth. - /// Requires --behind-tls-proxy (or --allow-insecure-auth for local dev). - #[arg(long)] - pub google_client_id: Option, - - /// Acknowledge that a TLS-terminating reverse proxy (nginx, Caddy, - /// cloud LB) sits in front of the hub. Required when auth is enabled. - #[arg(long)] - pub behind_tls_proxy: bool, - - /// Allow auth without TLS (local development only). Tokens will - /// transit in plaintext — never use this in production. - #[arg(long)] - pub allow_insecure_auth: bool, - - /// Allowed email addresses (comma-separated). - #[arg(long, value_delimiter = ',')] - pub allowed_emails: Option>, - - /// Allowed email domains (comma-separated). - #[arg(long, value_delimiter = ',')] - pub allowed_domains: Option>, -} -``` - -#### CLI Client Connection - -```rust -// crates/quarto/src/commands/hub.rs (client connection) - -pub async fn connect_to_hub(url: &str, require_auth: bool) -> Result<()> { - let ws_url = if require_auth { - let token = crate::auth::get_id_token().await?; - format!("{}?id_token={}", url, urlencoding::encode(&token)) - } else { - url.to_string() - }; - - // Connect to hub with ws_url — samod sees a normal WebSocket - // ... - - Ok(()) -} -``` - --- ## Configuration @@ -852,18 +685,13 @@ QUARTO_HUB_ALLOWED_EMAILS=admin@example.com - Add test users if the app is in "Testing" publish status 3. Navigate to **APIs & Services > Credentials > Create Credentials > OAuth client ID**. - Create **two** credentials: **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` - **Desktop application** (for CLI `q2 auth login`): - - Download the JSON credentials file - - Save as `~/.config/quarto/client_secret.json` - -Both the server `--google-client-id` flag and the browser `VITE_GOOGLE_CLIENT_ID` use the **web application** client ID. The server validates that the JWT `aud` claim matches this ID. The CLI uses the desktop credential to obtain tokens through the browser redirect flow. +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 @@ -888,13 +716,6 @@ 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. -**CLI client:** -```bash -q2 auth login # Opens browser, gets Google ID token -q2 auth status # Shows token cache and client secret paths -q2 auth logout # Clears cached tokens -``` - --- ## Security Review @@ -917,9 +738,7 @@ q2 auth logout # Clears cached tokens 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. **Restrictive file permissions (CLI).** The token cache directory is created with 0700 and the token cache file is set to 0600 (Unix only), preventing other users on shared machines from reading cached credentials. -16. **No token leakage in CLI output.** `q2 auth login` prints only "Authenticated successfully." without any token content. -17. **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. +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 @@ -940,9 +759,7 @@ q2 auth logout # Clears cached tokens 1. **No user database.** Cannot track users, audit access history, or implement per-user settings. Add if/when needed. -2. **CLI ID token expiry.** The CLI obtains a Google ID token via the `yup-oauth2` installed-app flow (`quarto auth login`). Like the browser flow, the ID token expires in ~1 hour. Unlike the browser, there is no silent refresh — `yup-oauth2` can refresh the *access* token automatically, but the refreshed response does not always include a new ID token. If a long-running CLI session needs to re-authenticate, the user must run `quarto auth login` again. This is not an issue today because no long-lived CLI-to-hub connection exists yet. - -3. **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. +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. --- @@ -984,9 +801,6 @@ q2 auth logout # Clears cached tokens - [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`) +### Phase 4: CLI Client Auth (Rust — `crates/quarto`) — REMOVED -- [x] Add `yup-oauth2` and `dirs` dependencies -- [x] Create `crates/quarto/src/auth.rs` (get_id_token, clear_tokens, status) -- [x] Add `quarto auth login/logout/status` subcommands -- [ ] Append ID token to WebSocket URL when connecting as client (deferred: no client connect command exists yet) +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/crates/quarto/Cargo.toml b/crates/quarto/Cargo.toml index 0df36e40..847ffe47 100644 --- a/crates/quarto/Cargo.toml +++ b/crates/quarto/Cargo.toml @@ -30,10 +30,6 @@ quarto-sass.workspace = true quarto-test.workspace = true serde_yaml.workspace = true -# OAuth2 (CLI authentication for hub) -yup-oauth2 = "11" -dirs = "6" - [build-dependencies] [dev-dependencies] diff --git a/crates/quarto/src/auth.rs b/crates/quarto/src/auth.rs deleted file mode 100644 index 613fbbd3..00000000 --- a/crates/quarto/src/auth.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! OAuth2 authentication for the Quarto CLI. -//! -//! Uses `yup-oauth2` for the installed application flow (opens browser, -//! receives callback). By requesting `openid` scopes, the token response -//! includes an `id_token` field which is what the hub server validates. - -use anyhow::{Context, Result}; -use std::path::{Path, PathBuf}; -use yup_oauth2::{InstalledFlowAuthenticator, InstalledFlowReturnMethod}; - -/// Request openid scopes so the token response includes an id_token. -const SCOPES: &[&str] = &[ - "openid", - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/userinfo.profile", -]; - -fn token_cache_path() -> PathBuf { - dirs::cache_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("quarto") - .join("oauth2_tokens.json") -} - -fn client_secret_path() -> PathBuf { - dirs::config_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("quarto") - .join("client_secret.json") -} - -/// Restrict a file's permissions to owner-only read/write (0600). -/// No-op on non-Unix platforms. -fn restrict_permissions(path: &Path) { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if path.exists() { - let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); - } - } - #[cfg(not(unix))] - { - let _ = path; - } -} - -/// Create the cache directory with restrictive permissions (0700 on Unix). -fn create_cache_dir(path: &Path) -> Result<()> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = - std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)); - } - } - Ok(()) -} - -/// Get a Google ID token for hub authentication. -/// Opens browser on first use, uses cached/refreshed tokens subsequently. -pub async fn get_id_token() -> Result { - let secret_path = client_secret_path(); - if !secret_path.exists() { - anyhow::bail!( - "OAuth2 client secret not found at: {}\n\ - Download client_secret.json from Google Cloud Console.", - secret_path.display() - ); - } - - let secret = yup_oauth2::read_application_secret(&secret_path) - .await - .context("Failed to read client secret")?; - - let cache = token_cache_path(); - create_cache_dir(&cache)?; - - let auth = InstalledFlowAuthenticator::builder( - secret, - InstalledFlowReturnMethod::HTTPRedirect, - ) - .persist_tokens_to_disk(&cache) - .build() - .await - .context("Failed to create authenticator")?; - - // Restrict permissions on the token cache file (written by yup-oauth2). - restrict_permissions(&cache); - - // id_token() returns Result, Error>. - // Requires "openid" in SCOPES for Google to include the ID token. - auth.id_token(SCOPES) - .await - .context("Failed to get ID token")? - .ok_or_else(|| { - anyhow::anyhow!("No ID token in response. Ensure 'openid' scope is granted.") - }) -} - -pub fn clear_tokens() -> Result<()> { - let path = token_cache_path(); - if path.exists() { - std::fs::remove_file(&path)?; - } - Ok(()) -} - -/// Show authentication status. -pub fn status() { - let cache = token_cache_path(); - let secret = client_secret_path(); - - if secret.exists() { - println!("Client secret: {}", secret.display()); - } else { - println!("Client secret: not found (expected at {})", secret.display()); - } - - if cache.exists() { - println!("Token cache: {} (cached)", cache.display()); - } else { - println!("Token cache: not logged in"); - } -} diff --git a/crates/quarto/src/commands/auth_cmd.rs b/crates/quarto/src/commands/auth_cmd.rs deleted file mode 100644 index 60b284ae..00000000 --- a/crates/quarto/src/commands/auth_cmd.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! Auth command - manage authentication for hub access -//! -//! Provides login, logout, and status subcommands for Google OAuth2 -//! authentication used when connecting to authenticated hub servers. - -use anyhow::Result; - -use crate::auth; - -/// Execute the auth login command. -pub fn login() -> Result<()> { - let runtime = tokio::runtime::Runtime::new()?; - runtime.block_on(async { - let _token = auth::get_id_token().await?; - println!("Authenticated successfully."); - Ok(()) - }) -} - -/// Execute the auth logout command. -pub fn logout() -> Result<()> { - auth::clear_tokens()?; - println!("Logged out. Token cache cleared."); - Ok(()) -} - -/// Execute the auth status command. -pub fn status() -> Result<()> { - auth::status(); - Ok(()) -} diff --git a/crates/quarto/src/commands/mod.rs b/crates/quarto/src/commands/mod.rs index d3f25634..6cddb5d2 100644 --- a/crates/quarto/src/commands/mod.rs +++ b/crates/quarto/src/commands/mod.rs @@ -4,7 +4,6 @@ //! quarto-core for actual implementation. pub mod add; -pub mod auth_cmd; pub mod call; pub mod check; pub mod convert; diff --git a/crates/quarto/src/main.rs b/crates/quarto/src/main.rs index d2ff6196..c6e00266 100644 --- a/crates/quarto/src/main.rs +++ b/crates/quarto/src/main.rs @@ -6,7 +6,6 @@ use anyhow::Result; use clap::{Parser, Subcommand}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -mod auth; mod commands; #[derive(Parser)] @@ -320,12 +319,6 @@ enum Commands { /// Start the Quarto Language Server Protocol server Lsp, - /// Manage authentication for hub access - Auth { - #[command(subcommand)] - action: AuthAction, - }, - /// Start collaborative hub server for real-time editing Hub { /// Project root directory (defaults to current directory) @@ -385,16 +378,6 @@ enum Commands { }, } -#[derive(Subcommand)] -enum AuthAction { - /// Authenticate with Google for hub access. - Login, - /// Clear cached authentication tokens. - Logout, - /// Show authentication status. - Status, -} - fn main() -> Result<()> { // Initialize logging tracing_subscriber::registry() @@ -443,11 +426,6 @@ fn main() -> Result<()> { Commands::Check { .. } => commands::check::execute(), Commands::Call { function, args } => commands::call::execute(function, args), Commands::Lsp => commands::lsp::execute(), - Commands::Auth { action } => match action { - AuthAction::Login => commands::auth_cmd::login(), - AuthAction::Logout => commands::auth_cmd::logout(), - AuthAction::Status => commands::auth_cmd::status(), - }, Commands::Hub { project, port, From 54da26ea5e421f602d5a6af7c4892b35f51f4e0e Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Thu, 26 Feb 2026 15:13:02 +0000 Subject: [PATCH 18/29] Switch to cookies --- .../plans/2026-02-26-httponly-cookie-auth.md | 200 +++++++ crates/quarto-hub/Cargo.toml | 2 +- crates/quarto-hub/src/auth.rs | 41 +- crates/quarto-hub/src/context.rs | 33 +- crates/quarto-hub/src/main.rs | 11 +- crates/quarto-hub/src/server.rs | 502 ++++++++++++++++-- hub-client/src/App.tsx | 11 +- .../src/components/auth/LoginButton.tsx | 10 +- hub-client/src/hooks/useAuth.ts | 122 +++-- hub-client/src/services/authService.ts | 98 ++-- hub-client/src/services/automergeSync.ts | 21 +- hub-client/vite.config.ts | 235 +++++++- 12 files changed, 1047 insertions(+), 239 deletions(-) create mode 100644 claude-notes/plans/2026-02-26-httponly-cookie-auth.md 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 `