From ea9daada1c9e0dc135d9d1e1f5a9afdcc0894be3 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:09:42 +0000 Subject: [PATCH 1/6] Generic OIDC authentication: replace Google-specific auth with provider-agnostic OIDC --- Cargo.lock | 2 + claude-notes/plans/2026-03-10-generic-oidc.md | 253 +++++++++++++ crates/quarto-hub/Cargo.toml | 6 + crates/quarto-hub/src/auth.rs | 341 ++++++++++++++++-- crates/quarto-hub/src/context.rs | 8 +- crates/quarto-hub/src/main.rs | 32 +- crates/quarto-hub/src/server.rs | 239 +++++++++--- crates/quarto/src/commands/hub.rs | 12 +- crates/quarto/src/main.rs | 32 +- 9 files changed, 836 insertions(+), 89 deletions(-) create mode 100644 claude-notes/plans/2026-03-10-generic-oidc.md diff --git a/Cargo.lock b/Cargo.lock index c547ba39..c3bbc8c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3360,6 +3360,7 @@ dependencies = [ "jsonwebtoken", "notify", "notify-debouncer-mini", + "reqwest", "samod", "serde", "serde_json", @@ -3374,6 +3375,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "url", "walkdir", ] diff --git a/claude-notes/plans/2026-03-10-generic-oidc.md b/claude-notes/plans/2026-03-10-generic-oidc.md new file mode 100644 index 00000000..ee7f79b3 --- /dev/null +++ b/claude-notes/plans/2026-03-10-generic-oidc.md @@ -0,0 +1,253 @@ +# Generic OIDC Authentication for quarto-hub + +## Overview + +The hub server's authentication is currently hardcoded to Google as the OIDC provider. This plan makes the backend generic so that any OIDC-compliant identity provider (Auth0, Azure AD, Okta, Keycloak, etc.) can be used by changing CLI flags — no code changes required. + +**Scope**: Backend (Rust) only. The frontend currently uses `@react-oauth/google` and will be updated separately when a new provider is needed. The backend should accept any valid JWT from any configured OIDC provider. + +## Current State — Google-Specific Touchpoints + +### `crates/quarto-hub/src/auth.rs` +- `GoogleClaims` struct with Google-specific fields (`picture`) +- `build_auth_state()` hardcodes Google JWKS URL (`googleapis.com/oauth2/v3/certs`) +- `build_auth_state()` hardcodes Google issuer (`https://accounts.google.com`) +- `build_auth_state()` hardcodes `Algorithm::RS256` +- `validate_tls_config()` parameter named `google_client_id` + +### `crates/quarto-hub/src/server.rs` +- `CSP_WITH_AUTH` hardcodes Google domains (`accounts.google.com`, `fonts.googleapis.com`, `lh3.googleusercontent.com`) +- `AuthCallbackForm` has Google-specific `g_csrf_token` field +- `auth_callback()` validates `g_csrf_token` cookie (Google's redirect CSRF mechanism) +- `AuthMeResponse` includes `picture` (Google-specific, though common in OIDC) +- Error messages reference "Google JWKS decoder" + +### `crates/quarto-hub/src/context.rs` +- `GoogleClaims` imported and used in `authenticate_claims()` return type +- Module doc references "Google" + +### CLI flags (3 locations) +- `crates/quarto-hub/src/main.rs`: `--google-client-id` / `QUARTO_HUB_GOOGLE_CLIENT_ID` +- `crates/quarto/src/commands/hub.rs`: `google_client_id` field in `HubArgs` +- `crates/quarto/src/main.rs`: `--google-client-id` / `QUARTO_HUB_GOOGLE_CLIENT_ID` in `Commands::Hub` + +### Frontend (out of scope for this plan, listed for awareness — except CSRF cookie) +- `hub-client/src/main.tsx`: `GoogleOAuthProvider` +- `hub-client/src/hooks/useAuth.ts`: `useGoogleOneTapLogin` +- `hub-client/src/components/auth/LoginScreen.tsx`: `GoogleLogin` +- `hub-client/src/services/authService.ts`: `googleLogout` + +## Design Decisions + +### 1. Standard OIDC Claims +Replace `GoogleClaims` with `OidcClaims` using standard OIDC claim names (`sub`, `email`, `email_verified`, `name`, `picture`). These are defined in the [OIDC Core spec](https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims) and supported by all major providers. Use `#[serde(default)]` for optional claims so providers that omit them still work. + +**Identity note**: The `sub` claim is only unique within a single issuer. The true user identity is the tuple `(issuer, sub)`. Currently only one provider is supported, so `sub` alone suffices for identity comparisons. If multi-provider support is ever added, all identity lookups must be scoped by issuer to prevent cross-provider `sub` collisions. + +### 2. Issuer from CLI, JWKS URL from OIDC Discovery +Replace `--google-client-id` with: +- `--oidc-client-id` (required to enable auth) +- `--oidc-issuer` (optional; defaults to `https://accounts.google.com`) +- `--oidc-image-domains` (optional; comma-separated list of domains allowed in CSP `img-src` for profile pictures; defaults to `lh3.googleusercontent.com`) + +Remove `--google-client-id` entirely. There is no `--oidc-jwks-url` flag — the JWKS URL is always discovered by fetching `{issuer}/.well-known/openid-configuration` at startup and reading the `jwks_uri` field. This guarantees the JWKS endpoint is cryptographically bound to the issuer (an operator cannot accidentally misconfigure them independently). The discovery document is fetched once at startup; if the fetch fails, the server refuses to start with a clear error. + +This approach follows the [OIDC Discovery spec](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig). All major OIDC providers (Google, Azure AD, Okta, Auth0, Keycloak) serve this endpoint. Supplying only `--oidc-client-id` works for Google out of the box since the issuer defaults to `https://accounts.google.com`. + +### 3. Algorithm Discovery +The `axum-jwt-auth` / `jsonwebtoken` stack already validates the `alg` header against what the JWKS endpoint advertises. Currently we hardcode `RS256` in `Validation::new(Algorithm::RS256)`. Instead, derive the allowed algorithms from the JWKS key metadata. At JWKS fetch time, extract the `alg` field from each JWK and build the allowed algorithm set from those values. This ensures only algorithms actually advertised by the provider are accepted, preventing algorithm confusion attacks. If a JWK has no `alg` field, skip it (RFC 7517 makes `alg` optional but all major OIDC providers include it). If the discovered set is empty (all JWKs omit `alg`), fall back to `RS256` only and log a warning — RS256 is by far the most common OIDC signing algorithm. Note: `Validation::new()` takes a single algorithm; use `Validation::default()` and set `validation.algorithms` to the discovered set. + +### 4. CSP Generalization +The current `CSP_WITH_AUTH` hardcodes Google domains. Replace with a generic CSP that: +- Keeps `default-src 'self'` +- Parses `config.issuer` as a `Url`, validates it is HTTPS, and extracts only `scheme://host[:port]` as the origin. Rejects malformed or non-HTTPS issuers at startup. Adds the origin to `script-src`, `connect-src`, and `frame-src` +- Builds `img-src` from `config.image_domains`: `img-src 'self' https://{domain1} https://{domain2} ...`. Defaults to `lh3.googleusercontent.com` if not configured. Validates each domain at startup: must match `[a-zA-Z0-9.-]+` only (bare hostname, no scheme, no path, no whitespace, no semicolons). Reject any domain containing characters outside this set to prevent CSP directive injection (e.g., `evil.com; script-src 'unsafe-inline'` would break the entire policy). + +Since CSP construction depends on runtime config, change from a `const` to a function that builds the CSP string from `AuthConfig`. + +### 5. Auth Callback Generalization +The `auth_callback` endpoint currently handles Google's specific redirect flow (POST with `credential` + `g_csrf_token`). Different providers have different redirect flows: + +**Approach**: Keep the existing `auth_callback` as a generic "credential submission" endpoint that: +1. Accepts a `credential` field (the JWT) via POST form +2. Validates it through the standard JWKS/issuer pipeline +3. Sets the HttpOnly cookie + +For CSRF: Replace the Google-specific `g_csrf_token` with the existing `X-Requested-With` check, OR accept a generic `csrf_token` field. Since the callback comes from a provider redirect (cross-origin POST), we can't use `X-Requested-With`. Instead, use the `state` parameter (standard OIDC) stored in a cookie before the redirect, validated on return. However, this adds complexity. + +**Decision**: Keep `auth_callback` as-is but mark it as **Google-frontend-specific**. It's tightly coupled to Google's Sign-In library (which controls the POST body and `g_csrf_token` cookie). Non-Google frontends should use `/auth/refresh` instead — it accepts a JWT via JSON POST, validates it through the full JWKS/issuer/allowlist pipeline, sets the HttpOnly cookie, and is protected by the standard `X-Requested-With` CSRF check. When the frontend is eventually updated to a generic OIDC library, `auth_callback` can be removed entirely. + +### 6. JWT Cookie Size +The raw JWT is stored as the cookie value. Google tokens are ~1KB, but other providers (e.g., Azure AD) can produce 2-4KB tokens. Browser cookie limits are typically 4096 bytes total (including name, attributes). If a token exceeds this, the browser silently drops the cookie and the user appears unauthenticated. + +**Approach**: Log a warning at cookie-set time if the token exceeds 3800 bytes (leaving headroom for cookie metadata). This makes the failure mode visible in server logs rather than a silent auth mystery. If a provider's tokens are genuinely too large, the fix is server-side sessions — but that's a separate effort driven by actual need. + +### 7. `email_verified` Handling +Two independent mechanisms, needed for different reasons: + +`#[serde(default)]` on `email_verified` in `OidcClaims`. This handles providers (e.g., Azure AD) that omit the claim entirely — the field deserializes as `false` (safe default) rather than failing. Providers that omit the claim will be rejected since `email_verified` is always enforced. + +## Work Items + +### Phase 1: Backend Core (auth.rs + context.rs) + +- [x] Rename `GoogleClaims` to `OidcClaims` in `auth.rs` + - Keep the same fields (`sub`, `email`, `email_verified`, `name`, `picture`) + - Make `email_verified` default to `false` via `#[serde(default)]` (safe default; providers that omit it require `--no-require-email-verified`) + + +- [x] Add OIDC fields to `AuthConfig` + ```rust + pub struct AuthConfig { + pub client_id: String, + pub issuer: String, // e.g. "https://accounts.google.com" + pub image_domains: Vec, // CSP img-src domains for profile pictures + pub allowed_emails: Option>, + pub allowed_domains: Option>, + } + ``` + Note: no `jwks_url` field — it is discovered at startup from the issuer's `/.well-known/openid-configuration`. + +- [x] Add `discover_jwks_url(issuer: &str) -> Result` function + - Validate that `issuer` is a well-formed HTTPS URL before making any network request (reject HTTP, malformed URLs) + - Fetch `{issuer}/.well-known/openid-configuration` + - Parse the JSON response and extract the `jwks_uri` field + - Validate that `jwks_uri` is an HTTPS URL + - Validate that the `issuer` field in the discovery document matches `config.issuer` (prevents issuer spoofing) + - On fetch failure, return a clear error with the URL that was attempted + - Use a short timeout (e.g., 10 seconds) to fail fast at startup + +- [x] Update `build_auth_state()` to use `AuthConfig` fields + discovery + - Take `&AuthConfig` instead of `&str` (client_id) + - Call `discover_jwks_url(&config.issuer)` to get the JWKS URL + - Use `config.issuer` instead of hardcoded Google issuer + - Extract allowed algorithms from JWKS key `alg` fields; set `validation.algorithms` to that discovered set; if empty, fall back to `RS256` with a warning + - Set `validation.set_audience(&[config.client_id])` to enforce `aud` claim validation (prevents confused deputy attacks) + - Ensure `exp` validation is enabled (on by default) and explicitly set `validation.validate_nbf = true` (`nbf` defaults to `false` in `jsonwebtoken`). Leeway of 60 seconds (`validation.leeway = 60`, already the library default) + - **Deferred**: On token validation failure due to unknown `kid`, refetch JWKS at most once per 30 seconds. Would require wrapping the `axum-jwt-auth` decoder's `decode()` call in `context.rs::authenticate_claims()`. The library's existing periodic refresh (1 hour default) handles normal key rotation; this optimization is for faster rotation response. + +- [x] Verify `check_allowlists()` always enforces `email_verified` (no opt-out flag) + +- [x] Update `validate_tls_config()` to use generic parameter name + - `google_client_id: Option<&str>` → `oidc_client_id: Option<&str>` (internal only, no user-facing change) + +- [x] Update `context.rs` to use `OidcClaims` instead of `GoogleClaims` + - `authenticate_claims()` return type + - Import path + +- [x] Update all tests in `auth.rs` to use `OidcClaims` and new `AuthConfig` fields + +### Phase 2: Server (server.rs) + +- [x] Replace `CSP_WITH_AUTH` const with `fn build_csp(config: &AuthConfig) -> String` + - Parse `config.issuer` as a `Url`, validate HTTPS, extract origin (scheme + host + port) + - Build CSP dynamically: + - `script-src 'self' {issuer_origin}` + - `connect-src 'self' {issuer_origin}` + - `frame-src {issuer_origin}` + - `style-src 'self' 'unsafe-inline'` (drop Google Fonts — provider-agnostic) + - `font-src 'self'` (drop Google Fonts CDN) + - `img-src 'self' https://{domain}...` (from `config.image_domains`) + +- [x] Add JWT size warning in `build_auth_cookie()`: if token length > 3800 bytes, log a warning about potential browser cookie size limits + +- [x] Keep `AuthCallbackForm` field as `g_csrf_token` (set by Google's Sign-In library, not our code) + +- [x] Update `auth_callback()` handler + - Keep `g_csrf_token` cookie name (unchanged) + - Add doc comment marking this endpoint as Google-frontend-specific + - Add doc comment pointing to `/auth/refresh` as the generic credential submission endpoint + - Update error messages to be provider-agnostic + +- [x] Update `auth_me()` — no changes needed (already returns generic fields) + +- [x] Update `build_router()` to use `build_csp()` instead of `CSP_WITH_AUTH` + +- [x] Update error messages in `build_router()` ("Google JWKS decoder" → "OIDC JWKS decoder") + +- [x] Update server.rs tests to remove Google-specific assertions (CSP test) + +### Phase 3: CLI Flags (3 files) + +- [x] `crates/quarto-hub/src/main.rs`: Replace `--google-client-id` with generic OIDC flags + ``` + --oidc-client-id OIDC client ID (enables auth) + --oidc-issuer Expected JWT issuer (default: https://accounts.google.com) + --oidc-image-domains Comma-separated domains for CSP img-src (default: lh3.googleusercontent.com) + ``` + - Remove `--google-client-id` flag and `QUARTO_HUB_GOOGLE_CLIENT_ID` env var entirely + - Add env vars: `OIDC_CLIENT_ID`, `OIDC_ISSUER`, `OIDC_IMAGE_DOMAINS` + - No `--oidc-jwks-url` flag; JWKS URL is discovered from `{issuer}/.well-known/openid-configuration` + +- [x] `crates/quarto/src/main.rs`: Replace `--google-client-id` with the same generic OIDC flags (`--oidc-client-id`, `--oidc-issuer`); remove `QUARTO_HUB_GOOGLE_CLIENT_ID` + +- [x] `crates/quarto/src/commands/hub.rs`: Update `HubArgs` struct — remove `google_client_id`, add `oidc_client_id`, `oidc_issuer`, `oidc_image_domains`; update `run_hub()` to build `AuthConfig` + +- [x] Update `validate_tls_config()` calls to pass generic client ID + +**Note**: The `validate_tls_config()` parameter rename (Phase 1) and CLI flag changes (Phase 3) must be done together — both `main.rs` files call `validate_tls_config()` with the old parameter name. Implement Phases 1–3 before expecting compilation to succeed. + +### Phase 4: Documentation & Module Docs + +- [x] Update module doc comment in `auth.rs` (currently says "Google OAuth2") +- [x] Update module doc comment in `context.rs` +- [x] Update doc comments in `server.rs`: + - `auth_callback()` doc: "Google OAuth2 redirect callback" → generic + - `auth_refresh()` doc: "Validate a fresh Google JWT" / "Google One Tap" → generic + - `AUTH_COOKIE_MAX_AGE` comment: "matches Google ID token lifetime" → generic +- [x] Update code comments in `auth.rs`: + - `check_allowlists()` line 56: "Google normalizes emails to lowercase" → provider-agnostic + - `build_auth_state()` line 114: "Fetch the initial JWKS keys from Google" → "Discover JWKS URL and fetch initial keys" +- [x] Update `validate_tls_config()` error message text: `--google-client-id requires TLS` → `--oidc-client-id requires TLS` +- [x] Update `--help` text to be provider-agnostic where appropriate + +### Phase 5: Tests + +- [x] Add unit test: `OidcClaims` deserializes Google-style JWT payload +- [x] Add unit test: `OidcClaims` deserializes Azure AD-style JWT payload (no `picture`, `email_verified` absent) +- [x] Add unit test: `OidcClaims` without `email_verified` claim defaults to `false` and is rejected +- [x] Add unit test: `build_csp()` generates correct CSP for Google issuer +- [x] Add unit test: `build_csp()` generates correct CSP for custom issuer (e.g. `https://login.microsoftonline.com/...`) +- [x] Add unit test: `build_csp()` includes custom image domains in `img-src` +- [x] Add unit test: `build_csp()` uses default Google image domain when `image_domains` is empty +- [x] Add unit test: `AuthCallbackForm` deserializes with `g_csrf_token` field +- [ ] Add unit test: JWT with wrong `aud` claim is rejected — requires live JWKS (integration test) +- [ ] Add unit test: `discover_jwks_url()` parses a valid discovery document and extracts `jwks_uri` — requires mock HTTP server or live network +- [ ] Add unit test: `discover_jwks_url()` rejects a discovery document where `issuer` doesn't match — requires mock HTTP server +- [ ] Add unit test: `discover_jwks_url()` rejects a non-HTTPS `jwks_uri` — requires mock HTTP server +- [x] Add unit test: `discover_jwks_url()` rejects an HTTP (non-HTTPS) issuer before fetching +- [x] Add unit test: `discover_jwks_url()` rejects a malformed issuer URL before fetching +- [x] Add unit test: `build_csp()` rejects a non-HTTPS issuer +- [x] Add unit test: `build_csp()` rejects a malformed issuer URL +- [x] Add unit test: image domain validation rejects CSP injection (`evil.com; script-src 'unsafe-inline'`) +- [x] Add unit test: image domain validation rejects domains with whitespace +- [x] Add unit test: image domain validation rejects domains with scheme prefix (`https://example.com`) +- [x] Add unit test: image domain validation accepts valid domains (`lh3.googleusercontent.com`, `cdn.example.co.uk`) +- [x] Verify existing tests pass with renamed types + +## Migration Guide (for operators) + +### Before (Google-only) +```bash +hub --google-client-id +``` + +### After (Google — issuer and JWKS URL default to Google) +```bash +hub --oidc-client-id +``` + +### After (Custom OIDC provider) +```bash +hub \ + --oidc-client-id \ + --oidc-issuer https://your-provider.com \ + --oidc-image-domains avatars.your-provider.com,cdn.your-provider.com +``` +The JWKS URL is automatically discovered from `https://your-provider.com/.well-known/openid-configuration` at startup. Profile picture domains default to `lh3.googleusercontent.com` if `--oidc-image-domains` is not set. + +## Non-Goals + +- **Frontend changes**: The frontend remains Google-specific. When a new provider is needed, the frontend will be updated separately (likely replacing `@react-oauth/google` with a generic OIDC client library). +- **Multi-provider support**: Only one OIDC provider at a time. Supporting multiple simultaneous providers would require more complex routing and is not needed now. +- **OIDC Discovery beyond JWKS**: We use `/.well-known/openid-configuration` to discover the `jwks_uri`, but we do not auto-discover other fields (e.g., `authorization_endpoint`, `token_endpoint`, `supported_scopes`). The backend only needs the JWKS URL for token validation; other discovery fields are relevant to the frontend's login flow. diff --git a/crates/quarto-hub/Cargo.toml b/crates/quarto-hub/Cargo.toml index 1d398dbe..bd63266e 100644 --- a/crates/quarto-hub/Cargo.toml +++ b/crates/quarto-hub/Cargo.toml @@ -31,6 +31,12 @@ axum-jwt-auth = "0.6" jsonwebtoken = "10" tokio-util = { workspace = true } +# HTTP client for OIDC discovery +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } + +# URL parsing for OIDC issuer validation +url = "2.5" + # Cookie building (already transitive via axum-jwt-auth) cookie = "0.18" time = "0.3" diff --git a/crates/quarto-hub/src/auth.rs b/crates/quarto-hub/src/auth.rs index 7380cbde..d8aa2b1c 100644 --- a/crates/quarto-hub/src/auth.rs +++ b/crates/quarto-hub/src/auth.rs @@ -1,10 +1,13 @@ -//! Google OAuth2 authentication for quarto-hub. +//! OIDC authentication for quarto-hub. //! //! All auth code lives in this module. Authentication is optional — disabled -//! by default and enabled with `--google-client-id `. +//! by default and enabled with `--oidc-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. +//! Uses OIDC ID tokens (JWTs) validated locally against the provider's cached +//! public keys via `axum-jwt-auth`. No per-connection HTTP call to the provider. +//! +//! The JWKS URL is discovered automatically from the issuer's +//! `/.well-known/openid-configuration` endpoint at startup. use axum::http::StatusCode; use axum_jwt_auth::RemoteJwksDecoder; @@ -17,13 +20,20 @@ use tokio_util::sync::CancellationToken; #[derive(Debug, Clone)] pub struct AuthConfig { pub client_id: String, + pub issuer: String, + pub image_domains: Vec, pub allowed_emails: Option>, pub allowed_domains: Option>, } -/// Google ID token claims. +/// OIDC ID token claims. +/// +/// Uses standard OIDC claim names defined in the +/// [OIDC Core spec](https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims). +/// `email_verified` defaults to `false` via `#[serde(default)]` so providers +/// that omit the claim (e.g. Azure AD) deserialize safely rather than failing. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GoogleClaims { +pub struct OidcClaims { pub sub: String, pub email: String, #[serde(default)] @@ -40,7 +50,7 @@ pub struct GoogleClaims { /// 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> { +pub fn check_allowlists(claims: &OidcClaims, config: &AuthConfig) -> Result<(), StatusCode> { if !claims.email_verified { return Err(StatusCode::UNAUTHORIZED); } @@ -53,17 +63,16 @@ pub fn check_allowlists(claims: &GoogleClaims, config: &AuthConfig) -> Result<() return Ok(()); } - // Case-insensitive comparison: Google normalizes emails to lowercase - // in ID token claims, but the allowlist may have mixed case. Using - // eq_ignore_ascii_case is also forward-compatible with non-Google - // identity providers that may not normalize. + // Case-insensitive comparison: most OIDC providers normalize emails to + // lowercase in ID token claims, but the allowlist may have mixed case. + // Using eq_ignore_ascii_case handles providers that don't normalize. let email_ok = config .allowed_emails .as_ref() .is_some_and(|list| list.iter().any(|e| e.eq_ignore_ascii_case(&claims.email))); let domain_ok = config.allowed_domains.as_ref().is_some_and(|list| { - let domain = claims.email.split('@').last().unwrap_or(""); + let domain = claims.email.split('@').next_back().unwrap_or(""); list.iter().any(|d| d.eq_ignore_ascii_case(domain)) }); @@ -96,22 +105,176 @@ impl std::fmt::Debug for AuthState { } } -/// Build the JWKS decoder for Google ID token validation. +/// OIDC Discovery document (subset of fields we need). +#[derive(Deserialize)] +struct OidcDiscoveryDocument { + issuer: String, + jwks_uri: String, +} + +/// Discover the JWKS URL from the issuer's `/.well-known/openid-configuration`. +/// +/// Validates: +/// - The issuer is a well-formed HTTPS URL +/// - The discovery document's `issuer` field matches the configured issuer +/// - The `jwks_uri` is an HTTPS URL +/// +/// Returns the `jwks_uri` from the discovery document. +pub async fn discover_jwks_url(issuer: &str) -> Result> { + // Validate issuer is a well-formed HTTPS URL before making any request. + let issuer_url = url::Url::parse(issuer) + .map_err(|e| format!("Malformed OIDC issuer URL '{issuer}': {e}"))?; + if issuer_url.scheme() != "https" { + return Err(format!("OIDC issuer must use HTTPS, got '{}'", issuer_url.scheme()).into()); + } + + let discovery_url = format!( + "{}/.well-known/openid-configuration", + issuer.trim_end_matches('/') + ); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build()?; + + let response = client.get(&discovery_url).send().await.map_err(|e| { + format!("Failed to fetch OIDC discovery document from {discovery_url}: {e}") + })?; + + if !response.status().is_success() { + return Err(format!( + "OIDC discovery endpoint returned HTTP {}: {discovery_url}", + response.status() + ) + .into()); + } + + let doc: OidcDiscoveryDocument = response.json().await.map_err(|e| { + format!("Failed to parse OIDC discovery document from {discovery_url}: {e}") + })?; + + // Validate that the discovery document's issuer matches what we configured + // (prevents issuer spoofing). + if doc.issuer.trim_end_matches('/') != issuer.trim_end_matches('/') { + return Err(format!( + "OIDC issuer mismatch: configured '{}' but discovery document reports '{}'", + issuer, doc.issuer + ) + .into()); + } + + // Validate jwks_uri is HTTPS. + let jwks_url = url::Url::parse(&doc.jwks_uri) + .map_err(|e| format!("Malformed JWKS URI '{}': {e}", doc.jwks_uri))?; + if jwks_url.scheme() != "https" { + return Err(format!( + "JWKS URI must use HTTPS, got '{}' from {}", + jwks_url.scheme(), + discovery_url + ) + .into()); + } + + Ok(doc.jwks_uri) +} + +/// Discover allowed JWT signing algorithms from a JWKS endpoint. +/// +/// Fetches the JWKS and extracts the `alg` field from each key. +/// If the resulting set is empty (all keys omit `alg`), falls back to +/// `[RS256]` — the most common OIDC signing algorithm. +async fn discover_algorithms(jwks_url: &str) -> Result, Box> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build()?; + + let jwks: jsonwebtoken::jwk::JwkSet = client + .get(jwks_url) + .send() + .await + .map_err(|e| format!("Failed to fetch JWKS from {jwks_url}: {e}"))? + .json() + .await + .map_err(|e| format!("Failed to parse JWKS from {jwks_url}: {e}"))?; + + let mut algorithms = Vec::new(); + for jwk in &jwks.keys { + if let Some(ref alg) = jwk.common.key_algorithm { + // Convert JWK key_algorithm to jsonwebtoken Algorithm + let algo_str = format!("{alg:?}"); + if let Ok(algo) = algo_str.parse::() + && !algorithms.contains(&algo.0) + { + algorithms.push(algo.0); + } + } + } + + if algorithms.is_empty() { + tracing::warn!("No 'alg' field found in any JWK from {jwks_url}; falling back to RS256"); + algorithms.push(Algorithm::RS256); + } else { + tracing::info!( + algorithms = ?algorithms, + "Discovered JWT signing algorithms from JWKS" + ); + } + + Ok(algorithms) +} + +/// Wrapper for parsing Algorithm from JWK key_algorithm string representation. +struct AlgorithmWrapper(Algorithm); + +impl std::str::FromStr for AlgorithmWrapper { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "RS256" => Ok(AlgorithmWrapper(Algorithm::RS256)), + "RS384" => Ok(AlgorithmWrapper(Algorithm::RS384)), + "RS512" => Ok(AlgorithmWrapper(Algorithm::RS512)), + "ES256" => Ok(AlgorithmWrapper(Algorithm::ES256)), + "ES384" => Ok(AlgorithmWrapper(Algorithm::ES384)), + "PS256" => Ok(AlgorithmWrapper(Algorithm::PS256)), + "PS384" => Ok(AlgorithmWrapper(Algorithm::PS384)), + "PS512" => Ok(AlgorithmWrapper(Algorithm::PS512)), + "EdDSA" => Ok(AlgorithmWrapper(Algorithm::EdDSA)), + other => Err(format!("Unsupported JWK algorithm: {other}")), + } + } +} + +/// Build the JWKS decoder for OIDC ID token validation. /// Returns an `AuthState` that owns both the decoder and the /// background JWKS refresh task handle. +/// +/// Discovers the JWKS URL and signing algorithms from the provider's +/// OIDC discovery endpoint, then initializes the decoder with provider-specific +/// validation settings. pub async fn build_auth_state( - client_id: &str, + config: &AuthConfig, ) -> std::result::Result> { - let mut validation = Validation::new(Algorithm::RS256); - validation.set_audience(&[client_id]); - validation.set_issuer(&["https://accounts.google.com"]); + // Discover JWKS URL and fetch initial keys. + let jwks_url = discover_jwks_url(&config.issuer).await?; + tracing::info!(jwks_url = %jwks_url, "Discovered JWKS URL from OIDC issuer"); + + // Discover algorithms from the JWKS endpoint. + let algorithms = discover_algorithms(&jwks_url).await?; + + let mut validation = Validation::default(); + validation.algorithms = algorithms; + validation.set_audience(&[&config.client_id]); + validation.set_issuer(&[&config.issuer]); + validation.validate_nbf = true; + // leeway defaults to 60 seconds in jsonwebtoken, which is fine let decoder = RemoteJwksDecoder::builder() - .jwks_url("https://www.googleapis.com/oauth2/v3/certs".to_string()) + .jwks_url(jwks_url) .validation(validation) .build()?; - // Fetch the initial JWKS keys from Google before accepting requests. + // Fetch the initial JWKS keys before accepting requests. decoder.initialize().await?; // Spawn the periodic JWKS key refresh as a background task. @@ -137,19 +300,19 @@ 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). pub fn validate_tls_config( - google_client_id: Option<&str>, + oidc_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 { + if oidc_client_id.is_some() && !behind_tls_proxy && !allow_insecure_auth { return Err( - "--google-client-id requires TLS to protect tokens in transit.\n\ + "--oidc-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(), ); } - if allow_insecure_auth && google_client_id.is_some() { + if allow_insecure_auth && oidc_client_id.is_some() { tracing::warn!( "Auth enabled WITHOUT TLS (--allow-insecure-auth). \ Tokens will transit in plaintext. Do not use in production." @@ -158,12 +321,33 @@ pub fn validate_tls_config( Ok(()) } +/// Validate that an image domain is safe for CSP inclusion. +/// +/// Accepts bare hostnames only (e.g. `lh3.googleusercontent.com`). +/// Rejects domains containing characters that could allow CSP injection. +pub fn validate_image_domain(domain: &str) -> Result<(), String> { + if domain.is_empty() { + return Err("Image domain must not be empty".to_string()); + } + // Must be a bare hostname: alphanumeric, dots, hyphens only. + // No scheme, no path, no whitespace, no semicolons. + let valid = domain + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-'); + if !valid { + return Err(format!( + "Invalid image domain '{domain}': must contain only alphanumeric characters, dots, and hyphens" + )); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; - fn make_claims(email: &str, verified: bool) -> GoogleClaims { - GoogleClaims { + fn make_claims(email: &str, verified: bool) -> OidcClaims { + OidcClaims { sub: "123".to_string(), email: email.to_string(), email_verified: verified, @@ -175,6 +359,8 @@ mod tests { fn make_config(emails: Option>, domains: Option>) -> AuthConfig { AuthConfig { client_id: "test-client-id".to_string(), + issuer: "https://accounts.google.com".to_string(), + image_domains: vec!["lh3.googleusercontent.com".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()), } @@ -306,4 +492,109 @@ mod tests { fn tls_not_required_when_auth_disabled() { assert!(validate_tls_config(None, false, false).is_ok()); } + + // ── OidcClaims deserialization ───────────────────────────── + + #[test] + fn oidc_claims_google_style() { + let json = r#"{ + "sub": "1234567890", + "email": "user@gmail.com", + "email_verified": true, + "name": "Test User", + "picture": "https://lh3.googleusercontent.com/photo.jpg" + }"#; + let claims: OidcClaims = serde_json::from_str(json).unwrap(); + assert_eq!(claims.sub, "1234567890"); + assert_eq!(claims.email, "user@gmail.com"); + assert!(claims.email_verified); + assert_eq!(claims.name.as_deref(), Some("Test User")); + assert!(claims.picture.is_some()); + } + + #[test] + fn oidc_claims_azure_style_no_picture_no_email_verified() { + let json = r#"{ + "sub": "AAAAABBBBBcccccc", + "email": "user@contoso.com", + "name": "Contoso User" + }"#; + let claims: OidcClaims = serde_json::from_str(json).unwrap(); + assert_eq!(claims.sub, "AAAAABBBBBcccccc"); + assert_eq!(claims.email, "user@contoso.com"); + // email_verified defaults to false when absent + assert!(!claims.email_verified); + assert_eq!(claims.name.as_deref(), Some("Contoso User")); + assert!(claims.picture.is_none()); + } + + #[test] + fn oidc_claims_missing_email_verified_defaults_false_and_rejected() { + let json = r#"{ + "sub": "xyz", + "email": "user@example.com", + "name": "User" + }"#; + let claims: OidcClaims = serde_json::from_str(json).unwrap(); + assert!(!claims.email_verified); + + // Should be rejected by check_allowlists + let config = make_config(None, None); + assert_eq!( + check_allowlists(&claims, &config), + Err(StatusCode::UNAUTHORIZED) + ); + } + + // ── validate_image_domain ────────────────────────────────── + + #[test] + fn image_domain_valid() { + assert!(validate_image_domain("lh3.googleusercontent.com").is_ok()); + assert!(validate_image_domain("cdn.example.co.uk").is_ok()); + assert!(validate_image_domain("avatars.githubusercontent.com").is_ok()); + } + + #[test] + fn image_domain_rejects_csp_injection() { + assert!(validate_image_domain("evil.com; script-src 'unsafe-inline'").is_err()); + } + + #[test] + fn image_domain_rejects_whitespace() { + assert!(validate_image_domain("evil.com evil2.com").is_err()); + } + + #[test] + fn image_domain_rejects_scheme_prefix() { + assert!(validate_image_domain("https://example.com").is_err()); + } + + #[test] + fn image_domain_rejects_empty() { + assert!(validate_image_domain("").is_err()); + } + + // ── discover_jwks_url (unit tests with mock data) ────────── + + #[test] + fn discover_jwks_url_rejects_http_issuer() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(discover_jwks_url("http://accounts.google.com")); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("OIDC issuer must use HTTPS") + ); + } + + #[test] + fn discover_jwks_url_rejects_malformed_url() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(discover_jwks_url("not a url at all")); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Malformed")); + } } diff --git a/crates/quarto-hub/src/context.rs b/crates/quarto-hub/src/context.rs index b011e678..e0c4c78b 100644 --- a/crates/quarto-hub/src/context.rs +++ b/crates/quarto-hub/src/context.rs @@ -14,7 +14,7 @@ use samod::{ConnectionId, Repo}; use tokio::sync::Mutex; use tracing::{debug, info, warn}; -use crate::auth::{self, AuthConfig, AuthState, GoogleClaims}; +use crate::auth::{self, AuthConfig, AuthState, OidcClaims}; use crate::discovery::ProjectFiles; use crate::error::Result; use crate::index::{IndexDocument, load_or_create_index}; @@ -348,7 +348,7 @@ impl HubContext { pub async fn authenticate_claims( &self, token: Option<&str>, - ) -> std::result::Result { + ) -> std::result::Result { let auth_config = self.auth_config().ok_or(StatusCode::UNAUTHORIZED)?; let token = token.ok_or(StatusCode::UNAUTHORIZED)?; @@ -359,8 +359,8 @@ impl HubContext { // 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 = + // to select OidcClaims. + let token_data: jsonwebtoken::TokenData = auth_state.decoder.decode(token).await.map_err(|err| { tracing::warn!(%err, "Auth failed"); StatusCode::UNAUTHORIZED diff --git a/crates/quarto-hub/src/main.rs b/crates/quarto-hub/src/main.rs index aed03746..3cd975c9 100644 --- a/crates/quarto-hub/src/main.rs +++ b/crates/quarto-hub/src/main.rs @@ -58,10 +58,28 @@ struct Args { #[arg(long, default_value = "500")] watch_debounce: u64, - /// Google OAuth2 client ID. Presence enables auth. + /// OIDC client ID. Presence enables auth. /// Requires --behind-tls-proxy (or --allow-insecure-auth for local dev). - #[arg(long, env = "QUARTO_HUB_GOOGLE_CLIENT_ID")] - google_client_id: Option, + #[arg(long, env = "OIDC_CLIENT_ID")] + oidc_client_id: Option, + + /// OIDC issuer URL for JWT validation. + /// The JWKS URL is discovered automatically from {issuer}/.well-known/openid-configuration. + #[arg( + long, + env = "OIDC_ISSUER", + default_value = "https://accounts.google.com" + )] + oidc_issuer: String, + + /// Comma-separated domains allowed in CSP img-src for profile pictures. + #[arg( + long, + env = "OIDC_IMAGE_DOMAINS", + value_delimiter = ',', + default_value = "lh3.googleusercontent.com" + )] + oidc_image_domains: Vec, /// Acknowledge that a TLS-terminating reverse proxy (nginx, Caddy, /// cloud LB) sits in front of the hub. Required when auth is enabled. @@ -139,15 +157,17 @@ async fn main() -> anyhow::Result<()> { // Validate TLS configuration when auth is enabled auth::validate_tls_config( - args.google_client_id.as_deref(), + args.oidc_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| auth::AuthConfig { + // Build auth config if OIDC client ID is provided + let auth_config = args.oidc_client_id.map(|client_id| auth::AuthConfig { client_id, + issuer: args.oidc_issuer, + image_domains: args.oidc_image_domains, allowed_emails: args.allowed_emails, allowed_domains: args.allowed_domains, }); diff --git a/crates/quarto-hub/src/server.rs b/crates/quarto-hub/src/server.rs index a3cb66ec..9c5a7e76 100644 --- a/crates/quarto-hub/src/server.rs +++ b/crates/quarto-hub/src/server.rs @@ -94,23 +94,59 @@ struct UpdateDocumentRequest { value: String, } -/// Content-Security-Policy for defense-in-depth against XSS. -/// Even with HttpOnly cookies eliminating credential theft, XSS can still -/// make authenticated requests from the victim's browser. CSP limits what -/// injected scripts can do. -const CSP_WITH_AUTH: &str = "\ - 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' https://accounts.google.com; \ - frame-src https://accounts.google.com"; +/// Build a Content-Security-Policy header value from the auth configuration. +/// +/// Defense-in-depth against XSS: even with HttpOnly cookies eliminating +/// credential theft, XSS can still make authenticated requests from the +/// victim's browser. CSP limits what injected scripts can do. +/// +/// The CSP is constructed dynamically from the OIDC issuer origin and +/// configured image domains (for profile pictures). +fn build_csp(config: &auth::AuthConfig) -> std::result::Result { + let issuer_url = url::Url::parse(&config.issuer) + .map_err(|e| format!("Invalid OIDC issuer URL for CSP: {e}"))?; + if issuer_url.scheme() != "https" { + return Err(format!( + "OIDC issuer must use HTTPS for CSP, got '{}'", + issuer_url.scheme() + )); + } + let issuer_origin = match issuer_url.port() { + Some(port) => format!("https://{}:{}", issuer_url.host_str().unwrap_or(""), port), + None => format!("https://{}", issuer_url.host_str().unwrap_or("")), + }; + + // Validate image domains. + let image_domains: Vec<&str> = if config.image_domains.is_empty() { + vec!["lh3.googleusercontent.com"] + } else { + for domain in &config.image_domains { + auth::validate_image_domain(domain).map_err(|e| format!("CSP image domain: {e}"))?; + } + config.image_domains.iter().map(|s| s.as_str()).collect() + }; + + let img_src = image_domains + .iter() + .map(|d| format!("https://{d}")) + .collect::>() + .join(" "); + + Ok(format!( + "default-src 'self'; \ + script-src 'self' {issuer_origin}; \ + style-src 'self' 'unsafe-inline'; \ + font-src 'self'; \ + img-src 'self' data: {img_src}; \ + connect-src 'self' {issuer_origin}; \ + frame-src {issuer_origin}" + )) +} /// Cookie name for the hub authentication token. const AUTH_COOKIE_NAME: &str = "quarto_hub_token"; -/// Cookie Max-Age in seconds (matches Google ID token lifetime). +/// Cookie Max-Age in seconds (1 hour, matches typical OIDC ID token lifetime). const AUTH_COOKIE_MAX_AGE: u32 = 3600; /// JSON error body for auth failures, so clients can distinguish @@ -147,6 +183,14 @@ fn cookie_token(headers: &HeaderMap) -> Option { /// Uses the `cookie` crate for correct value encoding, preventing /// injection of extra attributes via malformed token values. fn build_auth_cookie(token: &str, secure: bool) -> String { + if token.len() > 3800 { + tracing::warn!( + token_len = token.len(), + "JWT token exceeds 3800 bytes; browsers may silently drop the cookie \ + (4096 byte limit including cookie metadata). Consider server-side sessions \ + if your OIDC provider issues large tokens." + ); + } let mut builder = cookie::Cookie::build((AUTH_COOKIE_NAME, token)) .http_only(true) .same_site(SameSite::Lax) @@ -438,17 +482,20 @@ async fn update_document( } } -/// Google OAuth2 redirect callback form data. +/// Google-frontend-specific 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. +/// +/// This form structure is specific to Google's Sign-In library. Non-Google +/// OIDC frontends should use `POST /auth/refresh` instead. #[derive(Deserialize)] struct AuthCallbackForm { credential: String, g_csrf_token: String, } -/// Handle Google OAuth2 redirect callback. +/// Handle Google-frontend-specific OAuth2 redirect callback. /// /// Receives the credential JWT from Google's POST, validates the CSRF token /// and the JWT itself, then sets an HttpOnly cookie and redirects to `/`. @@ -456,6 +503,12 @@ struct AuthCallbackForm { /// Validating the JWT here (not just in subsequent API calls) prevents /// setting a cookie with a bogus credential. /// +/// **Google-specific**: This endpoint is tightly coupled to Google's Sign-In +/// library (which controls the POST body and `g_csrf_token` cookie). Non-Google +/// OIDC frontends should use `POST /auth/refresh` instead — it accepts a JWT +/// via JSON POST, validates through the full JWKS/issuer/allowlist pipeline, +/// and is protected by the standard `X-Requested-With` CSRF check. +/// /// **CSRF**: This endpoint is excluded from the `X-Requested-With` CSRF /// check because it receives a cross-origin POST from Google's servers. /// Google's own `g_csrf_token` cookie provides CSRF protection instead. @@ -549,11 +602,15 @@ async fn auth_logout( Ok(response) } -/// Validate a fresh Google JWT and set a new cookie. +/// Validate a fresh OIDC JWT and set a new cookie. /// -/// Called by the client when Google One Tap silently produces a new -/// credential. The new JWT goes through the full `authenticate()` path -/// (signature, audience, issuer, email allowlist) before setting the cookie. +/// Called by the client after obtaining a new credential from the OIDC provider +/// (e.g. Google One Tap silent refresh). The new JWT goes through the full +/// `authenticate()` path (signature, audience, issuer, email allowlist) +/// before setting the cookie. +/// +/// This is also the recommended credential submission endpoint for non-Google +/// OIDC frontends (instead of the Google-specific `/auth/callback`). /// /// Requires `X-Requested-With: XMLHttpRequest` for CSRF protection. async fn auth_refresh( @@ -673,13 +730,9 @@ async fn handle_websocket(socket: WebSocket, ctx: SharedContext, email: Option Result { if let Some(config) = ctx.auth_config() { - let auth_state = auth::build_auth_state(&config.client_id) - .await - .map_err(|e| { - crate::error::Error::Server(format!( - "Failed to initialize Google JWKS decoder: {e}" - )) - })?; + let auth_state = auth::build_auth_state(config).await.map_err(|e| { + crate::error::Error::Server(format!("Failed to initialize OIDC JWKS decoder: {e}")) + })?; ctx.set_auth_state(auth_state) .map_err(|e| crate::error::Error::Server(e.to_string()))?; } @@ -706,11 +759,14 @@ async fn build_router(ctx: SharedContext) -> Result { .layer(TraceLayer::new_for_http().make_span_with(RedactedMakeSpan)); // Add Content-Security-Policy header when auth is enabled. - // Without auth there are no Google OAuth scripts to allow. - if ctx.auth_config().is_some() { + // Without auth there are no OIDC provider scripts to allow. + if let Some(config) = ctx.auth_config() { + let csp = build_csp(config) + .map_err(|e| crate::error::Error::Server(format!("Failed to build CSP: {e}")))?; router = router.layer(SetResponseHeaderLayer::if_not_present( http::header::HeaderName::from_static("content-security-policy"), - http::header::HeaderValue::from_static(CSP_WITH_AUTH), + http::header::HeaderValue::from_str(&csp) + .map_err(|e| crate::error::Error::Server(format!("Invalid CSP header: {e}")))?, )); } @@ -1115,21 +1171,74 @@ mod tests { // ── CSP ─────────────────────────────────────────────────────── + fn google_auth_config() -> auth::AuthConfig { + auth::AuthConfig { + client_id: "test-client-id".to_string(), + issuer: "https://accounts.google.com".to_string(), + image_domains: vec!["lh3.googleusercontent.com".to_string()], + allowed_emails: None, + allowed_domains: None, + } + } + #[test] - fn csp_allows_google_oauth() { - assert!(CSP_WITH_AUTH.contains("https://accounts.google.com")); + fn csp_google_issuer() { + let config = google_auth_config(); + let csp = build_csp(&config).unwrap(); + assert!(csp.contains("https://accounts.google.com")); + assert!(csp.contains("https://lh3.googleusercontent.com")); + } + + #[test] + fn csp_custom_issuer() { + let config = auth::AuthConfig { + client_id: "test".to_string(), + issuer: "https://login.microsoftonline.com/tenant-id/v2.0".to_string(), + image_domains: vec!["graph.microsoft.com".to_string()], + allowed_emails: None, + allowed_domains: None, + }; + let csp = build_csp(&config).unwrap(); + assert!(csp.contains("https://login.microsoftonline.com")); + assert!(csp.contains("https://graph.microsoft.com")); + assert!(!csp.contains("accounts.google.com")); + } + + #[test] + fn csp_custom_image_domains() { + let config = auth::AuthConfig { + client_id: "test".to_string(), + issuer: "https://accounts.google.com".to_string(), + image_domains: vec![ + "avatars.example.com".to_string(), + "cdn.example.com".to_string(), + ], + allowed_emails: None, + allowed_domains: None, + }; + let csp = build_csp(&config).unwrap(); + assert!(csp.contains("https://avatars.example.com")); + assert!(csp.contains("https://cdn.example.com")); + } + + #[test] + fn csp_default_image_domain_when_empty() { + let config = auth::AuthConfig { + client_id: "test".to_string(), + issuer: "https://accounts.google.com".to_string(), + image_domains: vec![], + allowed_emails: None, + allowed_domains: None, + }; + let csp = build_csp(&config).unwrap(); + assert!(csp.contains("https://lh3.googleusercontent.com")); } #[test] fn csp_disallows_arbitrary_websocket() { - // CSP should NOT contain bare ws:/wss: scheme sources, which would - // allow XSS to exfiltrate data to any WebSocket host. Same-origin - // WebSocket is covered by 'self' in modern browsers (CSP Level 3). - let connect_src = CSP_WITH_AUTH - .split(';') - .find(|d| d.contains("connect-src")) - .unwrap(); - // Bare "ws:" or "wss:" as scheme sources (space-delimited tokens) + let config = google_auth_config(); + let csp = build_csp(&config).unwrap(); + let connect_src = csp.split(';').find(|d| d.contains("connect-src")).unwrap(); let has_bare_ws = connect_src .split_whitespace() .any(|tok| tok == "ws:" || tok == "wss:"); @@ -1141,17 +1250,57 @@ mod tests { #[test] fn csp_blocks_inline_scripts() { - // script-src should NOT contain 'unsafe-inline' - let script_src = CSP_WITH_AUTH - .split(';') - .find(|d| d.contains("script-src")) - .unwrap(); + let config = google_auth_config(); + let csp = build_csp(&config).unwrap(); + let script_src = csp.split(';').find(|d| d.contains("script-src")).unwrap(); assert!(!script_src.contains("unsafe-inline")); } #[test] fn csp_has_default_self() { - assert!(CSP_WITH_AUTH.contains("default-src 'self'")); + let config = google_auth_config(); + let csp = build_csp(&config).unwrap(); + assert!(csp.contains("default-src 'self'")); + } + + #[test] + fn csp_rejects_non_https_issuer() { + let config = auth::AuthConfig { + client_id: "test".to_string(), + issuer: "http://insecure.example.com".to_string(), + image_domains: vec![], + allowed_emails: None, + allowed_domains: None, + }; + assert!(build_csp(&config).is_err()); + } + + #[test] + fn csp_rejects_malformed_issuer() { + let config = auth::AuthConfig { + client_id: "test".to_string(), + issuer: "not a url".to_string(), + image_domains: vec![], + allowed_emails: None, + allowed_domains: None, + }; + assert!(build_csp(&config).is_err()); + } + + // ── AuthCallbackForm ────────────────────────────────────────── + + #[test] + fn auth_callback_form_deserializes() { + // AuthCallbackForm is used by axum's Form extractor which parses + // URL-encoded POST bodies. Verify it has the expected fields by + // deserializing from JSON (same serde derive). + let form: AuthCallbackForm = serde_json::from_value(serde_json::json!({ + "credential": "eyJhbGciOiJSUzI1NiJ9.test", + "g_csrf_token": "abc123" + })) + .unwrap(); + assert_eq!(form.credential, "eyJhbGciOiJSUzI1NiJ9.test"); + assert_eq!(form.g_csrf_token, "abc123"); } // ── format_peer_info ────────────────────────────────────────── diff --git a/crates/quarto/src/commands/hub.rs b/crates/quarto/src/commands/hub.rs index 5d4cdfd9..6b2e541d 100644 --- a/crates/quarto/src/commands/hub.rs +++ b/crates/quarto/src/commands/hub.rs @@ -23,7 +23,9 @@ pub struct HubArgs { pub sync_interval: u64, pub no_watch: bool, pub watch_debounce: u64, - pub google_client_id: Option, + pub oidc_client_id: Option, + pub oidc_issuer: String, + pub oidc_image_domains: Vec, pub behind_tls_proxy: bool, pub allow_insecure_auth: bool, pub allowed_emails: Option>, @@ -96,15 +98,17 @@ async fn run_hub(args: HubArgs) -> Result<()> { // Validate TLS configuration when auth is enabled auth::validate_tls_config( - args.google_client_id.as_deref(), + args.oidc_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| auth::AuthConfig { + // Build auth config if OIDC client ID is provided + let auth_config = args.oidc_client_id.map(|client_id| auth::AuthConfig { client_id, + issuer: args.oidc_issuer, + image_domains: args.oidc_image_domains, allowed_emails: args.allowed_emails, allowed_domains: args.allowed_domains, }); diff --git a/crates/quarto/src/main.rs b/crates/quarto/src/main.rs index 7425ad69..90b3fe1a 100644 --- a/crates/quarto/src/main.rs +++ b/crates/quarto/src/main.rs @@ -366,10 +366,28 @@ enum Commands { #[arg(long, default_value = "500")] watch_debounce: u64, - /// Google OAuth2 client ID. Presence enables auth. + /// OIDC client ID. Presence enables auth. /// Requires --behind-tls-proxy (or --allow-insecure-auth for local dev). - #[arg(long, env = "QUARTO_HUB_GOOGLE_CLIENT_ID")] - google_client_id: Option, + #[arg(long, env = "OIDC_CLIENT_ID")] + oidc_client_id: Option, + + /// OIDC issuer URL for JWT validation. + /// The JWKS URL is discovered automatically from {issuer}/.well-known/openid-configuration. + #[arg( + long, + env = "OIDC_ISSUER", + default_value = "https://accounts.google.com" + )] + oidc_issuer: String, + + /// Comma-separated domains allowed in CSP img-src for profile pictures. + #[arg( + long, + env = "OIDC_IMAGE_DOMAINS", + value_delimiter = ',', + default_value = "lh3.googleusercontent.com" + )] + oidc_image_domains: Vec, /// Acknowledge that a TLS-terminating reverse proxy (nginx, Caddy, /// cloud LB) sits in front of the hub. Required when auth is enabled. @@ -449,7 +467,9 @@ fn main() -> Result<()> { sync_interval, no_watch, watch_debounce, - google_client_id, + oidc_client_id, + oidc_issuer, + oidc_image_domains, behind_tls_proxy, allow_insecure_auth, allowed_emails, @@ -464,7 +484,9 @@ fn main() -> Result<()> { sync_interval, no_watch, watch_debounce, - google_client_id, + oidc_client_id, + oidc_issuer, + oidc_image_domains, behind_tls_proxy, allow_insecure_auth, allowed_emails, From cbe8608b89cd3b9e8e94d133bde353491efae6e4 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:17:20 +0000 Subject: [PATCH 2/6] Complete plan --- claude-notes/plans/2026-03-10-generic-oidc.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/claude-notes/plans/2026-03-10-generic-oidc.md b/claude-notes/plans/2026-03-10-generic-oidc.md index ee7f79b3..316762c8 100644 --- a/claude-notes/plans/2026-03-10-generic-oidc.md +++ b/claude-notes/plans/2026-03-10-generic-oidc.md @@ -124,7 +124,7 @@ Two independent mechanisms, needed for different reasons: - Extract allowed algorithms from JWKS key `alg` fields; set `validation.algorithms` to that discovered set; if empty, fall back to `RS256` with a warning - Set `validation.set_audience(&[config.client_id])` to enforce `aud` claim validation (prevents confused deputy attacks) - Ensure `exp` validation is enabled (on by default) and explicitly set `validation.validate_nbf = true` (`nbf` defaults to `false` in `jsonwebtoken`). Leeway of 60 seconds (`validation.leeway = 60`, already the library default) - - **Deferred**: On token validation failure due to unknown `kid`, refetch JWKS at most once per 30 seconds. Would require wrapping the `axum-jwt-auth` decoder's `decode()` call in `context.rs::authenticate_claims()`. The library's existing periodic refresh (1 hour default) handles normal key rotation; this optimization is for faster rotation response. + - **Not needed**: On-demand JWKS refetch on unknown `kid` was evaluated and rejected. OIDC providers overlap old and new keys during rotation by design, so the library's 1-hour periodic refresh is always sufficient. Emergency rotation (key compromise) is extremely rare, and in that scenario the compromised key's tokens should be rejected anyway — users must re-authenticate regardless. Adding retry logic would be over-engineering for a near-zero probability case. - [x] Verify `check_allowlists()` always enforces `email_verified` (no opt-out flag) From 205e780c2a738491df12bf76223ef2819a5db602 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:48:42 +0000 Subject: [PATCH 3/6] Perf: share startup request --- crates/quarto-hub/src/auth.rs | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/crates/quarto-hub/src/auth.rs b/crates/quarto-hub/src/auth.rs index d8aa2b1c..9de6f743 100644 --- a/crates/quarto-hub/src/auth.rs +++ b/crates/quarto-hub/src/auth.rs @@ -120,7 +120,10 @@ struct OidcDiscoveryDocument { /// - The `jwks_uri` is an HTTPS URL /// /// Returns the `jwks_uri` from the discovery document. -pub async fn discover_jwks_url(issuer: &str) -> Result> { +pub async fn discover_jwks_url( + client: &reqwest::Client, + issuer: &str, +) -> Result> { // Validate issuer is a well-formed HTTPS URL before making any request. let issuer_url = url::Url::parse(issuer) .map_err(|e| format!("Malformed OIDC issuer URL '{issuer}': {e}"))?; @@ -133,10 +136,6 @@ pub async fn discover_jwks_url(issuer: &str) -> Result Result Result, Box> { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build()?; - +async fn discover_algorithms( + client: &reqwest::Client, + jwks_url: &str, +) -> Result, Box> { let jwks: jsonwebtoken::jwk::JwkSet = client .get(jwks_url) .send() @@ -255,12 +253,17 @@ impl std::str::FromStr for AlgorithmWrapper { pub async fn build_auth_state( config: &AuthConfig, ) -> std::result::Result> { + // Shared HTTP client for OIDC discovery requests. + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build()?; + // Discover JWKS URL and fetch initial keys. - let jwks_url = discover_jwks_url(&config.issuer).await?; + let jwks_url = discover_jwks_url(&client, &config.issuer).await?; tracing::info!(jwks_url = %jwks_url, "Discovered JWKS URL from OIDC issuer"); // Discover algorithms from the JWKS endpoint. - let algorithms = discover_algorithms(&jwks_url).await?; + let algorithms = discover_algorithms(&client, &jwks_url).await?; let mut validation = Validation::default(); validation.algorithms = algorithms; @@ -580,7 +583,8 @@ mod tests { #[test] fn discover_jwks_url_rejects_http_issuer() { let rt = tokio::runtime::Runtime::new().unwrap(); - let result = rt.block_on(discover_jwks_url("http://accounts.google.com")); + let client = reqwest::Client::new(); + let result = rt.block_on(discover_jwks_url(&client, "http://accounts.google.com")); assert!(result.is_err()); assert!( result @@ -593,7 +597,8 @@ mod tests { #[test] fn discover_jwks_url_rejects_malformed_url() { let rt = tokio::runtime::Runtime::new().unwrap(); - let result = rt.block_on(discover_jwks_url("not a url at all")); + let client = reqwest::Client::new(); + let result = rt.block_on(discover_jwks_url(&client, "not a url at all")); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("Malformed")); } From 81c516200725818263a0acd03a73778dd8e07458 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:02:23 +0000 Subject: [PATCH 4/6] Refactor: validate config at construction, simplify downstream code --- crates/quarto-hub/src/auth.rs | 212 +++++++++++++++++++++--------- crates/quarto-hub/src/main.rs | 20 ++- crates/quarto-hub/src/server.rs | 136 +++++++------------ crates/quarto/src/commands/hub.rs | 20 ++- 4 files changed, 228 insertions(+), 160 deletions(-) diff --git a/crates/quarto-hub/src/auth.rs b/crates/quarto-hub/src/auth.rs index 9de6f743..ef6b20f3 100644 --- a/crates/quarto-hub/src/auth.rs +++ b/crates/quarto-hub/src/auth.rs @@ -16,16 +16,78 @@ use serde::{Deserialize, Serialize}; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; +/// Default image domain for Google profile pictures. +const DEFAULT_IMAGE_DOMAIN: &str = "lh3.googleusercontent.com"; + /// Authentication configuration. +/// +/// Construct via [`AuthConfig::new()`] which validates the issuer URL +/// and image domains at creation time. #[derive(Debug, Clone)] pub struct AuthConfig { pub client_id: String, + /// OIDC issuer URL, guaranteed to be a valid HTTPS URL. pub issuer: String, pub image_domains: Vec, pub allowed_emails: Option>, pub allowed_domains: Option>, } +impl AuthConfig { + /// Create a new `AuthConfig`, validating the issuer URL and image domains. + /// + /// - `issuer` must be a well-formed HTTPS URL. + /// - Each image domain must be a bare hostname (no scheme, no path). + /// - If `image_domains` is empty, defaults to Google's profile picture CDN. + pub fn new( + client_id: String, + issuer: String, + image_domains: Vec, + allowed_emails: Option>, + allowed_domains: Option>, + ) -> Result { + // Validate issuer is a well-formed HTTPS URL. + let parsed = url::Url::parse(&issuer) + .map_err(|e| format!("Malformed OIDC issuer URL '{issuer}': {e}"))?; + if parsed.scheme() != "https" { + return Err(format!( + "OIDC issuer must use HTTPS, got '{}'", + parsed.scheme() + )); + } + + // Apply default and validate image domains. + let image_domains = if image_domains.is_empty() { + vec![DEFAULT_IMAGE_DOMAIN.to_string()] + } else { + for domain in &image_domains { + validate_image_domain(domain).map_err(|e| format!("Image domain: {e}"))?; + } + image_domains + }; + + Ok(Self { + client_id, + issuer, + image_domains, + allowed_emails, + allowed_domains, + }) + } + + /// Extract the CSP origin (`scheme://host[:port]`) from the validated issuer URL. + /// + /// Panics if the issuer is not a valid URL, which cannot happen if + /// the config was constructed via [`AuthConfig::new()`]. + pub fn issuer_origin(&self) -> String { + let url = url::Url::parse(&self.issuer).expect("issuer validated at construction"); + match url.port() { + Some(port) => format!("https://{}:{}", url.host_str().unwrap_or(""), port), + None => format!("https://{}", url.host_str().unwrap_or("")), + } + } +} + /// OIDC ID token claims. /// /// Uses standard OIDC claim names defined in the @@ -114,8 +176,9 @@ struct OidcDiscoveryDocument { /// Discover the JWKS URL from the issuer's `/.well-known/openid-configuration`. /// +/// The `issuer` must be a validated HTTPS URL (guaranteed by [`AuthConfig::new()`]). +/// /// Validates: -/// - The issuer is a well-formed HTTPS URL /// - The discovery document's `issuer` field matches the configured issuer /// - The `jwks_uri` is an HTTPS URL /// @@ -124,13 +187,6 @@ pub async fn discover_jwks_url( client: &reqwest::Client, issuer: &str, ) -> Result> { - // Validate issuer is a well-formed HTTPS URL before making any request. - let issuer_url = url::Url::parse(issuer) - .map_err(|e| format!("Malformed OIDC issuer URL '{issuer}': {e}"))?; - if issuer_url.scheme() != "https" { - return Err(format!("OIDC issuer must use HTTPS, got '{}'", issuer_url.scheme()).into()); - } - let discovery_url = format!( "{}/.well-known/openid-configuration", issuer.trim_end_matches('/') @@ -177,6 +233,26 @@ pub async fn discover_jwks_url( Ok(doc.jwks_uri) } +/// Convert a JWK key algorithm to a JWT signing algorithm. +/// +/// Returns `None` for key encryption algorithms (RSA1_5, RSA-OAEP, etc.) +/// and unknown algorithms, which are not used for OIDC token signing. +fn signing_algorithm(ka: &jsonwebtoken::jwk::KeyAlgorithm) -> Option { + use jsonwebtoken::jwk::KeyAlgorithm; + match ka { + KeyAlgorithm::RS256 => Some(Algorithm::RS256), + KeyAlgorithm::RS384 => Some(Algorithm::RS384), + KeyAlgorithm::RS512 => Some(Algorithm::RS512), + KeyAlgorithm::ES256 => Some(Algorithm::ES256), + KeyAlgorithm::ES384 => Some(Algorithm::ES384), + KeyAlgorithm::PS256 => Some(Algorithm::PS256), + KeyAlgorithm::PS384 => Some(Algorithm::PS384), + KeyAlgorithm::PS512 => Some(Algorithm::PS512), + KeyAlgorithm::EdDSA => Some(Algorithm::EdDSA), + _ => None, + } +} + /// Discover allowed JWT signing algorithms from a JWKS endpoint. /// /// Fetches the JWKS and extracts the `alg` field from each key. @@ -197,13 +273,11 @@ async fn discover_algorithms( let mut algorithms = Vec::new(); for jwk in &jwks.keys { - if let Some(ref alg) = jwk.common.key_algorithm { - // Convert JWK key_algorithm to jsonwebtoken Algorithm - let algo_str = format!("{alg:?}"); - if let Ok(algo) = algo_str.parse::() - && !algorithms.contains(&algo.0) - { - algorithms.push(algo.0); + if let Some(ref ka) = jwk.common.key_algorithm { + if let Some(algo) = signing_algorithm(ka) { + if !algorithms.contains(&algo) { + algorithms.push(algo); + } } } } @@ -221,28 +295,6 @@ async fn discover_algorithms( Ok(algorithms) } -/// Wrapper for parsing Algorithm from JWK key_algorithm string representation. -struct AlgorithmWrapper(Algorithm); - -impl std::str::FromStr for AlgorithmWrapper { - type Err = String; - - fn from_str(s: &str) -> Result { - match s { - "RS256" => Ok(AlgorithmWrapper(Algorithm::RS256)), - "RS384" => Ok(AlgorithmWrapper(Algorithm::RS384)), - "RS512" => Ok(AlgorithmWrapper(Algorithm::RS512)), - "ES256" => Ok(AlgorithmWrapper(Algorithm::ES256)), - "ES384" => Ok(AlgorithmWrapper(Algorithm::ES384)), - "PS256" => Ok(AlgorithmWrapper(Algorithm::PS256)), - "PS384" => Ok(AlgorithmWrapper(Algorithm::PS384)), - "PS512" => Ok(AlgorithmWrapper(Algorithm::PS512)), - "EdDSA" => Ok(AlgorithmWrapper(Algorithm::EdDSA)), - other => Err(format!("Unsupported JWK algorithm: {other}")), - } - } -} - /// Build the JWKS decoder for OIDC ID token validation. /// Returns an `AuthState` that owns both the decoder and the /// background JWKS refresh task handle. @@ -360,13 +412,14 @@ mod tests { } fn make_config(emails: Option>, domains: Option>) -> AuthConfig { - AuthConfig { - client_id: "test-client-id".to_string(), - issuer: "https://accounts.google.com".to_string(), - image_domains: vec!["lh3.googleusercontent.com".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()), - } + AuthConfig::new( + "test-client-id".to_string(), + "https://accounts.google.com".to_string(), + vec!["lh3.googleusercontent.com".to_string()], + emails.map(|v| v.into_iter().map(String::from).collect()), + domains.map(|v| v.into_iter().map(String::from).collect()), + ) + .unwrap() } #[test] @@ -578,28 +631,69 @@ mod tests { assert!(validate_image_domain("").is_err()); } - // ── discover_jwks_url (unit tests with mock data) ────────── + // ── AuthConfig::new() validation ─────────────────────────── #[test] - fn discover_jwks_url_rejects_http_issuer() { - let rt = tokio::runtime::Runtime::new().unwrap(); - let client = reqwest::Client::new(); - let result = rt.block_on(discover_jwks_url(&client, "http://accounts.google.com")); + fn auth_config_rejects_http_issuer() { + let result = AuthConfig::new( + "client-id".to_string(), + "http://accounts.google.com".to_string(), + vec![], + None, + None, + ); assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("OIDC issuer must use HTTPS") + assert!(result.unwrap_err().contains("OIDC issuer must use HTTPS")); + } + + #[test] + fn auth_config_rejects_malformed_issuer() { + let result = AuthConfig::new( + "client-id".to_string(), + "not a url at all".to_string(), + vec![], + None, + None, ); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Malformed")); } #[test] - fn discover_jwks_url_rejects_malformed_url() { - let rt = tokio::runtime::Runtime::new().unwrap(); - let client = reqwest::Client::new(); - let result = rt.block_on(discover_jwks_url(&client, "not a url at all")); + fn auth_config_defaults_image_domain_when_empty() { + let config = AuthConfig::new( + "client-id".to_string(), + "https://accounts.google.com".to_string(), + vec![], + None, + None, + ) + .unwrap(); + assert_eq!(config.image_domains, vec!["lh3.googleusercontent.com"]); + } + + #[test] + fn auth_config_rejects_invalid_image_domain() { + let result = AuthConfig::new( + "client-id".to_string(), + "https://accounts.google.com".to_string(), + vec!["evil.com; script-src 'unsafe-inline'".to_string()], + None, + None, + ); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Malformed")); + } + + #[test] + fn auth_config_issuer_origin() { + let config = AuthConfig::new( + "client-id".to_string(), + "https://login.microsoftonline.com/tenant/v2.0".to_string(), + vec![], + None, + None, + ) + .unwrap(); + assert_eq!(config.issuer_origin(), "https://login.microsoftonline.com"); } } diff --git a/crates/quarto-hub/src/main.rs b/crates/quarto-hub/src/main.rs index 3cd975c9..288f79c7 100644 --- a/crates/quarto-hub/src/main.rs +++ b/crates/quarto-hub/src/main.rs @@ -164,13 +164,19 @@ async fn main() -> anyhow::Result<()> { .map_err(|e| anyhow::anyhow!(e))?; // Build auth config if OIDC client ID is provided - let auth_config = args.oidc_client_id.map(|client_id| auth::AuthConfig { - client_id, - issuer: args.oidc_issuer, - image_domains: args.oidc_image_domains, - allowed_emails: args.allowed_emails, - allowed_domains: args.allowed_domains, - }); + let auth_config = args + .oidc_client_id + .map(|client_id| { + auth::AuthConfig::new( + client_id, + args.oidc_issuer, + args.oidc_image_domains, + args.allowed_emails, + args.allowed_domains, + ) + }) + .transpose() + .map_err(|e| anyhow::anyhow!(e))?; // Configure and run server let sync_interval_secs = if args.sync_interval == 0 { diff --git a/crates/quarto-hub/src/server.rs b/crates/quarto-hub/src/server.rs index 9c5a7e76..a1351170 100644 --- a/crates/quarto-hub/src/server.rs +++ b/crates/quarto-hub/src/server.rs @@ -102,37 +102,20 @@ struct UpdateDocumentRequest { /// /// The CSP is constructed dynamically from the OIDC issuer origin and /// configured image domains (for profile pictures). -fn build_csp(config: &auth::AuthConfig) -> std::result::Result { - let issuer_url = url::Url::parse(&config.issuer) - .map_err(|e| format!("Invalid OIDC issuer URL for CSP: {e}"))?; - if issuer_url.scheme() != "https" { - return Err(format!( - "OIDC issuer must use HTTPS for CSP, got '{}'", - issuer_url.scheme() - )); - } - let issuer_origin = match issuer_url.port() { - Some(port) => format!("https://{}:{}", issuer_url.host_str().unwrap_or(""), port), - None => format!("https://{}", issuer_url.host_str().unwrap_or("")), - }; - - // Validate image domains. - let image_domains: Vec<&str> = if config.image_domains.is_empty() { - vec!["lh3.googleusercontent.com"] - } else { - for domain in &config.image_domains { - auth::validate_image_domain(domain).map_err(|e| format!("CSP image domain: {e}"))?; - } - config.image_domains.iter().map(|s| s.as_str()).collect() - }; +/// +/// The issuer URL and image domains are validated at [`auth::AuthConfig`] +/// construction time, so this function cannot fail from invalid config. +fn build_csp(config: &auth::AuthConfig) -> String { + let issuer_origin = config.issuer_origin(); - let img_src = image_domains + let img_src = config + .image_domains .iter() .map(|d| format!("https://{d}")) .collect::>() .join(" "); - Ok(format!( + format!( "default-src 'self'; \ script-src 'self' {issuer_origin}; \ style-src 'self' 'unsafe-inline'; \ @@ -140,7 +123,7 @@ fn build_csp(config: &auth::AuthConfig) -> std::result::Result { img-src 'self' data: {img_src}; \ connect-src 'self' {issuer_origin}; \ frame-src {issuer_origin}" - )) + ) } /// Cookie name for the hub authentication token. @@ -761,8 +744,7 @@ async fn build_router(ctx: SharedContext) -> Result { // Add Content-Security-Policy header when auth is enabled. // Without auth there are no OIDC provider scripts to allow. if let Some(config) = ctx.auth_config() { - let csp = build_csp(config) - .map_err(|e| crate::error::Error::Server(format!("Failed to build CSP: {e}")))?; + let csp = build_csp(config); router = router.layer(SetResponseHeaderLayer::if_not_present( http::header::HeaderName::from_static("content-security-policy"), http::header::HeaderValue::from_str(&csp) @@ -1172,33 +1154,35 @@ mod tests { // ── CSP ─────────────────────────────────────────────────────── fn google_auth_config() -> auth::AuthConfig { - auth::AuthConfig { - client_id: "test-client-id".to_string(), - issuer: "https://accounts.google.com".to_string(), - image_domains: vec!["lh3.googleusercontent.com".to_string()], - allowed_emails: None, - allowed_domains: None, - } + auth::AuthConfig::new( + "test-client-id".to_string(), + "https://accounts.google.com".to_string(), + vec!["lh3.googleusercontent.com".to_string()], + None, + None, + ) + .unwrap() } #[test] fn csp_google_issuer() { let config = google_auth_config(); - let csp = build_csp(&config).unwrap(); + let csp = build_csp(&config); assert!(csp.contains("https://accounts.google.com")); assert!(csp.contains("https://lh3.googleusercontent.com")); } #[test] fn csp_custom_issuer() { - let config = auth::AuthConfig { - client_id: "test".to_string(), - issuer: "https://login.microsoftonline.com/tenant-id/v2.0".to_string(), - image_domains: vec!["graph.microsoft.com".to_string()], - allowed_emails: None, - allowed_domains: None, - }; - let csp = build_csp(&config).unwrap(); + let config = auth::AuthConfig::new( + "test".to_string(), + "https://login.microsoftonline.com/tenant-id/v2.0".to_string(), + vec!["graph.microsoft.com".to_string()], + None, + None, + ) + .unwrap(); + let csp = build_csp(&config); assert!(csp.contains("https://login.microsoftonline.com")); assert!(csp.contains("https://graph.microsoft.com")); assert!(!csp.contains("accounts.google.com")); @@ -1206,38 +1190,40 @@ mod tests { #[test] fn csp_custom_image_domains() { - let config = auth::AuthConfig { - client_id: "test".to_string(), - issuer: "https://accounts.google.com".to_string(), - image_domains: vec![ + let config = auth::AuthConfig::new( + "test".to_string(), + "https://accounts.google.com".to_string(), + vec![ "avatars.example.com".to_string(), "cdn.example.com".to_string(), ], - allowed_emails: None, - allowed_domains: None, - }; - let csp = build_csp(&config).unwrap(); + None, + None, + ) + .unwrap(); + let csp = build_csp(&config); assert!(csp.contains("https://avatars.example.com")); assert!(csp.contains("https://cdn.example.com")); } #[test] fn csp_default_image_domain_when_empty() { - let config = auth::AuthConfig { - client_id: "test".to_string(), - issuer: "https://accounts.google.com".to_string(), - image_domains: vec![], - allowed_emails: None, - allowed_domains: None, - }; - let csp = build_csp(&config).unwrap(); + let config = auth::AuthConfig::new( + "test".to_string(), + "https://accounts.google.com".to_string(), + vec![], + None, + None, + ) + .unwrap(); + let csp = build_csp(&config); assert!(csp.contains("https://lh3.googleusercontent.com")); } #[test] fn csp_disallows_arbitrary_websocket() { let config = google_auth_config(); - let csp = build_csp(&config).unwrap(); + let csp = build_csp(&config); let connect_src = csp.split(';').find(|d| d.contains("connect-src")).unwrap(); let has_bare_ws = connect_src .split_whitespace() @@ -1251,7 +1237,7 @@ mod tests { #[test] fn csp_blocks_inline_scripts() { let config = google_auth_config(); - let csp = build_csp(&config).unwrap(); + let csp = build_csp(&config); let script_src = csp.split(';').find(|d| d.contains("script-src")).unwrap(); assert!(!script_src.contains("unsafe-inline")); } @@ -1259,34 +1245,10 @@ mod tests { #[test] fn csp_has_default_self() { let config = google_auth_config(); - let csp = build_csp(&config).unwrap(); + let csp = build_csp(&config); assert!(csp.contains("default-src 'self'")); } - #[test] - fn csp_rejects_non_https_issuer() { - let config = auth::AuthConfig { - client_id: "test".to_string(), - issuer: "http://insecure.example.com".to_string(), - image_domains: vec![], - allowed_emails: None, - allowed_domains: None, - }; - assert!(build_csp(&config).is_err()); - } - - #[test] - fn csp_rejects_malformed_issuer() { - let config = auth::AuthConfig { - client_id: "test".to_string(), - issuer: "not a url".to_string(), - image_domains: vec![], - allowed_emails: None, - allowed_domains: None, - }; - assert!(build_csp(&config).is_err()); - } - // ── AuthCallbackForm ────────────────────────────────────────── #[test] diff --git a/crates/quarto/src/commands/hub.rs b/crates/quarto/src/commands/hub.rs index 6b2e541d..211fc0ec 100644 --- a/crates/quarto/src/commands/hub.rs +++ b/crates/quarto/src/commands/hub.rs @@ -105,13 +105,19 @@ async fn run_hub(args: HubArgs) -> Result<()> { .map_err(|e| anyhow::anyhow!(e))?; // Build auth config if OIDC client ID is provided - let auth_config = args.oidc_client_id.map(|client_id| auth::AuthConfig { - client_id, - issuer: args.oidc_issuer, - image_domains: args.oidc_image_domains, - allowed_emails: args.allowed_emails, - allowed_domains: args.allowed_domains, - }); + let auth_config = args + .oidc_client_id + .map(|client_id| { + auth::AuthConfig::new( + client_id, + args.oidc_issuer, + args.oidc_image_domains, + args.allowed_emails, + args.allowed_domains, + ) + }) + .transpose() + .map_err(|e| anyhow::anyhow!(e))?; // Configure and run server let sync_interval_secs = if args.sync_interval == 0 { From 51c706596cdb3f7b951019c70e6e823fa6919748 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:26:34 +0000 Subject: [PATCH 5/6] Sec: gate auth/callback for Google only --- crates/quarto-hub/src/auth.rs | 12 ++++++++++++ crates/quarto-hub/src/main.rs | 2 ++ crates/quarto-hub/src/server.rs | 7 ++++++- crates/quarto/src/main.rs | 2 ++ 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/quarto-hub/src/auth.rs b/crates/quarto-hub/src/auth.rs index ef6b20f3..6187ac6d 100644 --- a/crates/quarto-hub/src/auth.rs +++ b/crates/quarto-hub/src/auth.rs @@ -75,6 +75,13 @@ impl AuthConfig { }) } + /// Whether the configured issuer is Google (`https://accounts.google.com`). + /// + /// Used to gate Google-specific endpoints like `/auth/callback`. + pub fn is_google_issuer(&self) -> bool { + self.issuer.trim_end_matches('/') == "https://accounts.google.com" + } + /// Extract the CSP origin (`scheme://host[:port]`) from the validated issuer URL. /// /// Panics if the issuer is not a valid URL, which cannot happen if @@ -112,6 +119,11 @@ pub struct OidcClaims { /// user passes if they match ANY list (OR, not AND). This allows /// combining `--allowed-domains=company.com` with /// `--allowed-emails=contractor@gmail.com`. +/// +/// **Important**: the `email_verified` claim is trusted as reported by the +/// OIDC provider. Some providers set it to `true` without rigorous +/// verification. When using `--allowed-domains`, ensure your provider +/// actually verifies email ownership before issuing tokens. pub fn check_allowlists(claims: &OidcClaims, config: &AuthConfig) -> Result<(), StatusCode> { if !claims.email_verified { return Err(StatusCode::UNAUTHORIZED); diff --git a/crates/quarto-hub/src/main.rs b/crates/quarto-hub/src/main.rs index 288f79c7..df32e687 100644 --- a/crates/quarto-hub/src/main.rs +++ b/crates/quarto-hub/src/main.rs @@ -96,6 +96,8 @@ struct Args { allowed_emails: Option>, /// Allowed email domains (comma-separated). + /// Note: relies on the OIDC provider's `email_verified` claim. + /// Ensure your provider verifies email ownership before trusting domain-based access. #[arg(long, env = "QUARTO_HUB_ALLOWED_DOMAINS", value_delimiter = ',')] allowed_domains: Option>, } diff --git a/crates/quarto-hub/src/server.rs b/crates/quarto-hub/src/server.rs index a1351170..c3b2c924 100644 --- a/crates/quarto-hub/src/server.rs +++ b/crates/quarto-hub/src/server.rs @@ -729,7 +729,6 @@ async fn build_router(ctx: SharedContext) -> Result { get(get_document).put(update_document), ) // Auth endpoints - .route("/auth/callback", post(auth_callback)) .route("/auth/me", get(auth_me)) .route("/auth/logout", post(auth_logout)) .route("/auth/refresh", post(auth_refresh)) @@ -741,6 +740,12 @@ async fn build_router(ctx: SharedContext) -> Result { .fallback(not_found) .layer(TraceLayer::new_for_http().make_span_with(RedactedMakeSpan)); + // Google-specific redirect callback: only registered when the issuer is Google. + // Non-Google OIDC frontends should use POST /auth/refresh instead. + if ctx.auth_config().is_some_and(|c| c.is_google_issuer()) { + router = router.route("/auth/callback", post(auth_callback)); + } + // Add Content-Security-Policy header when auth is enabled. // Without auth there are no OIDC provider scripts to allow. if let Some(config) = ctx.auth_config() { diff --git a/crates/quarto/src/main.rs b/crates/quarto/src/main.rs index 90b3fe1a..0346a748 100644 --- a/crates/quarto/src/main.rs +++ b/crates/quarto/src/main.rs @@ -404,6 +404,8 @@ enum Commands { allowed_emails: Option>, /// Allowed email domains (comma-separated). + /// Note: relies on the OIDC provider's `email_verified` claim. + /// Ensure your provider verifies email ownership before trusting domain-based access. #[arg(long, env = "QUARTO_HUB_ALLOWED_DOMAINS", value_delimiter = ',')] allowed_domains: Option>, }, From 0cd3d1a666290ce771b7b35ca47a307c9aaaa34b Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Wed, 11 Mar 2026 18:12:18 +0000 Subject: [PATCH 6/6] Test key jkws validation logic --- crates/quarto-hub/src/auth.rs | 273 +++++++++++++++++++++++++++++++++- 1 file changed, 266 insertions(+), 7 deletions(-) diff --git a/crates/quarto-hub/src/auth.rs b/crates/quarto-hub/src/auth.rs index 6187ac6d..f1ede9e9 100644 --- a/crates/quarto-hub/src/auth.rs +++ b/crates/quarto-hub/src/auth.rs @@ -220,17 +220,28 @@ pub async fn discover_jwks_url( format!("Failed to parse OIDC discovery document from {discovery_url}: {e}") })?; - // Validate that the discovery document's issuer matches what we configured - // (prevents issuer spoofing). - if doc.issuer.trim_end_matches('/') != issuer.trim_end_matches('/') { + validate_discovery_document(&doc, issuer, &discovery_url) +} + +/// Validate an OIDC discovery document against the configured issuer. +/// +/// - The document's `issuer` field must match the configured issuer (prevents spoofing). +/// - The `jwks_uri` must be a well-formed HTTPS URL. +/// +/// Returns the `jwks_uri` on success. +fn validate_discovery_document( + doc: &OidcDiscoveryDocument, + configured_issuer: &str, + discovery_url: &str, +) -> Result> { + if doc.issuer.trim_end_matches('/') != configured_issuer.trim_end_matches('/') { return Err(format!( "OIDC issuer mismatch: configured '{}' but discovery document reports '{}'", - issuer, doc.issuer + configured_issuer, doc.issuer ) .into()); } - // Validate jwks_uri is HTTPS. let jwks_url = url::Url::parse(&doc.jwks_uri) .map_err(|e| format!("Malformed JWKS URI '{}': {e}", doc.jwks_uri))?; if jwks_url.scheme() != "https" { @@ -242,7 +253,7 @@ pub async fn discover_jwks_url( .into()); } - Ok(doc.jwks_uri) + Ok(doc.jwks_uri.clone()) } /// Convert a JWK key algorithm to a JWT signing algorithm. @@ -283,6 +294,18 @@ async fn discover_algorithms( .await .map_err(|e| format!("Failed to parse JWKS from {jwks_url}: {e}"))?; + let algorithms = extract_algorithms_from_jwks(&jwks, jwks_url); + Ok(algorithms) +} + +/// Extract signing algorithms from a JWKS key set. +/// +/// Returns a deduplicated list of signing algorithms found in the keys' `alg` fields. +/// Falls back to `[RS256]` if no keys declare a signing algorithm. +fn extract_algorithms_from_jwks( + jwks: &jsonwebtoken::jwk::JwkSet, + jwks_url: &str, +) -> Vec { let mut algorithms = Vec::new(); for jwk in &jwks.keys { if let Some(ref ka) = jwk.common.key_algorithm { @@ -304,7 +327,7 @@ async fn discover_algorithms( ); } - Ok(algorithms) + algorithms } /// Build the JWKS decoder for OIDC ID token validation. @@ -708,4 +731,240 @@ mod tests { .unwrap(); assert_eq!(config.issuer_origin(), "https://login.microsoftonline.com"); } + + #[test] + fn auth_config_issuer_origin_with_port() { + let config = AuthConfig::new( + "client-id".to_string(), + "https://auth.example.com:8443/realm".to_string(), + vec![], + None, + None, + ) + .unwrap(); + assert_eq!(config.issuer_origin(), "https://auth.example.com:8443"); + } + + // ── is_google_issuer ────────────────────────────────────────── + + #[test] + fn is_google_issuer_true() { + let config = make_config(None, None); + assert!(config.is_google_issuer()); + } + + #[test] + fn is_google_issuer_with_trailing_slash() { + let config = AuthConfig::new( + "client-id".to_string(), + "https://accounts.google.com/".to_string(), + vec![], + None, + None, + ) + .unwrap(); + assert!(config.is_google_issuer()); + } + + #[test] + fn is_google_issuer_false_for_azure() { + let config = AuthConfig::new( + "client-id".to_string(), + "https://login.microsoftonline.com/tenant/v2.0".to_string(), + vec![], + None, + None, + ) + .unwrap(); + assert!(!config.is_google_issuer()); + } + + // ── signing_algorithm ───────────────────────────────────────── + + #[test] + fn signing_algorithm_maps_common_algorithms() { + use jsonwebtoken::jwk::KeyAlgorithm; + assert_eq!( + signing_algorithm(&KeyAlgorithm::RS256), + Some(Algorithm::RS256) + ); + assert_eq!( + signing_algorithm(&KeyAlgorithm::ES256), + Some(Algorithm::ES256) + ); + assert_eq!( + signing_algorithm(&KeyAlgorithm::EdDSA), + Some(Algorithm::EdDSA) + ); + } + + #[test] + fn signing_algorithm_rejects_encryption_algorithms() { + use jsonwebtoken::jwk::KeyAlgorithm; + // RSA1_5 and RSA-OAEP are key encryption algorithms, not signing. + assert_eq!(signing_algorithm(&KeyAlgorithm::RSA1_5), None); + assert_eq!(signing_algorithm(&KeyAlgorithm::RSA_OAEP), None); + } + + // ── validate_discovery_document ─────────────────────────────── + + fn make_discovery_doc(issuer: &str, jwks_uri: &str) -> OidcDiscoveryDocument { + OidcDiscoveryDocument { + issuer: issuer.to_string(), + jwks_uri: jwks_uri.to_string(), + } + } + + #[test] + fn discovery_doc_valid() { + let doc = make_discovery_doc( + "https://accounts.google.com", + "https://www.googleapis.com/oauth2/v3/certs", + ); + let result = + validate_discovery_document(&doc, "https://accounts.google.com", "https://ignored"); + assert_eq!( + result.unwrap(), + "https://www.googleapis.com/oauth2/v3/certs" + ); + } + + #[test] + fn discovery_doc_issuer_mismatch() { + let doc = make_discovery_doc("https://evil.com", "https://evil.com/.well-known/jwks.json"); + let result = + validate_discovery_document(&doc, "https://accounts.google.com", "https://ignored"); + let err = result.unwrap_err().to_string(); + assert!(err.contains("issuer mismatch"), "got: {err}"); + assert!(err.contains("evil.com")); + } + + #[test] + fn discovery_doc_issuer_trailing_slash_normalization() { + let doc = make_discovery_doc( + "https://accounts.google.com/", + "https://www.googleapis.com/oauth2/v3/certs", + ); + // Configured without trailing slash, doc has trailing slash — should still match. + let result = + validate_discovery_document(&doc, "https://accounts.google.com", "https://ignored"); + assert!(result.is_ok()); + } + + #[test] + fn discovery_doc_rejects_http_jwks_uri() { + let doc = make_discovery_doc( + "https://accounts.google.com", + "http://www.googleapis.com/oauth2/v3/certs", + ); + let result = + validate_discovery_document(&doc, "https://accounts.google.com", "https://ignored"); + let err = result.unwrap_err().to_string(); + assert!(err.contains("JWKS URI must use HTTPS"), "got: {err}"); + } + + #[test] + fn discovery_doc_rejects_malformed_jwks_uri() { + let doc = make_discovery_doc("https://accounts.google.com", "not a url"); + let result = + validate_discovery_document(&doc, "https://accounts.google.com", "https://ignored"); + let err = result.unwrap_err().to_string(); + assert!(err.contains("Malformed JWKS URI"), "got: {err}"); + } + + // ── extract_algorithms_from_jwks ────────────────────────────── + + #[test] + fn extract_algorithms_finds_rs256() { + let jwks: jsonwebtoken::jwk::JwkSet = serde_json::from_value(serde_json::json!({ + "keys": [{ + "kty": "RSA", + "alg": "RS256", + "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", + "e": "AQAB", + "use": "sig" + }] + })) + .unwrap(); + let algos = extract_algorithms_from_jwks(&jwks, "https://example.com/jwks"); + assert_eq!(algos, vec![Algorithm::RS256]); + } + + #[test] + fn extract_algorithms_deduplicates() { + let jwks: jsonwebtoken::jwk::JwkSet = serde_json::from_value(serde_json::json!({ + "keys": [ + { + "kty": "RSA", + "alg": "RS256", + "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", + "e": "AQAB", + "use": "sig", + "kid": "key1" + }, + { + "kty": "RSA", + "alg": "RS256", + "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", + "e": "AQAB", + "use": "sig", + "kid": "key2" + } + ] + })) + .unwrap(); + let algos = extract_algorithms_from_jwks(&jwks, "https://example.com/jwks"); + assert_eq!(algos, vec![Algorithm::RS256]); + } + + #[test] + fn extract_algorithms_multiple_different() { + let jwks: jsonwebtoken::jwk::JwkSet = serde_json::from_value(serde_json::json!({ + "keys": [ + { + "kty": "RSA", + "alg": "RS256", + "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", + "e": "AQAB", + "use": "sig", + "kid": "rsa-key" + }, + { + "kty": "EC", + "alg": "ES256", + "crv": "P-256", + "x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU", + "y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0", + "use": "sig", + "kid": "ec-key" + } + ] + })) + .unwrap(); + let algos = extract_algorithms_from_jwks(&jwks, "https://example.com/jwks"); + assert_eq!(algos, vec![Algorithm::RS256, Algorithm::ES256]); + } + + #[test] + fn extract_algorithms_falls_back_to_rs256_when_no_alg() { + let jwks: jsonwebtoken::jwk::JwkSet = serde_json::from_value(serde_json::json!({ + "keys": [{ + "kty": "RSA", + "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", + "e": "AQAB", + "use": "sig" + }] + })) + .unwrap(); + let algos = extract_algorithms_from_jwks(&jwks, "https://example.com/jwks"); + assert_eq!(algos, vec![Algorithm::RS256]); + } + + #[test] + fn extract_algorithms_empty_keyset_falls_back_to_rs256() { + let jwks: jsonwebtoken::jwk::JwkSet = + serde_json::from_value(serde_json::json!({ "keys": [] })).unwrap(); + let algos = extract_algorithms_from_jwks(&jwks, "https://example.com/jwks"); + assert_eq!(algos, vec![Algorithm::RS256]); + } }