}
);
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 `