diff --git a/docs/proxy-auth.md b/docs/proxy-auth.md new file mode 100644 index 0000000000..fdd915c698 --- /dev/null +++ b/docs/proxy-auth.md @@ -0,0 +1,120 @@ +# Signing in through an identity-aware proxy + +`codeg-server` normally asks every browser for its access token. When the +server sits behind an identity-aware proxy that already signs users in — such +as [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/policies/access/) +— the token is a second login on top of the proxy's. With +`CODEG_AUTH=proxy-jwt` the server also lets a request in when it carries the +proxy's signed assertion (a JWT), so the browser needs no token. + +The token keeps working in this mode. Clients that do not pass through the +proxy — `codeg-cli --url`, scripts — still use it. + +## Why it is safe + +The server does not trust the header because it is there: anything that can +reach the listener can set a header. It checks the assertion's signature +against the keys the proxy publishes (JWKS), and its issuer, audience and +expiry. A process that can reach the port cannot forge that signature. + +Everything fails closed: + +- `CODEG_AUTH=proxy-jwt` with a setting missing, a misspelled mode, or a proxy + setting without `CODEG_AUTH=proxy-jwt` stops the server at startup (exit + code 2) with the variable named. +- A request whose assertion is missing, malformed, expired, issued for another + audience or by another issuer, signed with an unknown key or an algorithm + other than RS256 / ES256 gets a 401. So does every request without the token + while no keys could be fetched. +- A request with a valid assertion that a page on another origin sent gets a + 403 (see below). + +### Who gets in + +Codeg does not choose users in this mode. It admits every request the proxy +vouches for with a valid assertion for this application, including requests +made with a service token the application's policy allows. The proxy's policy +is the user list: keep it to the people who should use this server, such as +your own address or one group, and never `Everyone`. The audience check ties an +assertion to this application, so a sign-in to another application of the same +proxy does not get in. + +### Requests from other origins + +The assertion rides on the proxy's session cookie, and the browser attaches +that cookie to requests that pages on other origins make as well (Cloudflare +sets its application cookie `SameSite=None` unless you change it). Without a +further check, any page the signed-in user opens could use Codeg's WebSockets +or upload endpoints in their name. So a request admitted by an assertion must +come from Codeg's own page: the server refuses it when the browser marks it as +sent from another origin (`Sec-Fetch-Site`), or, in a browser that does not +send that header, when its `Origin` names a host other than the one the +request was sent to (`Host`, or `X-Forwarded-Host` from a proxy that rewrites +`Host`). The token is not affected: a page on another origin cannot send it. + +`SameSite` does not make this check optional. Browsers apply it by site, which +means the scheme and the registrable domain (`https://example.com`), not by +origin. A page on `wiki.example.com` is same-site with Codeg on +`codeg.example.com`, so its requests carry the cookie even when the cookie is +`Lax` or `Strict`. +Setting `Lax` still keeps the cookie off requests from unrelated sites, but +when anything else is served on a subdomain of the same domain, this check is +what turns its pages away. + +Chrome sends no `Sec-Fetch-Site` on a WebSocket handshake, so for the event +socket the `Origin` always decides. A proxy between the browser and Codeg must +therefore pass on the browser's `Host`, or put it in `X-Forwarded-Host`. +Cloudflare Tunnel does: it keeps the `Host` unless `httpHostHeader` is set, and +then moves the original to `X-Forwarded-Host`. Otherwise the socket is refused +with a 403, and the server's warning names the `Origin` and the hosts it saw. + +## Configuration + +| Variable | Value | +| --- | --- | +| `CODEG_AUTH` | `proxy-jwt` turns the mode on. Unset or `token`: token only, as before. | +| `CODEG_PROXY_AUTH_HEADER` | The request header holding the assertion. | +| `CODEG_PROXY_AUTH_JWKS_URL` | Where the proxy publishes its signing keys. `https` (plain `http` only for a loopback host). | +| `CODEG_PROXY_AUTH_ISSUER` | The exact `iss` the assertion must carry. | +| `CODEG_PROXY_AUTH_AUDIENCE` | A value the assertion's `aud` must hold. | + +The keys are fetched at startup, kept for an hour, and fetched again early when +an assertion names a key id the server has not seen (a key rotation) — at most +once every 30 seconds. Past the hour, the keys held keep answering while a fresh +copy is fetched beside the request. If a fetch fails, the keys already held stay +in use. `exp` and `nbf` allow 60 seconds of clock skew. + +### Cloudflare Access + +With `` your Zero Trust team name and the application's **Application +Audience (AUD) Tag** from its overview page: + +```bash +CODEG_AUTH=proxy-jwt +CODEG_PROXY_AUTH_HEADER=Cf-Access-Jwt-Assertion +CODEG_PROXY_AUTH_JWKS_URL=https://.cloudflareaccess.com/cdn-cgi/access/certs +CODEG_PROXY_AUTH_ISSUER=https://.cloudflareaccess.com +CODEG_PROXY_AUTH_AUDIENCE= +``` + +Bind the server to loopback (`CODEG_HOST=127.0.0.1`) and reach it only through +the tunnel, so every browser request passes Access. In the application's +cookie settings, setting **SameSite Attribute** to `Lax` keeps the cookie off +requests from unrelated sites, apart from top-level `GET` navigations such as a +link the user follows into Codeg. It does not hold back pages on other +subdomains of the same domain; see +[Requests from other origins](#requests-from-other-origins). + +## In the browser + +Opened through the proxy, the web app finds that the server admits it without a +token and skips the token prompt. When the proxy's session expires, the app +shows **Sign-in expired** with **Reload**: reloading lets the proxy run its +login again, and the app comes back where it was. API calls send +`X-Requested-With: XMLHttpRequest`, which makes Cloudflare Access answer an +expired session with 401 rather than a redirect the browser cannot follow. + +## Scope + +This is a setting of the standalone `codeg-server`. The desktop app's web +service stays token-only. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index eb5ef1aed1..69e2f7c797 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1214,6 +1214,7 @@ dependencies = [ "rand 0.8.5", "regex", "reqwest 0.12.28", + "ring", "rusqlite", "sea-orm", "sea-orm-migration", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e4e8395cfe..e35fda0567 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -252,6 +252,9 @@ sha2 = "0.10" # HMAC-SHA256 over a forge webhook's body (GitHub / Gitea signatures) with a # constant-time check. Already in the lock tree as a transitive dependency. hmac = "0.12" +# RS256 / ES256 signature checks for the opt-in proxy-JWT authentication +# (`web/proxy_auth.rs`). Already in the lock tree through rustls. +ring = "0.17" aes-gcm = { version = "0.10", features = ["stream"] } argon2 = "0.5" tempfile = "3" diff --git a/src-tauri/src/bin/codeg_server.rs b/src-tauri/src/bin/codeg_server.rs index 319da5c036..edb60d63ba 100644 --- a/src-tauri/src/bin/codeg_server.rs +++ b/src-tauri/src/bin/codeg_server.rs @@ -140,6 +140,17 @@ fn main() -> ExitCode { return ExitCode::from(2); } + // The opt-in proxy-JWT authentication is all-or-nothing: a half-set or + // misspelled configuration aborts here rather than serving with an + // authentication the operator did not mean (see `web::proxy_auth`). + let proxy_auth_config = match codeg_lib::web::proxy_auth::ProxyAuthConfig::from_env() { + Ok(config) => config, + Err(err) => { + tracing::error!("[AUTH][FATAL] {err}; aborting startup."); + return ExitCode::from(2); + } + }; + // Before anything opens the database (see `runtime_lock`): every server // shares the data directory, and waits while a `codeg-cli --standalone` // server has it — which itself takes it alone, or leaves untouched. @@ -205,10 +216,12 @@ fn main() -> ExitCode { .enable_all() .build() .expect("Failed to build tokio runtime") - .block_on(async_main()) + .block_on(async_main(proxy_auth_config)) } -async fn async_main() -> ExitCode { +async fn async_main( + proxy_auth_config: Option, +) -> ExitCode { // Sweep stale ACP binary cache trash (rename-aside fallback artifacts). // Detached OS thread: cannot block startup, panics are caught and dropped, // errors are silenced, no subprocesses spawned. @@ -747,10 +760,33 @@ async fn async_main() -> ExitCode { codeg_lib::web::handlers::files::purge_upload_staging().await; // Build router + let proxy_auth = match proxy_auth_config { + None => None, + Some(config) => match codeg_lib::web::proxy_auth::ProxyJwtAuth::new(config) { + Ok(auth) => { + let config = auth.config(); + tracing::info!( + "[AUTH] proxy-jwt on: header {}, issuer {}, audience {}, keys from {}", + config.header, + config.issuer, + config.audience, + config.jwks_url + ); + auth.prime().await; + Some(Arc::new(auth)) + } + Err(err) => { + tracing::error!("[AUTH][FATAL] proxy-jwt key client: {err}; aborting startup."); + return ExitCode::from(1); + } + }, + }; + let shutdown_signal = state.web_server_state.shutdown_signal(); - let router = codeg_lib::web::router::build_router( + let router = codeg_lib::web::router::build_router_with_proxy_auth( state.clone(), token.clone(), + proxy_auth, static_dir, shutdown_signal, ); diff --git a/src-tauri/src/web/auth.rs b/src-tauri/src/web/auth.rs index 4e4d9630f1..c81cfc517c 100644 --- a/src-tauri/src/web/auth.rs +++ b/src-tauri/src/web/auth.rs @@ -1,13 +1,30 @@ use axum::{ extract::Request, - http::StatusCode, + http::{header, HeaderMap, StatusCode}, middleware::Next, response::{IntoResponse, Response}, }; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use std::sync::Arc; + +use super::proxy_auth::{ProxyJwtAuth, Rejection}; pub const WS_EVENT_PROTOCOL: &str = "codeg-events"; const WS_TOKEN_PROTOCOL_PREFIX: &str = "codeg-token."; +// Sent with every 401 this middleware returns. The web client reads it to +// tell Codeg's own rejection (re-enter the token) from a 401 sent by an +// identity-aware proxy in front of Codeg whose session expired (reload, so the +// proxy can run its login flow); see `src/lib/transport/web-auth.ts`. +const CODEG_CHALLENGE: &str = r#"Bearer realm="codeg""#; + +fn unauthorized(message: &'static str) -> Response { + ( + StatusCode::UNAUTHORIZED, + [(header::WWW_AUTHENTICATE, CODEG_CHALLENGE)], + message, + ) + .into_response() +} fn token_from_ws_protocols(value: &str) -> Option { value @@ -18,17 +35,11 @@ fn token_from_ws_protocols(value: &str) -> Option { .and_then(|bytes| String::from_utf8(bytes).ok()) } -pub async fn require_token(request: Request, next: Next, token: String) -> Response { - // Fail closed on a misconfigured empty token: otherwise `Bearer ` (an empty - // bearer value) would match it and silently disable authentication. - if token.is_empty() { - return (StatusCode::UNAUTHORIZED, "Server token is not configured").into_response(); - } - +fn carries_token(request: &Request, token: &str) -> bool { if let Some(auth_header) = request.headers().get("authorization") { if let Ok(auth_str) = auth_header.to_str() { if auth_str.strip_prefix("Bearer ").is_some_and(|t| t == token) { - return next.run(request).await; + return true; } } } @@ -36,12 +47,116 @@ pub async fn require_token(request: Request, next: Next, token: String) -> Respo if let Some(protocol_header) = request.headers().get("sec-websocket-protocol") { if let Ok(protocols) = protocol_header.to_str() { if token_from_ws_protocols(protocols).is_some_and(|t| t == token) { - return next.run(request).await; + return true; } } } - (StatusCode::UNAUTHORIZED, "Invalid or missing token").into_response() + false +} + +pub async fn require_token(request: Request, next: Next, token: String) -> Response { + // Fail closed on a misconfigured empty token: otherwise `Bearer ` (an empty + // bearer value) would match it and silently disable authentication. + if token.is_empty() { + return unauthorized("Server token is not configured"); + } + if carries_token(&request, &token) { + return next.run(request).await; + } + unauthorized("Invalid or missing token") +} + +/// How the web API authenticates a request: the access token always, and with +/// `CODEG_AUTH=proxy-jwt` also a proxy's verified assertion (see +/// `proxy_auth`). The token keeps working in that mode for clients that do +/// not pass through the proxy (`codeg-cli --url`, scripts). +#[derive(Clone)] +pub struct WebAuth { + pub token: String, + pub proxy: Option>, +} + +/// Why a request the proxy's assertion would admit was not sent by Codeg's own +/// page, or `None` when it was. +/// +/// The assertion rides on the proxy's session cookie, and the browser attaches +/// that cookie to requests that pages on other origins make as well: +/// Cloudflare sets its application cookie `SameSite=None` unless the operator +/// changes it, and even `Lax` or `Strict` lets a sibling subdomain's page send +/// it, because `SameSite` compares sites, not origins. Such a page could then +/// open the event or browser-tunnel WebSocket, or post a multipart upload +/// (neither needs a CORS preflight), as the signed-in user. The token cannot +/// travel that way, so only the assertion needs this check. +/// +/// `Sec-Fetch-Site` is set by the browser and a page cannot change it: +/// `same-origin` is Codeg's own page and `none` an address the user typed; +/// `same-site` (a sibling subdomain) and `cross-site` are refused. A browser +/// that sends no Fetch Metadata still sends `Origin` on every WebSocket +/// handshake and every cross-origin POST, so when present it must name the host +/// the request was addressed to: `Host`, or a proxy's `X-Forwarded-Host` — a +/// page cannot set either on a request to another origin. A request with +/// neither header is not a WebSocket or POST from another origin's page: it +/// comes from a client that is not a browser, or is a cross-origin GET from an +/// old browser, whose answer that page cannot read (CORS) and which changes +/// nothing here. +fn cross_origin_initiator(headers: &HeaderMap) -> Option { + if let Some(site) = headers.get("sec-fetch-site") { + let site = site.to_str().unwrap_or_default().trim(); + return (!matches!(site, "same-origin" | "none")) + .then(|| format!("Sec-Fetch-Site: {site}")); + } + let origin = headers.get(header::ORIGIN)?; + let origin = origin.to_str().unwrap_or_default().trim(); + let addressed = reqwest::Url::parse(origin) + .ok() + .filter(|url| matches!(url.scheme(), "http" | "https")) + .and_then(|url| { + let host = url.host_str()?.to_ascii_lowercase(); + Some(match url.port() { + Some(port) => format!("{host}:{port}"), + None => host, + }) + }); + let hosts: Vec = headers + .get_all(header::HOST) + .iter() + .chain(headers.get_all("x-forwarded-host").iter()) + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .map(|host| host.trim().to_ascii_lowercase()) + .collect(); + if addressed.is_some_and(|addressed| hosts.contains(&addressed)) { + return None; + } + // Both, so a proxy that rewrites `Host` without `X-Forwarded-Host` shows. + Some(format!("Origin: {origin}; sent to: {}", hosts.join(", "))) +} + +pub async fn require_auth(request: Request, next: Next, auth: WebAuth) -> Response { + let Some(proxy) = auth.proxy.as_ref() else { + return require_token(request, next, auth.token).await; + }; + if !auth.token.is_empty() && carries_token(&request, &auth.token) { + return next.run(request).await; + } + match proxy.authenticate(request.headers()).await { + Ok(()) => match cross_origin_initiator(request.headers()) { + None => next.run(request).await, + Some(initiator) => { + tracing::warn!( + "[AUTH] proxy-jwt: refused a request sent by a page on another origin ({initiator})" + ); + (StatusCode::FORBIDDEN, "Cross-origin request refused").into_response() + } + }, + Err(Rejection::Missing) => unauthorized("Invalid or missing token"), + Err(reason) => { + // The reason only: the assertion is a bearer credential. + tracing::warn!("[AUTH] proxy-jwt assertion refused: {reason}"); + unauthorized("Invalid or missing token") + } + } } #[cfg(test)] @@ -63,4 +178,299 @@ mod tests { fn ignores_invalid_ws_protocol_token() { assert!(token_from_ws_protocols("codeg-events, codeg-token.not-valid-@@@@").is_none()); } + + fn guarded(server_token: &str) -> axum_test::TestServer { + let server_token = server_token.to_string(); + let app = axum::Router::new() + .route("/api/health", axum::routing::post(|| async { "ok" })) + .layer(axum::middleware::from_fn(move |req, next| { + require_token(req, next, server_token.clone()) + })); + axum_test::TestServer::new(app).unwrap() + } + + // The web client tells Codeg's own 401 from one sent by a proxy in front + // of it (whose session expired) by this challenge; see `web-auth.ts`. + #[tokio::test] + async fn every_rejection_carries_the_codeg_challenge() { + let server = guarded("tok"); + + let missing = server.post("/api/health").await; + missing.assert_status_unauthorized(); + assert_eq!(missing.header("www-authenticate"), CODEG_CHALLENGE); + + let wrong = server + .post("/api/health") + .authorization_bearer("nope") + .await; + wrong.assert_status_unauthorized(); + assert_eq!(wrong.header("www-authenticate"), CODEG_CHALLENGE); + + let unconfigured = guarded("").post("/api/health").await; + unconfigured.assert_status_unauthorized(); + assert_eq!(unconfigured.header("www-authenticate"), CODEG_CHALLENGE); + } + + fn guarded_with(auth: WebAuth) -> axum_test::TestServer { + let app = axum::Router::new() + .route("/api/health", axum::routing::post(|| async { "ok" })) + .layer(axum::middleware::from_fn(move |req, next| { + require_auth(req, next, auth.clone()) + })); + axum_test::TestServer::new(app).unwrap() + } + + use crate::web::proxy_auth::testing::{TestIssuer, HEADER}; + + #[tokio::test] + async fn proxy_mode_admits_a_verified_assertion_without_the_token() { + let issuer = TestIssuer::new(); + let server = guarded_with(WebAuth { + token: "tok".into(), + proxy: Some(Arc::new(issuer.auth().await)), + }); + server + .post("/api/health") + .add_header(HEADER, issuer.assertion()) + .await + .assert_status_ok(); + } + + #[tokio::test] + async fn proxy_mode_still_admits_the_token() { + let issuer = TestIssuer::new(); + let server = guarded_with(WebAuth { + token: "tok".into(), + proxy: Some(Arc::new(issuer.auth().await)), + }); + server + .post("/api/health") + .authorization_bearer("tok") + .await + .assert_status_ok(); + } + + #[tokio::test] + async fn proxy_mode_refuses_a_missing_or_bad_assertion_with_the_codeg_challenge() { + let issuer = TestIssuer::new(); + let server = guarded_with(WebAuth { + token: "tok".into(), + proxy: Some(Arc::new(issuer.auth().await)), + }); + + let missing = server.post("/api/health").await; + missing.assert_status_unauthorized(); + assert_eq!(missing.header("www-authenticate"), CODEG_CHALLENGE); + + let other_app = server + .post("/api/health") + .add_header(HEADER, issuer.assertion_for("another-app")) + .await; + other_app.assert_status_unauthorized(); + assert_eq!(other_app.header("www-authenticate"), CODEG_CHALLENGE); + + let forged = server + .post("/api/health") + .add_header(HEADER, TestIssuer::new().assertion()) + .await; + forged.assert_status_unauthorized(); + + let wrong_token = server + .post("/api/health") + .authorization_bearer("nope") + .await; + wrong_token.assert_status_unauthorized(); + } + + // Token mode must not grow a second way in: an assertion that would pass + // in proxy mode is ignored. + #[tokio::test] + async fn token_mode_ignores_a_valid_assertion() { + let issuer = TestIssuer::new(); + let server = guarded_with(WebAuth { + token: "tok".into(), + proxy: None, + }); + let res = server + .post("/api/health") + .add_header(HEADER, issuer.assertion()) + .await; + res.assert_status_unauthorized(); + assert_eq!(res.header("www-authenticate"), CODEG_CHALLENGE); + } + + #[tokio::test] + async fn proxy_mode_with_an_empty_token_accepts_no_empty_bearer() { + let issuer = TestIssuer::new(); + let server = guarded_with(WebAuth { + token: String::new(), + proxy: Some(Arc::new(issuer.auth().await)), + }); + server + .post("/api/health") + .add_header("authorization", "Bearer ") + .await + .assert_status_unauthorized(); + } + + async fn proxy_server(issuer: &TestIssuer) -> axum_test::TestServer { + let auth = WebAuth { + token: "tok".into(), + proxy: Some(Arc::new(issuer.auth().await)), + }; + let app = axum::Router::new() + .route("/api/health", axum::routing::post(|| async { "ok" })) + .route("/ws/events", axum::routing::get(|| async { "upgraded" })) + .layer(axum::middleware::from_fn(move |req, next| { + require_auth(req, next, auth.clone()) + })); + axum_test::TestServer::new(app).unwrap() + } + + // The assertion rides on the proxy's session cookie, which the browser + // also attaches to requests that pages on other origins make: Cloudflare's + // application cookie is SameSite=None unless the operator changes it, and + // a sibling subdomain's page (`same-site`) sends it even under Lax or Strict. + #[tokio::test] + async fn proxy_mode_refuses_an_assertion_carried_by_another_origins_request() { + let issuer = TestIssuer::new(); + let server = proxy_server(&issuer).await; + + for site in ["cross-site", "same-site"] { + server + .post("/api/health") + .add_header(HEADER, issuer.assertion()) + .add_header("sec-fetch-site", site) + .await + .assert_status(StatusCode::FORBIDDEN); + } + // Without Fetch Metadata the Origin decides; `null` is an opaque one. + for origin in ["https://evil.example.test", "null"] { + server + .post("/api/health") + .add_header(HEADER, issuer.assertion()) + .add_header("host", "codeg.example.test") + .add_header("origin", origin) + .await + .assert_status(StatusCode::FORBIDDEN); + } + } + + // A WebSocket handshake is a GET, and the one a page on another origin + // opens carries the proxy's cookie too. + #[tokio::test] + async fn proxy_mode_refuses_another_origins_websocket_handshake() { + let issuer = TestIssuer::new(); + let server = proxy_server(&issuer).await; + server + .get("/ws/events") + .add_header(HEADER, issuer.assertion()) + .add_header("sec-fetch-mode", "websocket") + .add_header("sec-fetch-site", "cross-site") + .add_header("origin", "https://evil.example.test") + .await + .assert_status(StatusCode::FORBIDDEN); + server + .get("/ws/events") + .add_header(HEADER, issuer.assertion()) + .add_header("host", "codeg.example.test") + .add_header("origin", "https://evil.example.test") + .await + .assert_status(StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn proxy_mode_admits_an_assertion_sent_by_codegs_own_page() { + let issuer = TestIssuer::new(); + let server = proxy_server(&issuer).await; + + for site in ["same-origin", "none"] { + server + .post("/api/health") + .add_header(HEADER, issuer.assertion()) + .add_header("sec-fetch-site", site) + .await + .assert_status_ok(); + } + server + .get("/ws/events") + .add_header(HEADER, issuer.assertion()) + .add_header("sec-fetch-mode", "websocket") + .add_header("sec-fetch-site", "same-origin") + .add_header("origin", "https://codeg.example.test") + .await + .assert_status_ok(); + // Chrome sends no Fetch Metadata on a WebSocket handshake, so its Origin + // decides: against the browser's Host, or, where cloudflared rewrites + // Host (`httpHostHeader`), the X-Forwarded-Host it moves the original to. + server + .get("/ws/events") + .add_header(HEADER, issuer.assertion()) + .add_header("host", "codeg.example.test") + .add_header("origin", "https://codeg.example.test") + .await + .assert_status_ok(); + server + .get("/ws/events") + .add_header(HEADER, issuer.assertion()) + .add_header("host", "localhost:3080") + .add_header("x-forwarded-host", "codeg.example.test") + .add_header("origin", "https://codeg.example.test") + .await + .assert_status_ok(); + // A browser without Fetch Metadata: its Origin names the host it + // addressed, as `Host` or, behind a proxy that rewrites it, as + // `X-Forwarded-Host`. + server + .post("/api/health") + .add_header(HEADER, issuer.assertion()) + .add_header("host", "codeg.example.test") + .add_header("origin", "https://codeg.example.test") + .await + .assert_status_ok(); + server + .post("/api/health") + .add_header(HEADER, issuer.assertion()) + .add_header("host", "127.0.0.1:3080") + .add_header("x-forwarded-host", "codeg.example.test") + .add_header("origin", "https://codeg.example.test") + .await + .assert_status_ok(); + // Neither header: not a request a page made. + server + .post("/api/health") + .add_header(HEADER, issuer.assertion()) + .await + .assert_status_ok(); + } + + // A page on another origin cannot send the token, so the token needs no + // proof of where the request came from, in either mode. + #[tokio::test] + async fn the_token_is_admitted_whoever_sent_the_request() { + let issuer = TestIssuer::new(); + proxy_server(&issuer) + .await + .post("/api/health") + .authorization_bearer("tok") + .add_header("sec-fetch-site", "cross-site") + .await + .assert_status_ok(); + guarded("tok") + .post("/api/health") + .authorization_bearer("tok") + .add_header("sec-fetch-site", "cross-site") + .await + .assert_status_ok(); + } + + #[tokio::test] + async fn an_accepted_request_carries_no_challenge() { + let ok = guarded("tok") + .post("/api/health") + .authorization_bearer("tok") + .await; + ok.assert_status_ok(); + assert!(ok.maybe_header("www-authenticate").is_none()); + } } diff --git a/src-tauri/src/web/mod.rs b/src-tauri/src/web/mod.rs index 449769ab5a..54a8e1831b 100644 --- a/src-tauri/src/web/mod.rs +++ b/src-tauri/src/web/mod.rs @@ -5,6 +5,7 @@ pub mod compression; pub mod event_bridge; pub mod handlers; pub mod port_probe; +pub mod proxy_auth; pub mod router; pub mod shutdown; pub mod socket_inherit; diff --git a/src-tauri/src/web/proxy_auth.rs b/src-tauri/src/web/proxy_auth.rs new file mode 100644 index 0000000000..ace4ac1d49 --- /dev/null +++ b/src-tauri/src/web/proxy_auth.rs @@ -0,0 +1,1445 @@ +//! Opt-in authentication by an identity-aware proxy's signed assertion. +//! +//! With `CODEG_AUTH=proxy-jwt`, a request is also let in when a header set by +//! a proxy in front of Codeg (Cloudflare Access sends `Cf-Access-Jwt-Assertion`) +//! holds a JWT whose signature verifies against the proxy's published keys +//! (JWKS) and whose `iss`, `aud`, `exp` and `nbf` match. The browser then needs +//! no Codeg token: the proxy's login is the login. The token keeps working for +//! every other client (`codeg-cli --url`, scripts). +//! +//! Why a signature check and not "trust the header": anything that can reach +//! the listener — any local process when Codeg is bound to loopback — can set +//! a header, but cannot forge the proxy's signature. +//! +//! Fail closed throughout: an incomplete configuration refuses to start the +//! server, and a missing header, an unreachable JWKS with no cached key, an +//! unknown key id, an algorithm other than RS256/ES256 or any claim mismatch +//! rejects the request (the caller answers 401). + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use axum::http::{HeaderMap, HeaderName}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use ring::signature::{self, RsaPublicKeyComponents, UnparsedPublicKey}; +use serde_json::{Map, Value}; +use tokio::sync::{Mutex, RwLock}; + +pub const AUTH_MODE_VAR: &str = "CODEG_AUTH"; +pub const AUTH_MODE_PROXY_JWT: &str = "proxy-jwt"; +pub const HEADER_VAR: &str = "CODEG_PROXY_AUTH_HEADER"; +pub const JWKS_URL_VAR: &str = "CODEG_PROXY_AUTH_JWKS_URL"; +pub const ISSUER_VAR: &str = "CODEG_PROXY_AUTH_ISSUER"; +pub const AUDIENCE_VAR: &str = "CODEG_PROXY_AUTH_AUDIENCE"; + +/// A real assertion is about 1 KB; the bound only caps the work an +/// oversized header can cause before it is rejected. +const MAX_ASSERTION_LEN: usize = 16 * 1024; +/// Tolerated clock skew between the proxy and this host for `exp` / `nbf`. +const CLOCK_LEEWAY_SECS: f64 = 60.0; +/// Keys are re-fetched at most this old, so a rotation is picked up even +/// when every request names a key id that is still cached. +const KEYS_MAX_AGE: Duration = Duration::from_secs(60 * 60); +/// At most one JWKS fetch per interval: an assertion naming an unknown key id +/// triggers a refresh, and must not let a caller turn that into a request flood +/// against the proxy's key endpoint. +const MIN_REFRESH_INTERVAL: Duration = Duration::from_secs(30); +const JWKS_FETCH_TIMEOUT: Duration = Duration::from_secs(10); +const MAX_JWKS_BYTES: usize = 256 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProxyAuthConfig { + pub header: HeaderName, + pub jwks_url: reqwest::Url, + pub issuer: String, + pub audience: String, +} + +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +pub enum ConfigError { + #[error("{AUTH_MODE_VAR}={0:?} is not a known mode (expected \"token\" or \"{AUTH_MODE_PROXY_JWT}\")")] + UnknownMode(String), + #[error("{AUTH_MODE_VAR}={AUTH_MODE_PROXY_JWT} needs {0} to be set")] + Missing(&'static str), + #[error("{0} is set but {AUTH_MODE_VAR} is not {AUTH_MODE_PROXY_JWT}; set it, or unset {0}")] + WithoutMode(&'static str), + #[error("{HEADER_VAR}={0:?} is not a valid header name")] + BadHeader(String), + #[error("{JWKS_URL_VAR}={0:?} must be an https URL (http only for a loopback host)")] + BadJwksUrl(String), +} + +impl ProxyAuthConfig { + /// Reads the configuration from the environment. `Ok(None)` is token-only + /// authentication, as before this mode existed. + pub fn from_env() -> Result, ConfigError> { + Self::from_lookup(|name| std::env::var(name).ok()) + } + + pub fn from_lookup(get: impl Fn(&str) -> Option) -> Result, ConfigError> { + let value = |name: &str| { + get(name) + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + }; + let mode = value(AUTH_MODE_VAR); + match mode.as_deref() { + None | Some("token") => { + // A proxy setting without the mode would leave the operator + // believing the proxy's login is enough while every request + // without the token gets 401: say so at startup instead. + for name in [HEADER_VAR, JWKS_URL_VAR, ISSUER_VAR, AUDIENCE_VAR] { + if value(name).is_some() { + return Err(ConfigError::WithoutMode(name)); + } + } + return Ok(None); + } + Some(AUTH_MODE_PROXY_JWT) => {} + Some(other) => return Err(ConfigError::UnknownMode(other.to_string())), + } + let require = |name: &'static str| value(name).ok_or(ConfigError::Missing(name)); + let header_raw = require(HEADER_VAR)?; + let header = HeaderName::try_from(header_raw.as_str()) + .map_err(|_| ConfigError::BadHeader(header_raw.clone()))?; + let url_raw = require(JWKS_URL_VAR)?; + let jwks_url = reqwest::Url::parse(&url_raw) + .ok() + .filter(jwks_url_allowed) + .ok_or_else(|| ConfigError::BadJwksUrl(url_raw.clone()))?; + Ok(Some(Self { + header, + jwks_url, + issuer: require(ISSUER_VAR)?, + audience: require(AUDIENCE_VAR)?, + })) + } +} + +/// The keys decide what verifies, so they must not come over a channel anyone +/// on the path can rewrite. Plain http is accepted only for loopback (a local +/// key server, tests). +fn jwks_url_allowed(url: &reqwest::Url) -> bool { + match url.scheme() { + "https" => url.host().is_some(), + "http" => is_loopback(url), + _ => false, + } +} + +fn is_loopback(url: &reqwest::Url) -> bool { + match url.host() { + Some(url::Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"), + Some(url::Host::Ipv4(ip)) => ip.is_loopback(), + Some(url::Host::Ipv6(ip)) => ip.is_loopback(), + None => false, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Alg { + Rs256, + Es256, +} + +impl Alg { + fn from_jose(name: &str) -> Option { + match name { + "RS256" => Some(Self::Rs256), + "ES256" => Some(Self::Es256), + _ => None, + } + } +} + +#[derive(Debug, Clone)] +enum PublicKey { + Rsa { + n: Vec, + e: Vec, + }, + /// Uncompressed SEC1 point: 0x04 || x || y. + EcP256 { + point: Vec, + }, +} + +impl PublicKey { + fn alg(&self) -> Alg { + match self { + Self::Rsa { .. } => Alg::Rs256, + Self::EcP256 { .. } => Alg::Es256, + } + } + + fn verify(&self, message: &[u8], sig: &[u8]) -> bool { + match self { + Self::Rsa { n, e } => RsaPublicKeyComponents { n, e } + .verify(&signature::RSA_PKCS1_2048_8192_SHA256, message, sig) + .is_ok(), + Self::EcP256 { point } => { + UnparsedPublicKey::new(&signature::ECDSA_P256_SHA256_FIXED, point) + .verify(message, sig) + .is_ok() + } + } + } +} + +/// The proxy's verification keys by key id. +#[derive(Debug, Clone, Default)] +pub struct KeySet(HashMap); + +impl KeySet { + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + fn contains(&self, kid: &str) -> bool { + self.0.contains_key(kid) + } +} + +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +pub enum JwksError { + #[error("the JWKS document is not a JSON object with a \"keys\" array")] + NotAKeySet, + #[error("the JWKS document holds no RS256 or ES256 signing key")] + NoUsableKey, +} + +/// Parses a JWKS document (RFC 7517). Keys this module cannot use — other key +/// types or curves, encryption keys, keys without a `kid`, keys whose `alg` +/// disagrees with their type — are skipped, not fatal, since a proxy may +/// publish more than its signing keys (Cloudflare adds `public_cert(s)` +/// beside `keys`, which is ignored). +pub fn parse_jwks(body: &[u8]) -> Result { + let doc: Value = serde_json::from_slice(body).map_err(|_| JwksError::NotAKeySet)?; + let keys = doc + .get("keys") + .and_then(Value::as_array) + .ok_or(JwksError::NotAKeySet)?; + let set: HashMap = keys + .iter() + .filter_map(Value::as_object) + .filter_map(|jwk| Some((jwk_str(jwk, "kid")?.to_string(), parse_jwk(jwk)?))) + .collect(); + if set.is_empty() { + return Err(JwksError::NoUsableKey); + } + Ok(KeySet(set)) +} + +fn jwk_str<'a>(jwk: &'a Map, name: &str) -> Option<&'a str> { + jwk.get(name) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) +} + +fn jwk_bytes(jwk: &Map, name: &str) -> Option> { + URL_SAFE_NO_PAD + .decode(jwk_str(jwk, name)?) + .ok() + .filter(|b| !b.is_empty()) +} + +fn parse_jwk(jwk: &Map) -> Option { + if jwk.get("use").is_some_and(|u| u.as_str() != Some("sig")) { + return None; + } + let key = match jwk_str(jwk, "kty")? { + "RSA" => PublicKey::Rsa { + n: jwk_bytes(jwk, "n")?, + e: jwk_bytes(jwk, "e")?, + }, + "EC" if jwk_str(jwk, "crv")? == "P-256" => { + let (x, y) = (jwk_bytes(jwk, "x")?, jwk_bytes(jwk, "y")?); + if x.len() != 32 || y.len() != 32 { + return None; + } + let mut point = Vec::with_capacity(65); + point.push(0x04); + point.extend_from_slice(&x); + point.extend_from_slice(&y); + PublicKey::EcP256 { point } + } + _ => return None, + }; + // An `alg` on the key pins it to one algorithm; one that disagrees with the + // key's type is a key this module would use differently from its owner. + if let Some(alg) = jwk.get("alg") { + if alg.as_str().and_then(Alg::from_jose) != Some(key.alg()) { + return None; + } + } + Some(key) +} + +/// Why an assertion was refused. Logged by reason only — never the assertion, +/// which is a bearer credential. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum Rejection { + #[error("no assertion header")] + Missing, + #[error("malformed assertion: {0}")] + Malformed(&'static str), + #[error("algorithm not accepted")] + Algorithm, + #[error("unknown key id")] + UnknownKey, + #[error("no verification keys available")] + NoKeys, + #[error("bad signature")] + Signature, + #[error("expired")] + Expired, + #[error("not yet valid")] + NotYetValid, + #[error("wrong issuer")] + Issuer, + #[error("wrong audience")] + Audience, +} + +fn decode_json_object(part: &str, what: &'static str) -> Result, Rejection> { + let bytes = URL_SAFE_NO_PAD + .decode(part) + .map_err(|_| Rejection::Malformed(what))?; + match serde_json::from_slice(&bytes) { + Ok(Value::Object(map)) => Ok(map), + _ => Err(Rejection::Malformed(what)), + } +} + +/// The key id an assertion names, read before its signature is checked so the +/// caller knows whether its cached keys can answer. +fn assertion_kid(assertion: &str) -> Result { + let header = assertion.split('.').next().unwrap_or_default(); + let header = decode_json_object(header, "header")?; + jwk_str(&header, "kid") + .map(str::to_string) + .ok_or(Rejection::Malformed("kid")) +} + +fn numeric_date(claims: &Map, name: &str) -> Result, Rejection> { + match claims.get(name) { + None => Ok(None), + Some(v) => v + .as_f64() + .filter(|t| t.is_finite()) + .map(Some) + .ok_or(Rejection::Malformed("date claim")), + } +} + +/// Verifies a compact-serialized JWS (RFC 7515) and its claims (RFC 7519). +/// `now` is seconds since the Unix epoch. +pub fn verify( + assertion: &str, + keys: &KeySet, + issuer: &str, + audience: &str, + now: u64, +) -> Result<(), Rejection> { + if assertion.len() > MAX_ASSERTION_LEN { + return Err(Rejection::Malformed("too long")); + } + let parts: Vec<&str> = assertion.split('.').collect(); + let [header_b64, claims_b64, sig_b64] = parts.as_slice() else { + return Err(Rejection::Malformed("not three parts")); + }; + + let header = decode_json_object(header_b64, "header")?; + // The algorithm comes from the key, never from the header alone: the + // header only has to agree. That shuts out `none`, HS256 with a public key + // as the secret, and every other algorithm-confusion variant. + let alg = header + .get("alg") + .and_then(Value::as_str) + .and_then(Alg::from_jose) + .ok_or(Rejection::Algorithm)?; + // RFC 7515 §4.1.11: an extension this verifier does not implement must + // reject the token. + if header.contains_key("crit") { + return Err(Rejection::Malformed("crit")); + } + let kid = jwk_str(&header, "kid").ok_or(Rejection::Malformed("kid"))?; + let key = keys.0.get(kid).ok_or(Rejection::UnknownKey)?; + if key.alg() != alg { + return Err(Rejection::Algorithm); + } + + let sig = URL_SAFE_NO_PAD + .decode(sig_b64) + .map_err(|_| Rejection::Malformed("signature"))?; + let signing_input = &assertion[..header_b64.len() + 1 + claims_b64.len()]; + if !key.verify(signing_input.as_bytes(), &sig) { + return Err(Rejection::Signature); + } + + // Claims are only read once the signature says who wrote them. + let claims = decode_json_object(claims_b64, "claims")?; + let now = now as f64; + let exp = numeric_date(&claims, "exp")?.ok_or(Rejection::Malformed("exp"))?; + if now >= exp + CLOCK_LEEWAY_SECS { + return Err(Rejection::Expired); + } + if let Some(nbf) = numeric_date(&claims, "nbf")? { + if now + CLOCK_LEEWAY_SECS < nbf { + return Err(Rejection::NotYetValid); + } + } + if claims.get("iss").and_then(Value::as_str) != Some(issuer) { + return Err(Rejection::Issuer); + } + let audience_ok = match claims.get("aud") { + Some(Value::String(aud)) => aud == audience, + Some(Value::Array(auds)) => auds.iter().any(|a| a.as_str() == Some(audience)), + _ => false, + }; + if !audience_ok { + return Err(Rejection::Audience); + } + Ok(()) +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// The configured proxy authentication with its key cache. +pub struct ProxyJwtAuth { + config: ProxyAuthConfig, + client: reqwest::Client, + keys: RwLock, Instant)>>, + /// Serializes refreshes and remembers the last attempt (success or not) + /// for the rate limit. + last_refresh: Mutex>, + min_refresh_interval: Duration, + keys_max_age: Duration, + /// Set while a refresh started for keys past their age is out, so the + /// requests that find them stale meanwhile do not start one each. + refreshing_in_background: AtomicBool, +} + +impl ProxyJwtAuth { + pub fn new(config: ProxyAuthConfig) -> Result { + let mut client = reqwest::Client::builder() + .timeout(JWKS_FETCH_TIMEOUT) + // A redirect could lead off the configured (https) origin. + .redirect(reqwest::redirect::Policy::none()); + // A key server on this machine is not reached through HTTP(S)_PROXY: + // a proxy on another host would dial its own loopback. + if is_loopback(&config.jwks_url) { + client = client.no_proxy(); + } + let client = client.build()?; + Ok(Self { + config, + client, + keys: RwLock::new(None), + last_refresh: Mutex::new(None), + min_refresh_interval: MIN_REFRESH_INTERVAL, + keys_max_age: KEYS_MAX_AGE, + refreshing_in_background: AtomicBool::new(false), + }) + } + + pub fn config(&self) -> &ProxyAuthConfig { + &self.config + } + + /// Fetches the keys once at startup so the first request does not wait, + /// and so a wrong URL shows in the log right away. A failure is not fatal: + /// requests are refused until a later fetch succeeds. + pub async fn prime(&self) { + self.refresh().await; + if self.keys.read().await.is_none() { + tracing::warn!( + "[AUTH] proxy-jwt: no verification keys yet from {}; requests without the token are refused until a fetch succeeds", + self.config.jwks_url + ); + } + } + + /// Checks the request's assertion header. + pub async fn authenticate(self: &Arc, headers: &HeaderMap) -> Result<(), Rejection> { + let assertion = headers + .get(&self.config.header) + .ok_or(Rejection::Missing)? + .to_str() + .map_err(|_| Rejection::Malformed("header encoding"))?; + if assertion.len() > MAX_ASSERTION_LEN { + return Err(Rejection::Malformed("too long")); + } + let kid = assertion_kid(assertion)?; + let keys = self.keys_for(&kid).await?; + verify( + assertion, + &keys, + &self.config.issuer, + &self.config.audience, + unix_now(), + ) + } + + async fn cached(&self) -> Option<(Arc, Instant)> { + self.keys.read().await.clone() + } + + async fn keys_for(self: &Arc, kid: &str) -> Result, Rejection> { + if let Some((keys, fetched)) = self.cached().await { + if keys.contains(kid) { + // Past their age the keys still verify what they signed: answer + // with them and refresh beside the request, so a slow or + // unreachable key endpoint never holds requests up. A key the + // endpoint has withdrawn is refused once the refresh is in. + if fetched.elapsed() >= self.keys_max_age { + self.refresh_in_background(); + } + return Ok(keys); + } + } + // An unknown key id (a rotation) or no keys yet: nothing cached can + // answer, so this request waits for the fetch. + self.refresh().await; + // A failed refresh keeps the previous keys: they still verify what + // they signed, and `exp` bounds how long such an assertion lives. + self.cached() + .await + .map(|(keys, _)| keys) + .ok_or(Rejection::NoKeys) + } + + fn refresh_in_background(self: &Arc) { + if self.refreshing_in_background.swap(true, Ordering::AcqRel) { + return; + } + let this = Arc::clone(self); + tokio::spawn(async move { + this.refresh().await; + this.refreshing_in_background + .store(false, Ordering::Release); + }); + } + + async fn refresh(&self) { + let mut last = self.last_refresh.lock().await; + if last.is_some_and(|at| at.elapsed() < self.min_refresh_interval) { + return; + } + *last = Some(Instant::now()); + match self.fetch().await { + Ok(keys) => { + tracing::info!( + "[AUTH] proxy-jwt: loaded {} verification key(s)", + keys.len() + ); + *self.keys.write().await = Some((Arc::new(keys), Instant::now())); + } + Err(reason) => { + tracing::warn!( + "[AUTH] proxy-jwt: fetching keys from {} failed: {reason}", + self.config.jwks_url + ); + } + } + } + + async fn fetch(&self) -> Result { + let mut response = self + .client + .get(self.config.jwks_url.clone()) + .send() + .await + .map_err(|e| e.without_url().to_string())?; + if !response.status().is_success() { + return Err(format!("HTTP {}", response.status())); + } + if response + .content_length() + .is_some_and(|len| len > MAX_JWKS_BYTES as u64) + { + return Err("document too large".into()); + } + // Read by chunk so a body sent without a length stops at the cap too. + let mut body = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|e| e.without_url().to_string())? + { + if body.len() + chunk.len() > MAX_JWKS_BYTES { + return Err("document too large".into()); + } + body.extend_from_slice(&chunk); + } + parse_jwks(&body).map_err(|e| e.to_string()) + } +} + +/// A test issuer: one ES256 key, its key set preloaded into a +/// `ProxyJwtAuth`, and assertions it signs. For other modules' tests. +#[cfg(test)] +pub(crate) mod testing { + use super::*; + use ring::rand::SystemRandom; + use ring::signature::{EcdsaKeyPair, KeyPair, ECDSA_P256_SHA256_FIXED_SIGNING}; + use serde_json::json; + + pub(crate) const HEADER: &str = "cf-access-jwt-assertion"; + pub(crate) const ISSUER: &str = "https://team.example.test"; + pub(crate) const AUDIENCE: &str = "app-audience-tag"; + + pub(crate) struct TestIssuer { + pair: EcdsaKeyPair, + } + + impl TestIssuer { + pub(crate) fn new() -> Self { + let rng = SystemRandom::new(); + let pkcs8 = + EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap(); + let pair = + EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng) + .unwrap(); + Self { pair } + } + + /// A `ProxyJwtAuth` that already holds this issuer's key, so no + /// request reaches the (unroutable) key URL. + pub(crate) async fn auth(&self) -> ProxyJwtAuth { + let config = ProxyAuthConfig { + header: HeaderName::from_static(HEADER), + jwks_url: reqwest::Url::parse("http://127.0.0.1:9/certs").unwrap(), + issuer: ISSUER.into(), + audience: AUDIENCE.into(), + }; + let auth = ProxyJwtAuth::new(config).unwrap(); + let p = self.pair.public_key().as_ref(); + let doc = json!({"keys": [{"kid": "k", "kty": "EC", "crv": "P-256", + "x": URL_SAFE_NO_PAD.encode(&p[1..33]), "y": URL_SAFE_NO_PAD.encode(&p[33..65])}]}); + let keys = parse_jwks(doc.to_string().as_bytes()).unwrap(); + *auth.keys.write().await = Some((Arc::new(keys), Instant::now())); + auth + } + + /// A currently valid assertion for `audience`. + pub(crate) fn assertion_for(&self, audience: &str) -> String { + let now = unix_now(); + let header = json!({"alg": "ES256", "kid": "k", "typ": "JWT"}); + let claims = json!({"iss": ISSUER, "aud": [audience], "exp": now + 300, + "iat": now, "nbf": now, "sub": "user"}); + let input = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(header.to_string()), + URL_SAFE_NO_PAD.encode(claims.to_string()) + ); + let sig = self + .pair + .sign(&SystemRandom::new(), input.as_bytes()) + .unwrap(); + format!("{input}.{}", URL_SAFE_NO_PAD.encode(sig.as_ref())) + } + + pub(crate) fn assertion(&self) -> String { + self.assertion_for(AUDIENCE) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ring::rand::SystemRandom; + use ring::signature::{ + EcdsaKeyPair, KeyPair, RsaKeyPair, ECDSA_P256_SHA256_FIXED_SIGNING, RSA_PKCS1_SHA256, + }; + use serde_json::json; + use std::sync::atomic::{AtomicUsize, Ordering}; + + // TEST-ONLY 2048-bit RSA key (PKCS#8 DER, standard base64), made for these + // tests with `openssl genrsa 2048 | openssl pkcs8 -topk8 -nocrypt -outform + // DER` (ring reads PKCS#8 only, and cannot generate RSA keys, hence a + // fixture). It signs nothing outside this module. + const TEST_ONLY_RSA_PKCS8: &str = "MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC4iJcjrRiHvy7nrn95DNrBIMOTix+8B6ZIguRZTiymPnfnaPwW5c+81Jg0yaZmm1XP2a181TluHDvRZ5lmjl83glTdlVwJ1I6NDeXY88RXYAX8kkrjEBw3XWClmEZr8e4QdhJguzJ+LJjnHsO23tC+/M1RDI/9oLXNB64GEmjFr6WyoX/c4MsJE/ANAma5lyNXXNJKTBYmN/ie1p4U8tA20qorT+2+cJv+tcQWkfuAKw0xnzfbViYgMv2pdXXM9H1yi/SGnJDU2927DysiKfWX3rbFiY6/z7kx0qQu8u+6Bz+tiPLoCiUY3/uOEGY/lh1+I7UH+uHCT3M5w16gtrwxAgMBAAECggEABL+rGAVv0Kp4VAj3Cc/tXzPvJIBbiDSwMESCCNRMrNiFsGRQvGwcjPDxm8YmrJRJwIgLAV/hUvaSCYD/fxah16VYdr1NuYkJRBL7+7nGLzNu5AGFwNMczsXt40UIZuW6aR0IuwmGMp4GL0ho4+9W8ttBk1jd6bp1gzmz3j8iCBhCWYI+Lfk23e9Lnb8aACMNyPK0a04q8q8c+Z303PLQ8Lw7YW2ep6fNu2zG2DksnKvOL11lYbFi/IIN6FWX+OMVqe5+jp/dzT6gCRZcGGeqJmaGqqBXyMp0+O3t2FeL20BXZD5N+glRSZFpW+8rpzJPSciwjePKsPlYw9eBmCdZmQKBgQDkgTP25hgAYzUV+M4n/0+ioGII316/WnTfPtrE+Y0baXPW8vQtvbiirCB61xEh//+c0Bs1PY+I0y28sm6rCo84F4Ta1CExlIYSHl6Ye1dCKIWlnFKo9hqUVMN1yuEir4ruVm6Vhw3/FhTgaVpOoO8UnTD1ngsF23FxZUQ4OZR0XQKBgQDOvOmFl3laEf+1HF4tsMiw6LGRBlnB1T1rsCQm/dihhEFvd6gDgltSKb/Sxenqcvnmc1bQb1y2DyiPAPgFgaWfq5stQrKPbc71x0ikcn2cu16sWMziH+PH1M0vaYzzjSZhfk/Wr6UyjR+DrT7E6ZF7d/x8sD2kE8rYSS9Q9Orp5QKBgD45CKN06JhCa5Q+GjU+CrtoHQn5eV12Dlk0TnJQ2iHax1aC9gb2oK57XKTWp62wfjR/XslSqCFEEDJQDF67E2Dc1qqYFSnhHdkVOAEhpRM44ZvFQ6UKzxYRNuB+jGvbjWN1WcuR6TfLFYg+Z/79R90/KLPZg3rm1K9iOf9tfoVpAoGBALrvEIyuUZlC6x9dfj78OgvU7DJBNsnQwg/snlPvdoFdcDmVTYOWzHI3s+6m7IME4bgiFJ62A3PPsxHTg3CfMR52cgLz6zdLyDZust2XdMNSnSFjziatRS582eeEHmJfABRpvVFXUx2pg5RyA27T76tf7Brm1WXWS7VUP4eJXhyNAoGBANzRGdONiEnwf7NkVf0MwpJhzNKherecxH5aGwDvt74q+FlA95V1QAuvxTksdF32Hk0x2OmWHTLkqib7HuxTzb0D6GqT1S4WYmmgk74/dvk01yw+XRsTTbx9z5yrQBadICs2Oo85gvzlALX3Akp8RTlKAz853ZufLLmbG3o6qEYC"; + + const ISS: &str = "https://team.example.test"; + const AUD: &str = "app-audience-tag"; + const NOW: u64 = 1_800_000_000; + + fn b64(bytes: &[u8]) -> String { + URL_SAFE_NO_PAD.encode(bytes) + } + + enum SignerKind { + Rsa(RsaKeyPair), + Ec(EcdsaKeyPair), + } + + struct Signer { + kid: &'static str, + kind: SignerKind, + } + + fn rsa_signer(kid: &'static str) -> Signer { + use base64::engine::general_purpose::STANDARD; + let der = STANDARD.decode(TEST_ONLY_RSA_PKCS8).unwrap(); + Signer { + kid, + kind: SignerKind::Rsa(RsaKeyPair::from_pkcs8(&der).unwrap()), + } + } + + fn ec_signer(kid: &'static str) -> Signer { + let rng = SystemRandom::new(); + let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap(); + let pair = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng) + .unwrap(); + Signer { + kid, + kind: SignerKind::Ec(pair), + } + } + + impl Signer { + fn jwk(&self) -> Value { + match &self.kind { + SignerKind::Rsa(pair) => { + let c = RsaPublicKeyComponents::>::from(pair.public()); + json!({"kid": self.kid, "kty": "RSA", "alg": "RS256", "use": "sig", + "n": b64(&c.n), "e": b64(&c.e)}) + } + SignerKind::Ec(pair) => { + let p = pair.public_key().as_ref(); + json!({"kid": self.kid, "kty": "EC", "crv": "P-256", "alg": "ES256", + "use": "sig", "x": b64(&p[1..33]), "y": b64(&p[33..65])}) + } + } + } + + fn alg(&self) -> &'static str { + match self.kind { + SignerKind::Rsa(_) => "RS256", + SignerKind::Ec(_) => "ES256", + } + } + + fn sign_with_header(&self, header: &Value, claims: &Value) -> String { + let input = format!( + "{}.{}", + b64(header.to_string().as_bytes()), + b64(claims.to_string().as_bytes()) + ); + let rng = SystemRandom::new(); + let sig = match &self.kind { + SignerKind::Rsa(pair) => { + let mut sig = vec![0; pair.public().modulus_len()]; + pair.sign(&RSA_PKCS1_SHA256, &rng, input.as_bytes(), &mut sig) + .unwrap(); + sig + } + SignerKind::Ec(pair) => { + pair.sign(&rng, input.as_bytes()).unwrap().as_ref().to_vec() + } + }; + format!("{input}.{}", b64(&sig)) + } + + fn sign(&self, claims: &Value) -> String { + let header = json!({"alg": self.alg(), "kid": self.kid, "typ": "JWT"}); + self.sign_with_header(&header, claims) + } + } + + fn keyset(signers: &[&Signer]) -> KeySet { + let doc = json!({"keys": signers.iter().map(|s| s.jwk()).collect::>()}); + parse_jwks(doc.to_string().as_bytes()).unwrap() + } + + // Shaped like a Cloudflare Access application token. + fn claims() -> Value { + json!({"iss": ISS, "aud": [AUD], "exp": NOW + 300, "iat": NOW - 60, + "nbf": NOW - 60, "sub": "user-id", "email": "someone@example.test", + "type": "app"}) + } + + fn check(token: &str, keys: &KeySet) -> Result<(), Rejection> { + verify(token, keys, ISS, AUD, NOW) + } + + // ── verify: what passes ────────────────────────────────────────────── + + #[test] + fn accepts_rs256_and_es256() { + let (rsa, ec) = (rsa_signer("r1"), ec_signer("e1")); + let keys = keyset(&[&rsa, &ec]); + assert_eq!(check(&rsa.sign(&claims()), &keys), Ok(())); + assert_eq!(check(&ec.sign(&claims()), &keys), Ok(())); + } + + #[test] + fn accepts_a_string_audience_and_no_nbf() { + let rsa = rsa_signer("r1"); + let mut c = claims(); + c["aud"] = json!(AUD); + c.as_object_mut().unwrap().remove("nbf"); + assert_eq!(check(&rsa.sign(&c), &keyset(&[&rsa])), Ok(())); + } + + #[test] + fn tolerates_clock_skew_within_the_leeway() { + let rsa = rsa_signer("r1"); + let mut c = claims(); + c["exp"] = json!(NOW - 30); + c["nbf"] = json!(NOW + 30); + assert_eq!(check(&rsa.sign(&c), &keyset(&[&rsa])), Ok(())); + } + + // ── verify: what is refused ────────────────────────────────────────── + + #[test] + fn refuses_an_expired_assertion() { + let rsa = rsa_signer("r1"); + let mut c = claims(); + c["exp"] = json!(NOW - 61); + assert_eq!( + check(&rsa.sign(&c), &keyset(&[&rsa])), + Err(Rejection::Expired) + ); + } + + #[test] + fn refuses_an_assertion_without_exp() { + let rsa = rsa_signer("r1"); + let mut c = claims(); + c.as_object_mut().unwrap().remove("exp"); + assert_eq!( + check(&rsa.sign(&c), &keyset(&[&rsa])), + Err(Rejection::Malformed("exp")) + ); + } + + #[test] + fn refuses_a_non_numeric_exp() { + let rsa = rsa_signer("r1"); + let mut c = claims(); + c["exp"] = json!("tomorrow"); + assert_eq!( + check(&rsa.sign(&c), &keyset(&[&rsa])), + Err(Rejection::Malformed("date claim")) + ); + } + + #[test] + fn refuses_an_assertion_not_yet_valid() { + let rsa = rsa_signer("r1"); + let mut c = claims(); + c["nbf"] = json!(NOW + 61); + assert_eq!( + check(&rsa.sign(&c), &keyset(&[&rsa])), + Err(Rejection::NotYetValid) + ); + } + + #[test] + fn refuses_another_issuer() { + let rsa = rsa_signer("r1"); + let mut c = claims(); + c["iss"] = json!("https://other.example.test"); + assert_eq!( + check(&rsa.sign(&c), &keyset(&[&rsa])), + Err(Rejection::Issuer) + ); + c.as_object_mut().unwrap().remove("iss"); + assert_eq!( + check(&rsa.sign(&c), &keyset(&[&rsa])), + Err(Rejection::Issuer) + ); + } + + #[test] + fn refuses_another_audience() { + let rsa = rsa_signer("r1"); + let keys = keyset(&[&rsa]); + let mut c = claims(); + c["aud"] = json!(["other-app"]); + assert_eq!(check(&rsa.sign(&c), &keys), Err(Rejection::Audience)); + c["aud"] = json!("other-app"); + assert_eq!(check(&rsa.sign(&c), &keys), Err(Rejection::Audience)); + c.as_object_mut().unwrap().remove("aud"); + assert_eq!(check(&rsa.sign(&c), &keys), Err(Rejection::Audience)); + } + + #[test] + fn refuses_a_tampered_payload() { + let rsa = rsa_signer("r1"); + let token = rsa.sign(&claims()); + let parts: Vec<&str> = token.split('.').collect(); + let mut c = claims(); + c["email"] = json!("attacker@example.test"); + let forged = format!( + "{}.{}.{}", + parts[0], + b64(c.to_string().as_bytes()), + parts[2] + ); + assert_eq!(check(&forged, &keyset(&[&rsa])), Err(Rejection::Signature)); + } + + #[test] + fn refuses_a_signature_by_a_key_not_in_the_set() { + let (trusted, other) = (ec_signer("k1"), ec_signer("k1")); + assert_eq!( + check(&other.sign(&claims()), &keyset(&[&trusted])), + Err(Rejection::Signature) + ); + } + + #[test] + fn refuses_an_unknown_key_id() { + let (rsa, ec) = (rsa_signer("r1"), ec_signer("e1")); + assert_eq!( + check(&ec.sign(&claims()), &keyset(&[&rsa])), + Err(Rejection::UnknownKey) + ); + } + + #[test] + fn refuses_alg_none_and_hs256() { + let rsa = rsa_signer("r1"); + let keys = keyset(&[&rsa]); + let body = b64(claims().to_string().as_bytes()); + for alg in ["none", "HS256", "RS512", "PS256"] { + let header = b64(json!({"alg": alg, "kid": "r1"}).to_string().as_bytes()); + assert_eq!( + check(&format!("{header}.{body}."), &keys), + Err(Rejection::Algorithm), + "{alg}" + ); + assert_eq!( + check(&format!("{header}.{body}.c2ln"), &keys), + Err(Rejection::Algorithm), + "{alg}" + ); + } + } + + #[test] + fn refuses_a_header_alg_that_disagrees_with_the_key() { + // ES256 claimed for an RSA key id: the key decides the algorithm. + let (rsa, ec) = (rsa_signer("r1"), ec_signer("e1")); + let header = json!({"alg": "ES256", "kid": "r1"}); + let token = ec.sign_with_header(&header, &claims()); + assert_eq!(check(&token, &keyset(&[&rsa])), Err(Rejection::Algorithm)); + } + + #[test] + fn refuses_a_crit_header() { + let rsa = rsa_signer("r1"); + let header = json!({"alg": "RS256", "kid": "r1", "crit": ["exp"]}); + let token = rsa.sign_with_header(&header, &claims()); + assert_eq!( + check(&token, &keyset(&[&rsa])), + Err(Rejection::Malformed("crit")) + ); + } + + #[test] + fn refuses_malformed_structure() { + let rsa = rsa_signer("r1"); + let keys = keyset(&[&rsa]); + let token = rsa.sign(&claims()); + assert_eq!( + check("", &keys), + Err(Rejection::Malformed("not three parts")) + ); + assert_eq!( + check("a.b", &keys), + Err(Rejection::Malformed("not three parts")) + ); + assert_eq!( + check(&format!("{token}.extra"), &keys), + Err(Rejection::Malformed("not three parts")) + ); + assert_eq!(check("!!.b.c", &keys), Err(Rejection::Malformed("header"))); + let no_kid = rsa.sign_with_header(&json!({"alg": "RS256"}), &claims()); + assert_eq!(check(&no_kid, &keys), Err(Rejection::Malformed("kid"))); + let (head, rest) = token.split_once('.').unwrap(); + let (body, _) = rest.split_once('.').unwrap(); + assert_eq!( + check(&format!("{head}.{body}.!!"), &keys), + Err(Rejection::Malformed("signature")) + ); + assert_eq!( + check(&format!("{head}.{body}."), &keys), + Err(Rejection::Signature) + ); + let long = "a".repeat(MAX_ASSERTION_LEN + 1); + assert_eq!(check(&long, &keys), Err(Rejection::Malformed("too long"))); + } + + #[test] + fn refuses_a_signed_payload_that_is_not_an_object() { + let rsa = rsa_signer("r1"); + let token = rsa.sign(&json!(["not", "claims"])); + assert_eq!( + check(&token, &keyset(&[&rsa])), + Err(Rejection::Malformed("claims")) + ); + } + + // ── parse_jwks ─────────────────────────────────────────────────────── + + #[test] + fn jwks_keeps_usable_keys_and_skips_the_rest() { + let (rsa, ec) = (rsa_signer("r1"), ec_signer("e1")); + let mut enc = rsa.jwk(); + enc["kid"] = json!("enc"); + enc["use"] = json!("enc"); + let mut mislabeled = ec.jwk(); + mislabeled["kid"] = json!("mislabeled"); + mislabeled["alg"] = json!("RS256"); + let mut no_kid = rsa.jwk(); + no_kid.as_object_mut().unwrap().remove("kid"); + let doc = json!({ + "keys": [rsa.jwk(), ec.jwk(), enc, mislabeled, no_kid, + {"kid": "p384", "kty": "EC", "crv": "P-384", "x": "AA", "y": "AA"}, + {"kid": "oct", "kty": "oct", "k": "c2VjcmV0"}, + {"kid": "short", "kty": "EC", "crv": "P-256", "x": "AAAA", "y": "AAAA"}, + "not an object"], + // Cloudflare publishes the same keys as certificates beside them. + "public_cert": {"kid": "r1", "cert": "-----BEGIN CERTIFICATE-----"}, + "public_certs": [] + }); + let keys = parse_jwks(doc.to_string().as_bytes()).unwrap(); + assert_eq!(keys.len(), 2); + assert!(keys.contains("r1") && keys.contains("e1")); + } + + #[test] + fn jwks_without_a_usable_key_is_an_error() { + assert_eq!( + parse_jwks(b"{\"keys\": []}").unwrap_err(), + JwksError::NoUsableKey + ); + assert_eq!( + parse_jwks(b"{\"keys\": {}}").unwrap_err(), + JwksError::NotAKeySet + ); + assert_eq!(parse_jwks(b"").unwrap_err(), JwksError::NotAKeySet); + } + + // ── configuration ──────────────────────────────────────────────────── + + fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let map: HashMap = pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + move |name| map.get(name).cloned() + } + + fn full() -> Vec<(&'static str, &'static str)> { + vec![ + (AUTH_MODE_VAR, "proxy-jwt"), + (HEADER_VAR, "Cf-Access-Jwt-Assertion"), + ( + JWKS_URL_VAR, + "https://team.example.test/cdn-cgi/access/certs", + ), + (ISSUER_VAR, ISS), + (AUDIENCE_VAR, AUD), + ] + } + + #[test] + fn config_is_off_by_default() { + assert_eq!(ProxyAuthConfig::from_lookup(env(&[])), Ok(None)); + assert_eq!( + ProxyAuthConfig::from_lookup(env(&[(AUTH_MODE_VAR, "token")])), + Ok(None) + ); + assert_eq!( + ProxyAuthConfig::from_lookup(env(&[(AUTH_MODE_VAR, " ")])), + Ok(None) + ); + } + + #[test] + fn config_reads_a_complete_proxy_setup() { + let config = ProxyAuthConfig::from_lookup(env(&full())).unwrap().unwrap(); + assert_eq!(config.header.as_str(), "cf-access-jwt-assertion"); + assert_eq!(config.issuer, ISS); + assert_eq!(config.audience, AUD); + } + + #[test] + fn config_refuses_an_incomplete_proxy_setup() { + for missing in [HEADER_VAR, JWKS_URL_VAR, ISSUER_VAR, AUDIENCE_VAR] { + let pairs: Vec<_> = full().into_iter().filter(|(k, _)| *k != missing).collect(); + assert_eq!( + ProxyAuthConfig::from_lookup(env(&pairs)), + Err(ConfigError::Missing(missing)) + ); + let blank: Vec<_> = full() + .into_iter() + .map(|(k, v)| if k == missing { (k, " ") } else { (k, v) }) + .collect(); + assert_eq!( + ProxyAuthConfig::from_lookup(env(&blank)), + Err(ConfigError::Missing(missing)) + ); + } + } + + #[test] + fn config_refuses_an_unknown_mode_and_settings_without_the_mode() { + assert_eq!( + ProxyAuthConfig::from_lookup(env(&[(AUTH_MODE_VAR, "proxy")])), + Err(ConfigError::UnknownMode("proxy".into())) + ); + let without: Vec<_> = full() + .into_iter() + .filter(|(k, _)| *k != AUTH_MODE_VAR) + .collect(); + assert_eq!( + ProxyAuthConfig::from_lookup(env(&without)), + Err(ConfigError::WithoutMode(HEADER_VAR)) + ); + } + + #[test] + fn config_refuses_a_bad_header_name() { + let pairs: Vec<_> = full() + .into_iter() + .map(|(k, v)| { + if k == HEADER_VAR { + (k, "bad header") + } else { + (k, v) + } + }) + .collect(); + assert_eq!( + ProxyAuthConfig::from_lookup(env(&pairs)), + Err(ConfigError::BadHeader("bad header".into())) + ); + } + + #[test] + fn config_wants_https_for_the_keys_except_on_loopback() { + let with_url = |url: &'static str| { + let pairs: Vec<_> = full() + .into_iter() + .map(|(k, v)| if k == JWKS_URL_VAR { (k, url) } else { (k, v) }) + .collect(); + ProxyAuthConfig::from_lookup(env(&pairs)) + }; + for bad in [ + "http://team.example.test/certs", + "ftp://team.example.test/certs", + "file:///etc/certs.json", + "not a url", + ] { + assert_eq!( + with_url(bad), + Err(ConfigError::BadJwksUrl(bad.into())), + "{bad}" + ); + } + for ok in [ + "http://127.0.0.1:8080/certs", + "http://localhost/certs", + "http://[::1]/certs", + ] { + assert!(with_url(ok).unwrap().is_some(), "{ok}"); + } + } + + // ── key cache ──────────────────────────────────────────────────────── + + struct KeyServer { + url: String, + served: Arc>, + hits: Arc, + } + + async fn key_server(doc: Value) -> KeyServer { + key_server_answering_after(doc, Duration::ZERO).await + } + + async fn key_server_answering_after(doc: Value, delay: Duration) -> KeyServer { + let served = Arc::new(std::sync::Mutex::new(doc)); + let hits = Arc::new(AtomicUsize::new(0)); + let (s, h) = (served.clone(), hits.clone()); + let app = axum::Router::new().route( + "/certs", + axum::routing::get(move || { + let (s, h) = (s.clone(), h.clone()); + async move { + h.fetch_add(1, Ordering::SeqCst); + tokio::time::sleep(delay).await; + let doc = s.lock().unwrap().clone(); + axum::Json(doc) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/certs", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + KeyServer { url, served, hits } + } + + fn auth_for(url: &str, min_refresh_interval: Duration) -> ProxyJwtAuth { + let config = ProxyAuthConfig::from_lookup(env(&[ + (AUTH_MODE_VAR, "proxy-jwt"), + (HEADER_VAR, "Cf-Access-Jwt-Assertion"), + (JWKS_URL_VAR, url), + (ISSUER_VAR, ISS), + (AUDIENCE_VAR, AUD), + ])) + .unwrap() + .unwrap(); + let mut auth = ProxyJwtAuth::new(config).unwrap(); + auth.min_refresh_interval = min_refresh_interval; + auth + } + + fn live_claims() -> Value { + let now = unix_now(); + let mut c = claims(); + c["exp"] = json!(now + 300); + c["iat"] = json!(now); + c["nbf"] = json!(now); + c + } + + fn with_assertion(token: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert("cf-access-jwt-assertion", token.parse().unwrap()); + headers + } + + #[tokio::test] + async fn fetches_keys_once_and_serves_from_the_cache() { + let rsa = rsa_signer("r1"); + let server = key_server(json!({"keys": [rsa.jwk()]})).await; + let auth = Arc::new(auth_for(&server.url, Duration::ZERO)); + let token = rsa.sign(&live_claims()); + + assert_eq!(auth.authenticate(&with_assertion(&token)).await, Ok(())); + assert_eq!(auth.authenticate(&with_assertion(&token)).await, Ok(())); + assert_eq!(server.hits.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn an_unknown_key_id_refreshes_the_keys_after_a_rotation() { + let (old, new) = (ec_signer("old"), ec_signer("new")); + let server = key_server(json!({"keys": [old.jwk()]})).await; + let auth = Arc::new(auth_for(&server.url, Duration::ZERO)); + auth.prime().await; + + *server.served.lock().unwrap() = json!({"keys": [old.jwk(), new.jwk()]}); + let token = new.sign(&live_claims()); + assert_eq!(auth.authenticate(&with_assertion(&token)).await, Ok(())); + assert_eq!(server.hits.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn unknown_key_ids_cannot_drive_a_fetch_per_request() { + let (known, stranger) = (ec_signer("known"), ec_signer("stranger")); + let server = key_server(json!({"keys": [known.jwk()]})).await; + let auth = Arc::new(auth_for(&server.url, Duration::from_secs(3600))); + auth.prime().await; + + let token = stranger.sign(&live_claims()); + for _ in 0..5 { + assert_eq!( + auth.authenticate(&with_assertion(&token)).await, + Err(Rejection::UnknownKey) + ); + } + assert_eq!(server.hits.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn refuses_everything_while_no_keys_could_be_fetched() { + // Bound and closed again: nothing listens on that port. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/certs", listener.local_addr().unwrap()); + drop(listener); + let auth = Arc::new(auth_for(&url, Duration::ZERO)); + let token = rsa_signer("r1").sign(&live_claims()); + assert_eq!( + auth.authenticate(&with_assertion(&token)).await, + Err(Rejection::NoKeys) + ); + } + + #[tokio::test] + async fn keeps_the_last_keys_when_a_refresh_fails() { + let (rsa, stranger) = (rsa_signer("r1"), ec_signer("x")); + let server = key_server(json!({"keys": [rsa.jwk()]})).await; + let auth = Arc::new(auth_for(&server.url, Duration::ZERO)); + auth.prime().await; + + // The endpoint now serves garbage; an unknown kid forces a refresh. + *server.served.lock().unwrap() = json!({"keys": []}); + assert_eq!( + auth.authenticate(&with_assertion(&stranger.sign(&live_claims()))) + .await, + Err(Rejection::UnknownKey) + ); + assert_eq!( + auth.authenticate(&with_assertion(&rsa.sign(&live_claims()))) + .await, + Ok(()) + ); + } + + // The size cap holds for a body sent without a length too: the fetch stops + // reading once the document is past it, instead of keeping all of it until + // the stream ends or the timeout. + #[tokio::test] + async fn an_endless_key_document_is_cut_off_at_the_size_cap() { + let app = axum::Router::new().route( + "/certs", + axum::routing::get(|| async { + let chunks = futures_util::stream::unfold((), |()| async { + tokio::time::sleep(Duration::from_millis(20)).await; + Some(( + Ok::<_, std::io::Error>(axum::body::Bytes::from(vec![b' '; 64 * 1024])), + (), + )) + }); + axum::body::Body::from_stream(chunks) + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/certs", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let auth = Arc::new(auth_for(&url, Duration::ZERO)); + + let fetched = tokio::time::timeout(Duration::from_secs(3), auth.fetch()).await; + assert_eq!( + fetched.map(|r| r.err()), + Ok(Some("document too large".into())) + ); + } + + /// `auth_for` already holding `signers`' keys, which are past their age. + async fn with_stale_keys(url: &str, signers: &[&Signer]) -> Arc { + let mut auth = auth_for(url, Duration::ZERO); + auth.keys_max_age = Duration::ZERO; + *auth.keys.write().await = Some((Arc::new(keyset(signers)), Instant::now())); + Arc::new(auth) + } + + // Keys past their age still verify what they signed, so the refresh they + // call for must not hold the request up, however slow the key endpoint. + #[tokio::test] + async fn a_stale_key_set_answers_without_waiting_for_its_refresh() { + // Takes connections and never answers: a fetch waits out its timeout. + let blackhole = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/certs", blackhole.local_addr().unwrap()); + let rsa = rsa_signer("r1"); + let auth = with_stale_keys(&url, &[&rsa]).await; + + let answer = tokio::time::timeout( + Duration::from_secs(3), + auth.authenticate(&with_assertion(&rsa.sign(&live_claims()))), + ) + .await; + assert_eq!(answer, Ok(Ok(()))); + } + + // The refresh runs beside the request; once it is in, a key the endpoint + // no longer publishes is refused. + #[tokio::test] + async fn a_stale_key_set_is_refreshed_beside_the_request() { + let (old, new) = (ec_signer("old"), ec_signer("new")); + let server = key_server(json!({"keys": [new.jwk()]})).await; + let auth = with_stale_keys(&server.url, &[&old]).await; + + let signed_by_old = with_assertion(&old.sign(&live_claims())); + assert_eq!(auth.authenticate(&signed_by_old).await, Ok(())); + // Generous: it only has to catch a refresh that never lands. + let deadline = Instant::now() + Duration::from_secs(30); + while !auth + .cached() + .await + .is_some_and(|(keys, _)| keys.contains("new")) + { + assert!(Instant::now() < deadline, "the refresh never landed"); + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!( + auth.authenticate(&signed_by_old).await, + Err(Rejection::UnknownKey) + ); + assert_eq!( + auth.authenticate(&with_assertion(&new.sign(&live_claims()))) + .await, + Ok(()) + ); + } + + // Requests that find the keys stale while a refresh is out do not start + // one each. + #[tokio::test] + async fn stale_keys_start_one_refresh_at_a_time() { + let rsa = rsa_signer("r1"); + let server = + key_server_answering_after(json!({"keys": [rsa.jwk()]}), Duration::from_millis(300)) + .await; + let auth = with_stale_keys(&server.url, &[&rsa]).await; + let headers = with_assertion(&rsa.sign(&live_claims())); + + let answers = + futures_util::future::join_all((0..8).map(|_| auth.authenticate(&headers))).await; + assert!( + answers.iter().all(|answer| *answer == Ok(())), + "{answers:?}" + ); + // Generous: it only has to catch a refresh that never starts or ends. + let deadline = Instant::now() + Duration::from_secs(30); + while server.hits.load(Ordering::SeqCst) == 0 { + assert!(Instant::now() < deadline, "no refresh started"); + tokio::time::sleep(Duration::from_millis(10)).await; + } + // A second fetch, had one started, would show within this time; a slow + // machine can only hide one, never invent it. + tokio::time::sleep(Duration::from_millis(600)).await; + assert_eq!(server.hits.load(Ordering::SeqCst), 1); + + // Once that refresh is over, keys found stale again start the next one. + while auth.refreshing_in_background.load(Ordering::SeqCst) { + assert!(Instant::now() < deadline, "the first refresh never ended"); + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(auth.authenticate(&headers).await, Ok(())); + while server.hits.load(Ordering::SeqCst) < 2 { + assert!(Instant::now() < deadline, "no second refresh started"); + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + + #[tokio::test] + async fn refuses_a_request_without_the_header() { + let auth = Arc::new(auth_for("http://127.0.0.1:9/certs", Duration::ZERO)); + assert_eq!( + auth.authenticate(&HeaderMap::new()).await, + Err(Rejection::Missing) + ); + } +} diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 024058d60d..2df32e35e6 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -18,18 +18,36 @@ use super::{auth, handlers, ws}; use crate::app_state::AppState; use tracing::Instrument; +/// Token-only authentication: the desktop app's web service and the tests. pub fn build_router( state: Arc, token: String, static_dir: std::path::PathBuf, shutdown_signal: Arc, +) -> Router { + build_router_with_proxy_auth(state, token, None, static_dir, shutdown_signal) +} + +/// `proxy_auth` turns on the opt-in proxy-JWT authentication beside the +/// token (`CODEG_AUTH=proxy-jwt`, standalone `codeg-server` only); `None` is +/// the same as `build_router`. +pub fn build_router_with_proxy_auth( + state: Arc, + token: String, + proxy_auth: Option>, + static_dir: std::path::PathBuf, + shutdown_signal: Arc, ) -> Router { let cors = CorsLayer::new() .allow_origin(Any) .allow_methods(Any) .allow_headers(Any); - let token_for_ws = token.clone(); + let web_auth = auth::WebAuth { + token: token.clone(), + proxy: proxy_auth, + }; + let web_auth_for_ws = web_auth.clone(); let token_for_cli_hello = token.clone(); let api = Router::new() @@ -1983,7 +2001,7 @@ pub fn build_router( // Catch-all .fallback(api_not_found) .layer(middleware::from_fn(move |req, next| { - auth::require_token(req, next, token.clone()) + auth::require_auth(req, next, web_auth.clone()) })); // Public endpoints — no token required. @@ -2045,7 +2063,7 @@ pub fn build_router( // the viewer. `.instrument()` the downstream future; never `.enter()` across // an await, which corrupts the span stack. // - // Layer order: the protected router's `auth::require_token` layer was added + // Layer order: the protected router's `auth::require_auth` layer was added // (above) BEFORE this `.layer()`, and this layer is applied to the MERGED // router, so it is the OUTERMOST layer. The instrumented `next.run(req)` // future therefore wraps routing + auth + handler — auth-reject logs land @@ -2068,7 +2086,7 @@ pub fn build_router( get(crate::web::browser_tunnel::ws_handler), ) .layer(middleware::from_fn(move |req, next| { - auth::require_token(req, next, token_for_ws.clone()) + auth::require_auth(req, next, web_auth_for_ws.clone()) })); // Static file serving. diff --git a/src/app/login/page.test.tsx b/src/app/login/page.test.tsx new file mode 100644 index 0000000000..5fbfede4a7 --- /dev/null +++ b/src/app/login/page.test.tsx @@ -0,0 +1,93 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +const router = vi.hoisted(() => ({ replace: vi.fn() })) +vi.mock("next/navigation", () => ({ useRouter: () => router })) +vi.mock("@/lib/platform", () => ({ isDesktop: () => false })) + +import LoginPage from "./page" +import enMessages from "@/i18n/messages/en.json" + +const reply = (status: number, challenge?: string) => ({ + ok: status >= 200 && status < 300, + status, + headers: new Headers(challenge ? { "WWW-Authenticate": challenge } : {}), +}) + +let fetchMock: ReturnType + +beforeEach(() => { + router.replace.mockClear() + fetchMock = vi.fn() + vi.stubGlobal("fetch", fetchMock) +}) + +afterEach(() => { + vi.unstubAllGlobals() + localStorage.clear() +}) + +function submit(token: string) { + render( + + + + ) + fireEvent.change(screen.getByPlaceholderText("Access Token"), { + target: { value: token }, + }) + fireEvent.click(screen.getByRole("button", { name: "Connect" })) +} + +describe("login page", () => { + it("asks a proxy for a 401 instead of a redirect", async () => { + // The page first asks whether a proxy signed the browser in (no token); + // this server wants one. The form's request follows. + fetchMock + .mockResolvedValueOnce(reply(401, 'Bearer realm="codeg"')) + .mockResolvedValue(reply(200)) + submit("tok") + await vi.waitFor(() => expect(router.replace).toHaveBeenCalled()) + expect(fetchMock.mock.calls[1][1].headers).toMatchObject({ + Authorization: "Bearer tok", + "X-Requested-With": "XMLHttpRequest", + }) + }) + + it("calls the token invalid when Codeg rejects it", async () => { + fetchMock.mockResolvedValue(reply(401, 'Bearer realm="codeg"')) + submit("bad") + expect( + await screen.findByText("Invalid token. Please check and try again.") + ).toBeInTheDocument() + }) + + it("does not blame the token when a proxy in front of Codeg refused", async () => { + fetchMock.mockResolvedValue( + reply(401, 'Cloudflare-Access resource_metadata="https://x.test/m"') + ) + submit("tok") + expect( + await screen.findByText(/Your sign-in to the proxy/) + ).toBeInTheDocument() + expect( + screen.queryByText("Invalid token. Please check and try again.") + ).not.toBeInTheDocument() + }) +}) + +describe("login page behind a proxy", () => { + it("goes straight to the workspace when the proxy already admits the browser", async () => { + fetchMock.mockResolvedValue(reply(200)) + render( + + + + ) + await vi.waitFor(() => + expect(router.replace).toHaveBeenCalledWith("/workspace") + ) + expect(localStorage.getItem("codeg_proxy_auth")).toBe("1") + }) +}) diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index a02ef36fe3..d27fc50c7f 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -4,10 +4,16 @@ import { useEffect, useState } from "react" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" import { isDesktop } from "@/lib/platform" +import { + codegAuthHeaders, + probeProxyAuthentication, + unauthorizedSource, +} from "@/lib/transport/web-auth" export default function LoginPage() { const router = useRouter() const t = useTranslations("LoginPage") + const tConnection = useTranslations("WebConnection") const [token, setToken] = useState("") const [error, setError] = useState("") const [loading, setLoading] = useState(false) @@ -16,6 +22,19 @@ export default function LoginPage() { document.title = t("documentTitle") }, [t]) + // Signed in through a proxy in front of Codeg (CODEG_AUTH=proxy-jwt), there + // is no token to ask for. + useEffect(() => { + if (isDesktop()) return + let cancelled = false + void probeProxyAuthentication().then((result) => { + if (!cancelled && result === "proxy") router.replace("/workspace") + }) + return () => { + cancelled = true + } + }, [router]) + // Desktop users skip login entirely if (isDesktop()) { router.replace("/workspace") @@ -33,7 +52,7 @@ export default function LoginPage() { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, + ...codegAuthHeaders(token), }, body: "{}", }) @@ -41,6 +60,12 @@ export default function LoginPage() { if (res.ok) { localStorage.setItem("codeg_token", token) router.replace("/workspace") + } else if ( + res.status === 401 && + unauthorizedSource(res.headers.get("www-authenticate")) === "proxy" + ) { + // The proxy in front of Codeg refused before the token was checked. + setError(tConnection("proxySignInDescription")) } else if (res.status === 401) { setError(t("invalidToken")) } else { diff --git a/src/app/page.test.tsx b/src/app/page.test.tsx new file mode 100644 index 0000000000..16ac25727e --- /dev/null +++ b/src/app/page.test.tsx @@ -0,0 +1,78 @@ +import { render, waitFor } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +const router = vi.hoisted(() => ({ replace: vi.fn() })) +vi.mock("next/navigation", () => ({ useRouter: () => router })) +vi.mock("@/lib/platform", () => ({ isDesktop: () => false })) + +import Page from "./page" + +const reply = (status: number, challenge?: string) => ({ + ok: status >= 200 && status < 300, + status, + headers: new Headers(challenge ? { "WWW-Authenticate": challenge } : {}), +}) + +let fetchMock: ReturnType + +beforeEach(() => { + router.replace.mockClear() + localStorage.setItem("codeg_token", "tok") + fetchMock = vi.fn() + vi.stubGlobal("fetch", fetchMock) +}) + +afterEach(() => { + vi.unstubAllGlobals() + localStorage.clear() +}) + +describe("root page token check", () => { + it("asks a proxy for a 401 instead of a redirect", async () => { + fetchMock.mockResolvedValue(reply(200)) + render() + await waitFor(() => expect(router.replace).toHaveBeenCalled()) + expect(fetchMock.mock.calls[0][1].headers).toMatchObject({ + Authorization: "Bearer tok", + "X-Requested-With": "XMLHttpRequest", + }) + }) + + it("clears the token and goes to /login when Codeg rejects it", async () => { + fetchMock.mockResolvedValue(reply(401, 'Bearer realm="codeg"')) + render() + await waitFor(() => expect(router.replace).toHaveBeenCalledWith("/login")) + expect(localStorage.getItem("codeg_token")).toBeNull() + }) + + it("keeps the token when the 401 came from a proxy in front of Codeg", async () => { + fetchMock.mockResolvedValue( + reply(401, 'Cloudflare-Access resource_metadata="https://x.test/m"') + ) + render() + // The workspace's connection guard offers the reload from there. + await waitFor(() => + expect(router.replace).toHaveBeenCalledWith("/workspace") + ) + expect(localStorage.getItem("codeg_token")).toBe("tok") + }) +}) + +describe("root page without a token", () => { + beforeEach(() => localStorage.removeItem("codeg_token")) + + it("enters the workspace when a proxy in front of Codeg admits the browser", async () => { + fetchMock.mockResolvedValue(reply(200)) + render() + await waitFor(() => + expect(router.replace).toHaveBeenCalledWith("/workspace") + ) + expect(localStorage.getItem("codeg_proxy_auth")).toBe("1") + }) + + it("asks for the token when the server wants one", async () => { + fetchMock.mockResolvedValue(reply(401, 'Bearer realm="codeg"')) + render() + await waitFor(() => expect(router.replace).toHaveBeenCalledWith("/login")) + }) +}) diff --git a/src/app/page.tsx b/src/app/page.tsx index e5f8ed643e..22883c955d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,6 +3,11 @@ import { useEffect } from "react" import { useRouter } from "next/navigation" import { isDesktop } from "@/lib/platform" +import { + codegAuthHeaders, + probeProxyAuthentication, + unauthorizedSource, +} from "@/lib/transport/web-auth" export default function Page() { const router = useRouter() @@ -14,7 +19,12 @@ export default function Page() { // Web mode: validate token before entering app const token = localStorage.getItem("codeg_token") if (!token) { - router.replace("/login") + // A proxy in front of Codeg may have signed the user in already + // (CODEG_AUTH=proxy-jwt); an unreachable server leaves that to the + // in-app connection dialog, as with a token. + void probeProxyAuthentication().then((result) => { + router.replace(result === "unauthorized" ? "/login" : "/workspace") + }) return } // Verify token is still valid @@ -22,7 +32,7 @@ export default function Page() { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, + ...codegAuthHeaders(token), }, body: "{}", }) @@ -31,14 +41,18 @@ export default function Page() { router.replace("/workspace") return } - if (res.status === 401) { + if ( + res.status === 401 && + unauthorizedSource(res.headers.get("www-authenticate")) === "codeg" + ) { // Token genuinely rejected → clear it and re-authenticate. localStorage.removeItem("codeg_token") router.replace("/login") return } - // Server reachable but unhealthy (5xx / proxy error). Keep the token - // and enter the app; the in-app reconnect dialog handles recovery + // Server reachable but unhealthy (5xx / proxy error), or a proxy in + // front of Codeg refused (its own session expired). Keep the token + // and enter the app; the in-app connection dialog handles recovery // instead of bouncing a valid session to /login. router.replace("/workspace") }) diff --git a/src/components/connection/web-connection-guard.test.tsx b/src/components/connection/web-connection-guard.test.tsx index 0be1c0b4d5..20134ac39a 100644 --- a/src/components/connection/web-connection-guard.test.tsx +++ b/src/components/connection/web-connection-guard.test.tsx @@ -5,11 +5,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" // Controllable stand-in for the connection store. `vi.hoisted` guarantees the // shared object exists before the hoisted `vi.mock` factory runs. const store = vi.hoisted(() => { - let state: "connected" | "reconnecting" | "unauthorized" = "connected" + type State = + | "connected" + | "reconnecting" + | "unauthorized" + | "proxyUnauthorized" + let state: State = "connected" const listeners = new Set<() => void>() return { getState: () => state, - setState: (s: "connected" | "reconnecting" | "unauthorized") => { + setState: (s: State) => { state = s for (const l of listeners) l() }, @@ -105,6 +110,27 @@ describe("WebConnectionGuard", () => { expect(store.redirectToCodegLogin).toHaveBeenCalledTimes(1) }) + it("offers a reload, not the token login, when the proxy's sign-in expired", () => { + const reload = vi.fn() + vi.stubGlobal("location", { ...window.location, reload }) + renderGuard() + act(() => store.setState("proxyUnauthorized")) + // Surfaced at once, like unauthorized: a rejected session is definitive. + expect(screen.getByText("Sign-in expired")).toBeInTheDocument() + + fireEvent.click(screen.getByRole("button", { name: "Reload" })) + expect(reload).toHaveBeenCalledTimes(1) + expect(store.redirectToCodegLogin).not.toHaveBeenCalled() + vi.unstubAllGlobals() + }) + + it("still offers the token login from the proxy dialog", () => { + renderGuard() + act(() => store.setState("proxyUnauthorized")) + fireEvent.click(screen.getByRole("button", { name: "Go to login" })) + expect(store.redirectToCodegLogin).toHaveBeenCalledTimes(1) + }) + it("dismisses automatically once the connection recovers", () => { renderGuard() act(() => store.setState("reconnecting")) diff --git a/src/components/connection/web-connection-guard.tsx b/src/components/connection/web-connection-guard.tsx index 95e0bfa307..a2e6c69234 100644 --- a/src/components/connection/web-connection-guard.tsx +++ b/src/components/connection/web-connection-guard.tsx @@ -26,8 +26,8 @@ import { redirectToCodegLogin } from "@/lib/transport/web-auth" // surfacing a modal for every transient drop would flicker. The transport's // state machine flips to "reconnecting" instantly; this delay is purely the // UI deciding when the interruption is worth interrupting the user over. -// `unauthorized` bypasses this — a rejected token is a definitive signal worth -// telling the user about immediately. +// `unauthorized` and `proxyUnauthorized` bypass this — a rejected session is a +// definitive signal worth telling the user about immediately. const RECONNECT_DIALOG_GRACE_MS = 4_000 /** @@ -87,7 +87,12 @@ export function WebConnectionGuard() { const showReconnecting = state === "reconnecting" && graceElapsed const showUnauthorized = state === "unauthorized" - const open = showReconnecting || showUnauthorized + // A proxy in front of Codeg (e.g. Cloudflare Access) rejected the request: + // its session expired, not the Codeg token. A top-level reload lets the + // proxy run its own login flow and keeps the token; "Go to login" stays as + // the way out should the proxy keep refusing after the reload. + const showProxyUnauthorized = state === "proxyUnauthorized" + const open = showReconnecting || showUnauthorized || showProxyUnauthorized if (!open) return null @@ -101,25 +106,38 @@ export function WebConnectionGuard() { > - {showUnauthorized ? ( + {showUnauthorized || showProxyUnauthorized ? ( ) : ( )} - {showUnauthorized - ? t("sessionExpiredTitle") - : t("disconnectedTitle")} + {showProxyUnauthorized + ? t("proxySignInTitle") + : showUnauthorized + ? t("sessionExpiredTitle") + : t("disconnectedTitle")} - {showUnauthorized - ? t("sessionExpiredDescription") - : t("reconnectingDescription")} + {showProxyUnauthorized + ? t("proxySignInDescription") + : showUnauthorized + ? t("sessionExpiredDescription") + : t("reconnectingDescription")} - {showUnauthorized ? ( + {showProxyUnauthorized ? ( + <> + + + + ) : showUnauthorized ? ( diff --git a/src/components/connection/web-version-notice.test.tsx b/src/components/connection/web-version-notice.test.tsx index 2a877a9ccd..11357f79a3 100644 --- a/src/components/connection/web-version-notice.test.tsx +++ b/src/components/connection/web-version-notice.test.tsx @@ -37,6 +37,9 @@ vi.mock("@/lib/transport/web-connection-store", () => ({ })) vi.mock("@/lib/transport/web-auth", () => ({ getCodegToken: mocks.token, + // The component asks for credentials (token or proxy sign-in); here the + // token stands for them. + hasCodegCredentials: () => Boolean(mocks.token()), })) vi.mock("@/lib/updater", () => ({ getRunningServerVersion: mocks.serverVersion, diff --git a/src/components/connection/web-version-notice.tsx b/src/components/connection/web-version-notice.tsx index b4fc9615ff..8b39c332fe 100644 --- a/src/components/connection/web-version-notice.tsx +++ b/src/components/connection/web-version-notice.tsx @@ -8,7 +8,7 @@ import { isOwnServerRestartRunning, subscribeOwnServerRestart, } from "@/lib/updater" -import { getCodegToken } from "@/lib/transport/web-auth" +import { hasCodegCredentials } from "@/lib/transport/web-auth" import { getWebConnectionServerSnapshot, getWebConnectionSnapshot, @@ -51,9 +51,9 @@ export function WebVersionNotice() { // development build is not one, whatever its version says. const pageVersion = process.env.CODEG_APP_VERSION if (!pageVersion || process.env.NODE_ENV !== "production") return - // Without a token the request would be refused, and a refusal reads as an - // expired session. - if (!usesWebTransport() || !getCodegToken()) return + // Without credentials the request would be refused, and a refusal reads + // as an expired session. + if (!usesWebTransport() || !hasCodegCredentials()) return let cancelled = false void getRunningServerVersion().then((serverVersion) => { // An older server does not report its version. diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 3b3e49f55d..0939b88c37 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -5342,6 +5342,8 @@ "reconnectNow": "إعادة الاتصال الآن", "sessionExpiredTitle": "انتهت الجلسة", "sessionExpiredDescription": "لم تعد جلستك صالحة. يُرجى تسجيل الدخول مرة أخرى للمتابعة.", + "proxySignInTitle": "انتهت صلاحية تسجيل الدخول", + "proxySignInDescription": "انتهت صلاحية تسجيل دخولك إلى الوكيل الموجود أمام هذا الخادم. أعد تحميل الصفحة لتسجيل الدخول مجددًا.", "goToLogin": "الانتقال إلى تسجيل الدخول", "versionChangedTitle": "يعمل الخادم الآن بإصدار Codeg {version}", "versionChangedDescription": "لا تزال هذه الصفحة على الإصدار {version}. أعد تحميلها لاستخدام إصدار الخادم.", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 1b05271eb0..2bf3ffe252 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -5342,6 +5342,8 @@ "reconnectNow": "Jetzt neu verbinden", "sessionExpiredTitle": "Sitzung abgelaufen", "sessionExpiredDescription": "Deine Sitzung ist nicht mehr gültig. Bitte melde dich erneut an, um fortzufahren.", + "proxySignInTitle": "Anmeldung abgelaufen", + "proxySignInDescription": "Deine Anmeldung beim Proxy vor diesem Server ist abgelaufen. Lade die Seite neu, um dich erneut anzumelden.", "goToLogin": "Zur Anmeldung", "versionChangedTitle": "Auf dem Server läuft jetzt Codeg {version}", "versionChangedDescription": "Diese Seite läuft noch mit {version}. Lade sie neu, um die Version des Servers zu verwenden.", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 35288fef1b..1f210f604e 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -5342,6 +5342,8 @@ "reconnectNow": "Reconnect now", "sessionExpiredTitle": "Session expired", "sessionExpiredDescription": "Your session is no longer valid. Please sign in again to continue.", + "proxySignInTitle": "Sign-in expired", + "proxySignInDescription": "Your sign-in to the proxy in front of this server has expired. Reload the page to sign in again.", "goToLogin": "Go to login", "versionChangedTitle": "The server is now running Codeg {version}", "versionChangedDescription": "This page is still on {version}. Reload it to use the server's version.", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 3aa2be9e01..da9ada5735 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -5342,6 +5342,8 @@ "reconnectNow": "Volver a conectar ahora", "sessionExpiredTitle": "Sesión caducada", "sessionExpiredDescription": "Tu sesión ya no es válida. Inicia sesión de nuevo para continuar.", + "proxySignInTitle": "Inicio de sesión caducado", + "proxySignInDescription": "Tu inicio de sesión en el proxy que está delante de este servidor ha caducado. Recarga la página para volver a iniciar sesión.", "goToLogin": "Ir al inicio de sesión", "versionChangedTitle": "El servidor ahora ejecuta Codeg {version}", "versionChangedDescription": "Esta página sigue en la versión {version}. Recárgala para usar la versión del servidor.", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index e2647a8ccf..29ec494abb 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -5342,6 +5342,8 @@ "reconnectNow": "Se reconnecter maintenant", "sessionExpiredTitle": "Session expirée", "sessionExpiredDescription": "Votre session n'est plus valide. Veuillez vous reconnecter pour continuer.", + "proxySignInTitle": "Connexion expirée", + "proxySignInDescription": "Votre connexion au proxy placé devant ce serveur a expiré. Rechargez la page pour vous reconnecter.", "goToLogin": "Aller à la connexion", "versionChangedTitle": "Le serveur exécute désormais Codeg {version}", "versionChangedDescription": "Cette page est encore en version {version}. Rechargez-la pour utiliser la version du serveur.", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index a45a4fe947..d0d1874836 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -5342,6 +5342,8 @@ "reconnectNow": "今すぐ再接続", "sessionExpiredTitle": "セッションの有効期限が切れました", "sessionExpiredDescription": "セッションが無効になりました。続行するには再度サインインしてください。", + "proxySignInTitle": "サインインの有効期限切れ", + "proxySignInDescription": "このサーバーの前段にあるプロキシのサインインが期限切れになりました。ページを再読み込みして再度サインインしてください。", "goToLogin": "ログインへ移動", "versionChangedTitle": "サーバーは Codeg {version} で動作しています", "versionChangedDescription": "このページはまだ {version} です。再読み込みするとサーバーのバージョンを使用します。", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 84ed15428e..b9b506ab63 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -5342,6 +5342,8 @@ "reconnectNow": "지금 다시 연결", "sessionExpiredTitle": "세션이 만료되었습니다", "sessionExpiredDescription": "세션이 더 이상 유효하지 않습니다. 계속하려면 다시 로그인하세요.", + "proxySignInTitle": "로그인 만료", + "proxySignInDescription": "이 서버 앞단 프록시의 로그인이 만료되었습니다. 페이지를 새로고침하여 다시 로그인하세요.", "goToLogin": "로그인으로 이동", "versionChangedTitle": "서버가 Codeg {version}(으)로 실행 중입니다", "versionChangedDescription": "이 페이지는 아직 {version}입니다. 새로고침하면 서버의 버전을 사용합니다.", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 9481e44ee7..d2047ed7da 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -5342,6 +5342,8 @@ "reconnectNow": "Reconectar agora", "sessionExpiredTitle": "Sessão expirada", "sessionExpiredDescription": "Sua sessão não é mais válida. Faça login novamente para continuar.", + "proxySignInTitle": "Sessão expirada", + "proxySignInDescription": "Sua sessão no proxy à frente deste servidor expirou. Recarregue a página para entrar novamente.", "goToLogin": "Ir para o login", "versionChangedTitle": "O servidor agora executa o Codeg {version}", "versionChangedDescription": "Esta página ainda está na versão {version}. Recarregue-a para usar a versão do servidor.", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index b4b1a362b0..f4ad970db3 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -5342,6 +5342,8 @@ "reconnectNow": "立即重连", "sessionExpiredTitle": "会话已过期", "sessionExpiredDescription": "登录状态已失效,请重新登录以继续。", + "proxySignInTitle": "登录已过期", + "proxySignInDescription": "此服务器前置代理的登录已过期。请重新加载页面以再次登录。", "goToLogin": "前往登录", "versionChangedTitle": "服务器已运行 Codeg {version}", "versionChangedDescription": "此页面仍是 {version},刷新后即可使用服务器上的版本。", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 86567c400d..e52d82488a 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -5342,6 +5342,8 @@ "reconnectNow": "立即重新連線", "sessionExpiredTitle": "工作階段已過期", "sessionExpiredDescription": "登入狀態已失效,請重新登入以繼續。", + "proxySignInTitle": "登入已過期", + "proxySignInDescription": "此伺服器前置代理的登入已過期。請重新載入頁面以再次登入。", "goToLogin": "前往登入", "versionChangedTitle": "伺服器已執行 Codeg {version}", "versionChangedDescription": "此頁面仍是 {version},重新整理後即可使用伺服器上的版本。", diff --git a/src/lib/api.ts b/src/lib/api.ts index 97dd726f8a..a5e4c3ba10 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -6,7 +6,11 @@ import { isRemoteDesktopMode, notifyRemoteDesktopUnauthorized, } from "./transport" -import { getCodegToken } from "./transport/web-auth" +import { + codegAuthHeaders, + getCodegToken, + unauthorizedSource, +} from "./transport/web-auth" import { notifyWebUnauthorized } from "./transport/web-connection-store" import { getCurrentEffectiveAppLocale } from "./i18n" import { @@ -4323,11 +4327,13 @@ export async function uploadAttachment( const res = await fetch(`${window.location.origin}/api/upload_attachment`, { method: "POST", - headers: { Authorization: `Bearer ${token}` }, + headers: codegAuthHeaders(token), body: form, }) if (res.status === 401) { - notifyWebUnauthorized() + notifyWebUnauthorized( + unauthorizedSource(res.headers.get("www-authenticate")) + ) throw new Error("Unauthorized") } if (!res.ok) { @@ -4417,9 +4423,7 @@ async function workspaceFileFetch( isMultipart: boolean ): Promise { const token = getCodegToken() - const headers: Record = { - Authorization: `Bearer ${token}`, - } + const headers = codegAuthHeaders(token) if (!isMultipart) { headers["Content-Type"] = "application/json" } @@ -4429,7 +4433,9 @@ async function workspaceFileFetch( body, }) if (res.status === 401) { - notifyWebUnauthorized() + notifyWebUnauthorized( + unauthorizedSource(res.headers.get("www-authenticate")) + ) throw new Error("Unauthorized") } if (!res.ok) { @@ -4487,7 +4493,9 @@ export async function uploadWorkspaceFile( const token = getCodegToken() const xhr = new XMLHttpRequest() xhr.open("POST", `${window.location.origin}/api/upload_workspace_file`) - xhr.setRequestHeader("Authorization", `Bearer ${token}`) + for (const [name, value] of Object.entries(codegAuthHeaders(token))) { + xhr.setRequestHeader(name, value) + } if (args.onProgress) { const onProgress = args.onProgress @@ -4500,7 +4508,9 @@ export async function uploadWorkspaceFile( xhr.onload = () => { if (xhr.status === 401) { - notifyWebUnauthorized() + notifyWebUnauthorized( + unauthorizedSource(xhr.getResponseHeader("www-authenticate")) + ) reject(new Error("Unauthorized")) return } @@ -5826,7 +5836,9 @@ export async function uploadBackupWeb( const token = getCodegToken() const xhr = new XMLHttpRequest() xhr.open("POST", `${window.location.origin}/api/backup_upload`) - xhr.setRequestHeader("Authorization", `Bearer ${token}`) + for (const [name, value] of Object.entries(codegAuthHeaders(token))) { + xhr.setRequestHeader(name, value) + } if (onProgress) { xhr.upload.onprogress = (event) => { if (event.lengthComputable) onProgress(event.loaded, event.total) @@ -5834,7 +5846,9 @@ export async function uploadBackupWeb( } xhr.onload = () => { if (xhr.status === 401) { - notifyWebUnauthorized() + notifyWebUnauthorized( + unauthorizedSource(xhr.getResponseHeader("www-authenticate")) + ) reject(new Error("Unauthorized")) return } diff --git a/src/lib/transport/web-auth.test.ts b/src/lib/transport/web-auth.test.ts new file mode 100644 index 0000000000..ce51c0a8b9 --- /dev/null +++ b/src/lib/transport/web-auth.test.ts @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { + codegAuthHeaders, + hasCodegCredentials, + isProxyAuthenticated, + probeProxyAuthentication, + unauthorizedSource, +} from "./web-auth" + +afterEach(() => { + localStorage.clear() + vi.unstubAllGlobals() +}) + +// The challenge as fetch reads it from a real 401 response. +const with401 = (challenge?: string) => + new Response(null, { + status: 401, + headers: challenge ? { "WWW-Authenticate": challenge } : {}, + }).headers.get("www-authenticate") + +describe("codegAuthHeaders", () => { + it("carries the bearer token and asks a proxy for a 401 instead of a redirect", () => { + expect(codegAuthHeaders("tok")).toEqual({ + Authorization: "Bearer tok", + "X-Requested-With": "XMLHttpRequest", + }) + }) +}) + +describe("unauthorizedSource", () => { + it("attributes a 401 carrying Codeg's challenge to Codeg", () => { + expect(unauthorizedSource(with401('Bearer realm="codeg"'))).toBe("codeg") + }) + + it("matches the challenge case-insensitively, as HTTP auth schemes are", () => { + expect(unauthorizedSource(with401('bearer REALM="codeg"'))).toBe("codeg") + }) + + it("attributes a 401 with another challenge to the proxy", () => { + expect( + unauthorizedSource( + with401('Cloudflare-Access resource_metadata="https://x.test/m"') + ) + ).toBe("proxy") + }) + + it("attributes a 401 with no challenge at all to the proxy", () => { + expect(unauthorizedSource(with401())).toBe("proxy") + }) + + it("does not take another realm's bearer challenge for Codeg's", () => { + expect(unauthorizedSource(with401('Bearer realm="codeg-other"'))).toBe( + "proxy" + ) + }) +}) + +describe("proxy authentication (CODEG_AUTH=proxy-jwt)", () => { + it("sends no Authorization header when there is no token", () => { + expect(codegAuthHeaders("")).toEqual({ + "X-Requested-With": "XMLHttpRequest", + }) + }) + + it("counts a token or a proxy sign-in as credentials", () => { + expect(hasCodegCredentials()).toBe(false) + localStorage.setItem("codeg_token", "tok") + expect(hasCodegCredentials()).toBe(true) + localStorage.clear() + localStorage.setItem("codeg_proxy_auth", "1") + expect(hasCodegCredentials()).toBe(true) + }) + + it("attributes every 401 to the proxy while signed in through it without a token", () => { + localStorage.setItem("codeg_proxy_auth", "1") + // No token to re-enter: only the proxy's login can help. + expect(unauthorizedSource('Bearer realm="codeg"')).toBe("proxy") + localStorage.setItem("codeg_token", "tok") + expect(unauthorizedSource('Bearer realm="codeg"')).toBe("codeg") + }) + + it("marks the browser proxy-authenticated when the server admits it without a token", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + vi.stubGlobal("fetch", fetchMock) + await expect(probeProxyAuthentication()).resolves.toBe("proxy") + expect(isProxyAuthenticated()).toBe(true) + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe("/api/health") + expect(init.headers).not.toHaveProperty("Authorization") + expect(init.headers).toMatchObject({ "X-Requested-With": "XMLHttpRequest" }) + }) + + it("clears the mark when the server wants a token", async () => { + localStorage.setItem("codeg_proxy_auth", "1") + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: false, status: 401 }) + ) + await expect(probeProxyAuthentication()).resolves.toBe("unauthorized") + expect(isProxyAuthenticated()).toBe(false) + }) + + it("gives up on a server that takes the request and never answers", async () => { + vi.useFakeTimers() + try { + vi.stubGlobal( + "fetch", + vi.fn( + (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => + reject(new DOMException("aborted", "AbortError")) + ) + }) + ) + ) + const probe = probeProxyAuthentication() + await vi.advanceTimersByTimeAsync(8_000) + await expect(probe).resolves.toBe("unreachable") + } finally { + vi.useRealTimers() + } + }) + + it("reports an unreachable server without touching the mark", async () => { + localStorage.setItem("codeg_proxy_auth", "1") + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed"))) + await expect(probeProxyAuthentication()).resolves.toBe("unreachable") + expect(isProxyAuthenticated()).toBe(true) + }) +}) diff --git a/src/lib/transport/web-auth.ts b/src/lib/transport/web-auth.ts index dde9cc4a2a..a4bca51964 100644 --- a/src/lib/transport/web-auth.ts +++ b/src/lib/transport/web-auth.ts @@ -6,6 +6,89 @@ // call site. const TOKEN_KEY = "codeg_token" +// Set when the server admitted this browser without a token: a proxy in front +// of it vouched for the user (`CODEG_AUTH=proxy-jwt` on codeg-server). +const PROXY_AUTH_KEY = "codeg_proxy_auth" +// As long as the transport's own health probe waits (`web-transport.ts`). +const PROBE_TIMEOUT_MS = 8_000 + +// Codeg marks its own 401 with this bearer challenge (`web/auth.rs`). A 401 +// without it came from something in front of Codeg — typically an +// identity-aware proxy whose session expired — and re-entering the Codeg token +// cannot fix that; a top-level reload lets the proxy run its login flow. +const CODEG_CHALLENGE = /^bearer\s+realm="codeg"(?:\s*,|$)/i + +export type UnauthorizedSource = "codeg" | "proxy" + +export function codegAuthHeaders(token: string): Record { + return { + // Signed in through a proxy there is no token, and an empty bearer is + // noise at best. + ...(token ? { Authorization: `Bearer ${token}` } : {}), + // Asks a proxy in front of Codeg (e.g. Cloudflare Access) to answer an + // expired session with 401 instead of a redirect to its login page on + // another origin, which fetch cannot follow and reports as a network + // error — leaving the UI reconnecting forever. + "X-Requested-With": "XMLHttpRequest", + } +} + +/** + * Who answered a 401, from its `WWW-Authenticate` header (null if absent). + * Signed in through a proxy without a token, every 401 is the proxy's to + * resolve: there is no token to re-enter, only the proxy's login. + */ +export function unauthorizedSource( + challenge: string | null +): UnauthorizedSource { + if (isProxyAuthenticated() && !getCodegToken()) return "proxy" + return CODEG_CHALLENGE.test((challenge ?? "").trim()) ? "codeg" : "proxy" +} + +export function isProxyAuthenticated(): boolean { + return localStorage.getItem(PROXY_AUTH_KEY) === "1" +} + +/** A token, or a proxy that admitted this browser without one. */ +export function hasCodegCredentials(): boolean { + return Boolean(getCodegToken()) || isProxyAuthenticated() +} + +/** + * Asks the server whether it admits this browser without a token, which it + * does only when a proxy in front of it vouched for the user. Remembers the + * answer for the transport; an unreachable server leaves it as it was. + */ +export async function probeProxyAuthentication(): Promise< + "proxy" | "unauthorized" | "unreachable" +> { + // A server that takes the request and never answers counts as unreachable, + // rather than holding the page, or a subscription waiting on this, forever. + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS) + let res: Response + try { + res = await fetch("/api/health", { + method: "POST", + headers: { "Content-Type": "application/json", ...codegAuthHeaders("") }, + body: "{}", + signal: controller.signal, + }) + } catch { + return "unreachable" + } finally { + clearTimeout(timeout) + } + if (res.ok) { + localStorage.setItem(PROXY_AUTH_KEY, "1") + return "proxy" + } + if (res.status === 401) { + localStorage.removeItem(PROXY_AUTH_KEY) + return "unauthorized" + } + return "unreachable" +} export function getCodegToken(): string { return localStorage.getItem(TOKEN_KEY) ?? "" @@ -14,5 +97,6 @@ export function getCodegToken(): string { export function redirectToCodegLogin(): void { if (window.location.pathname.startsWith("/login")) return localStorage.removeItem(TOKEN_KEY) + localStorage.removeItem(PROXY_AUTH_KEY) window.location.href = "/login" } diff --git a/src/lib/transport/web-connection-store.ts b/src/lib/transport/web-connection-store.ts index e4605596ec..3295d72bbd 100644 --- a/src/lib/transport/web-connection-store.ts +++ b/src/lib/transport/web-connection-store.ts @@ -13,6 +13,7 @@ import { detectEnvironment } from "./detect" import { getShellTransport, isRemoteDesktopMode } from "./index" +import type { UnauthorizedSource } from "./web-auth" import type { WebConnState, WebTransport } from "./web-transport" // Module-level constant so `getServerSnapshot` returns a STABLE reference on @@ -68,6 +69,6 @@ export function reconnectWebNow(): void { * `lib/api.ts` that bypasses `WebTransport.call`) into the same unauthorized * dialog state, rather than an abrupt redirect. No-op off the web shell. */ -export function notifyWebUnauthorized(): void { - webTransport()?.markUnauthorized() +export function notifyWebUnauthorized(source: UnauthorizedSource): void { + webTransport()?.markUnauthorized(source) } diff --git a/src/lib/transport/web-transport.test.ts b/src/lib/transport/web-transport.test.ts index f2ecf5e71b..47e25e47a8 100644 --- a/src/lib/transport/web-transport.test.ts +++ b/src/lib/transport/web-transport.test.ts @@ -87,7 +87,23 @@ function connectReady() { } const ok200 = () => ({ status: 200, ok: true, json: async () => ({}) }) -const resp401 = () => ({ status: 401, ok: false, json: async () => ({}) }) +// Codeg's own 401 carries its bearer challenge (`web/auth.rs`); a 401 from an +// identity-aware proxy in front of it carries the proxy's challenge instead. +const resp401 = () => ({ + status: 401, + ok: false, + headers: new Headers({ "WWW-Authenticate": 'Bearer realm="codeg"' }), + json: async () => ({}), +}) +const proxy401 = () => ({ + status: 401, + ok: false, + headers: new Headers({ + "WWW-Authenticate": + 'Cloudflare-Access resource_metadata="https://example.test/.well-known/x"', + }), + json: async () => ({}), +}) describe("WebTransport connection state machine", () => { it("starts connected and the first __ready__ does not fire reconnect callbacks", () => { @@ -302,4 +318,190 @@ describe("WebTransport connection state machine", () => { expect(t.getConnectionSnapshot()).toBe("reconnecting") expect(localStorage.getItem("codeg_token")).toBe("tok") // token preserved }) + + it("sends X-Requested-With on health probes so a proxy answers 401, not a redirect", async () => { + const { ws } = connectReady() + fetchMock.mockResolvedValue(ok200()) + + ws.drop() + await vi.advanceTimersByTimeAsync(1000) + + const [, init] = fetchMock.mock.calls[0] + expect(init.headers).toMatchObject({ + Authorization: "Bearer tok", + "X-Requested-With": "XMLHttpRequest", + }) + }) + + it("enters proxyUnauthorized on a 401 without Codeg's challenge, keeps the token, stops retrying", async () => { + const { t, ws } = connectReady() + fetchMock.mockResolvedValue(proxy401()) + + ws.drop() + await vi.advanceTimersByTimeAsync(1000) + + expect(t.getConnectionSnapshot()).toBe("proxyUnauthorized") + // The Codeg token is still valid; only the proxy's session expired. + expect(localStorage.getItem("codeg_token")).toBe("tok") + + const callsSoFar = fetchMock.mock.calls.length + await vi.advanceTimersByTimeAsync(60_000) + expect(fetchMock.mock.calls.length).toBe(callsSoFar) + }) +}) + +describe("WebTransport.call", () => { + it("sends X-Requested-With alongside the bearer token", async () => { + const t = new WebTransport("http://localhost") + fetchMock.mockResolvedValue({ + status: 200, + ok: true, + text: async () => "{}", + }) + + await t.call("list_things") + + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe("http://localhost/api/list_things") + expect(init.headers).toMatchObject({ + Authorization: "Bearer tok", + "X-Requested-With": "XMLHttpRequest", + }) + }) + + it("classifies a 401 by its challenge: Codeg's → unauthorized, a proxy's → proxyUnauthorized", async () => { + const codeg = new WebTransport("http://localhost") + fetchMock.mockResolvedValueOnce(resp401()) + await expect(codeg.call("x")).rejects.toThrow("Unauthorized") + expect(codeg.getConnectionSnapshot()).toBe("unauthorized") + + const proxied = new WebTransport("http://localhost") + fetchMock.mockResolvedValueOnce(proxy401()) + await expect(proxied.call("x")).rejects.toThrow("Unauthorized") + expect(proxied.getConnectionSnapshot()).toBe("proxyUnauthorized") + }) +}) + +describe("WebTransport signed in through a proxy (no token)", () => { + beforeEach(() => { + localStorage.removeItem("codeg_token") + localStorage.setItem("codeg_proxy_auth", "1") + }) + + it("opens the event socket without a token protocol", () => { + const t = new WebTransport("http://localhost") + t.eventStream() + expect(MockWebSocket.instances).toHaveLength(1) + expect(lastWs().protocols).toEqual(["codeg-events"]) + }) + + it("calls the API without an Authorization header", async () => { + const t = new WebTransport("http://localhost") + fetchMock.mockResolvedValue({ + status: 200, + ok: true, + text: async () => "{}", + }) + await t.call("list_things") + const [, init] = fetchMock.mock.calls[0] + expect(init.headers).not.toHaveProperty("Authorization") + }) + + it("probes health after a drop instead of declaring the session gone", async () => { + const { t, ws } = connectReady() + fetchMock.mockResolvedValue(ok200()) + ws.drop() + await vi.advanceTimersByTimeAsync(1000) + expect(fetchMock).toHaveBeenCalled() + expect(t.getConnectionSnapshot()).toBe("reconnecting") + }) + + it("treats any 401 as the proxy's: a reload is the only way back in", async () => { + const { t, ws } = connectReady() + fetchMock.mockResolvedValue(resp401()) + ws.drop() + await vi.advanceTimersByTimeAsync(1000) + expect(t.getConnectionSnapshot()).toBe("proxyUnauthorized") + }) +}) + +// A browser that opens the workspace directly (a bookmark, a restored tab) +// holds neither a token nor the proxy mark, though a proxy in front of the +// server may already vouch for it: HTTP calls then succeed without either, +// so only asking the server can tell whether to open the event socket. +describe("WebTransport opened without any credential", () => { + beforeEach(() => { + localStorage.removeItem("codeg_token") + localStorage.removeItem("codeg_proxy_auth") + }) + + it("asks the server, and opens the socket once a proxy admits the browser", async () => { + fetchMock.mockResolvedValue(ok200()) + const t = new WebTransport("http://localhost") + const handler = vi.fn() + let resolved = false + const subscribed = t.subscribe("folders", handler).then(() => { + resolved = true + }) + await vi.advanceTimersByTimeAsync(0) + + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe("/api/health") + expect(init.headers).not.toHaveProperty("Authorization") + expect(localStorage.getItem("codeg_proxy_auth")).toBe("1") + expect(MockWebSocket.instances).toHaveLength(1) + expect(lastWs().protocols).toEqual(["codeg-events"]) + + // subscribe() still resolves only once the server-side receiver is in. + lastWs().open() + await vi.advanceTimersByTimeAsync(0) + expect(resolved).toBe(false) + lastWs().ready() + await subscribed + expect(resolved).toBe(true) + lastWs().onmessage?.({ + data: JSON.stringify({ channel: "folders", payload: 1 }), + }) + expect(handler).toHaveBeenCalledWith(1) + }) + + it("changes nothing when the server wants a token, and asks only once", async () => { + fetchMock.mockResolvedValue(resp401()) + const t = new WebTransport("http://localhost") + t.eventStream() + await vi.advanceTimersByTimeAsync(0) + await t.subscribe("folders", () => {}) + await vi.advanceTimersByTimeAsync(60_000) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(MockWebSocket.instances).toHaveLength(0) + // No dialog: on the login page there is nothing to sign back in to. + expect(t.getConnectionSnapshot()).toBe("connected") + expect(localStorage.getItem("codeg_proxy_auth")).toBeNull() + }) + + it("asks again, quietly, while the server cannot be reached", async () => { + fetchMock + .mockRejectedValueOnce(new TypeError("Failed to fetch")) + .mockResolvedValue(ok200()) + const t = new WebTransport("http://localhost") + t.eventStream() + await vi.advanceTimersByTimeAsync(0) + expect(MockWebSocket.instances).toHaveLength(0) + expect(t.getConnectionSnapshot()).toBe("connected") + + await vi.advanceTimersByTimeAsync(1000) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(MockWebSocket.instances).toHaveLength(1) + }) + + it("stops asking once destroyed", async () => { + fetchMock.mockRejectedValue(new TypeError("Failed to fetch")) + const t = new WebTransport("http://localhost") + t.eventStream() + await vi.advanceTimersByTimeAsync(0) + t.destroy() + await vi.advanceTimersByTimeAsync(60_000) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/lib/transport/web-transport.ts b/src/lib/transport/web-transport.ts index 4b55997e95..9468c21e1d 100644 --- a/src/lib/transport/web-transport.ts +++ b/src/lib/transport/web-transport.ts @@ -9,7 +9,14 @@ import type { } from "./types" import { buildCodegWebSocketProtocols } from "./ws-auth" import { parseJsonOrThrow } from "../json-parse-error" -import { getCodegToken } from "./web-auth" +import { + codegAuthHeaders, + getCodegToken, + hasCodegCredentials, + probeProxyAuthentication, + unauthorizedSource, + type UnauthorizedSource, +} from "./web-auth" // 60s covers the worst-case ACP probe path: some agents (Gemini in // particular) burn 8–10s on the Initialize handshake before session/new @@ -46,7 +53,13 @@ const HEALTH_PROBE_TIMEOUT_MS = 8_000 // `subscribeConnection`/`getConnectionSnapshot` so a single global dialog can // reflect it. Distinct from the per-ACP-agent `ConnectionStatus` — this is // the browser↔server transport link, not an agent session. -export type WebConnState = "connected" | "reconnecting" | "unauthorized" +// "proxyUnauthorized" is a 401 that did not come from Codeg: the session of a +// proxy in front of it expired while the Codeg token is still good. +export type WebConnState = + | "connected" + | "reconnecting" + | "unauthorized" + | "proxyUnauthorized" interface WebEvent { channel: string @@ -99,6 +112,14 @@ export class WebTransport implements Transport { // a late 200 can't reopen the socket behind the session-expired dialog. private probeEpoch = 0 private probeController: AbortController | null = null + // Asking the server whether a proxy in front of it admits this browser + // without a token (see `discoverProxyAuth`): the request in flight, the retry + // while the server cannot be reached, how many tries that took, and whether + // the server already said no. + private proxyDiscovery: Promise | null = null + private proxyDiscoveryTimer: ReturnType | null = null + private proxyDiscoveryAttempts = 0 + private proxyDiscoveryRefused = false constructor(baseUrl: string) { this.baseUrl = baseUrl @@ -162,7 +183,7 @@ export class WebTransport implements Transport { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, + ...codegAuthHeaders(token), }, body: JSON.stringify(args ?? {}), signal: controller.signal, @@ -179,8 +200,11 @@ export class WebTransport implements Transport { // Definitive auth failure. Surface the unified unauthorized dialog // rather than an abrupt redirect — a hard `location.href` here would // fight a reconnect dialog that may already be on screen. The user - // re-authenticates from the dialog's "Go to login" action. - this.markUnauthorized() + // re-authenticates from the dialog's "Go to login" action, or reloads + // when the 401 came from a proxy in front of Codeg. + this.markUnauthorized( + unauthorizedSource(res.headers.get("www-authenticate")) + ) throw new Error("Unauthorized") } if (!res.ok) { @@ -210,9 +234,11 @@ export class WebTransport implements Transport { const wrappedHandler = handler as (payload: unknown) => void this.handlers.get(event)!.add(wrappedHandler) - // If WS is not connected but we now have a token, connect - if (!this.ws && getToken()) { - this.connectWs() + // If WS is not connected but we now have credentials, connect. With none, + // ask the server whether a proxy in front of it admits this browser. + if (!this.ws) { + if (hasCodegCredentials()) this.connectWs() + else await this.discoverProxyAuth() } // Gate on the server-side broadcaster receiver actually being subscribed. @@ -222,7 +248,7 @@ export class WebTransport implements Transport { // ACP `Connected` event after a fast Initialize) are silently dropped // because the broadcaster skips `send` when receiver_count == 0, leaving // the UI permanently stuck on "正在连接". - if (getToken()) { + if (hasCodegCredentials()) { await this.waitForReady() } @@ -283,11 +309,12 @@ export class WebTransport implements Transport { /** * Enter the unauthorized state: a definitive 401 was observed (token - * rejected). Stop reconnecting and let the dialog prompt re-login. Does NOT - * clear the token — that happens when the user acts on "Go to login" — so a - * spuriously-cached 401 can't silently wipe a still-valid session. + * rejected, or a proxy's session expired). Stop reconnecting and let the + * dialog prompt re-login. Does NOT clear the token — that happens when the + * user acts on "Go to login" — so a spuriously-cached 401 can't silently + * wipe a still-valid session. */ - markUnauthorized(): void { + markUnauthorized(source: UnauthorizedSource = "codeg"): void { if (this.destroyed) return if (this.reconnectTimer) { clearTimeout(this.reconnectTimer) @@ -297,7 +324,7 @@ export class WebTransport implements Transport { // the socket or re-arm the backoff behind the session-expired dialog. this.invalidateProbe() this.teardownWs() - this.setConnState("unauthorized") + this.setConnState(source === "proxy" ? "proxyUnauthorized" : "unauthorized") } // Bump the epoch (so a pending probe's post-await guard bails) and abort its @@ -333,16 +360,60 @@ export class WebTransport implements Transport { }, } this.eventStreamInstance = new WebEventStream(host) - // If a token is present but the WS hasn't been opened yet (no legacy + // If credentials are present but the WS hasn't been opened yet (no legacy // subscribe has run), trigger the connect now so attach frames sent - // by the consumer have a place to land. - if (!this.ws && getToken()) { - this.connectWs() + // by the consumer have a place to land. With none, ask the server + // whether a proxy in front of it admits this browser. + if (!this.ws) { + if (hasCodegCredentials()) this.connectWs() + else void this.discoverProxyAuth() } } return this.eventStreamInstance } + // Signed in through a proxy, a browser that opens the workspace directly (a + // bookmark, a restored tab, or the root page could not reach the server) + // holds neither a token nor the proxy mark, and its HTTP calls succeed + // without either, so nothing else would ever open the event socket. Only the + // server can tell, so it is asked when a consumer needs the socket. A refusal + // ends the matter without touching the connection state: the token flow + // surfaces its own 401s, and on the login page there is nothing to reconnect. + // An unreachable server is asked again on the reconnect backoff, quietly for + // the same reason. + private discoverProxyAuth(): Promise { + if ( + this.destroyed || + this.proxyDiscoveryRefused || + this.proxyDiscoveryTimer + ) { + return this.proxyDiscovery ?? Promise.resolve() + } + this.proxyDiscovery ??= probeProxyAuthentication().then((result) => { + this.proxyDiscovery = null + if (this.destroyed) return + if (result === "proxy") { + if (!this.ws) this.connectWs() + return + } + if (result === "unauthorized") { + this.proxyDiscoveryRefused = true + return + } + const shift = Math.min(this.proxyDiscoveryAttempts++, 8) + this.proxyDiscoveryTimer = setTimeout( + () => { + this.proxyDiscoveryTimer = null + if (this.ws) return + if (hasCodegCredentials()) this.connectWs() + else void this.discoverProxyAuth() + }, + Math.min(WS_BACKOFF_INITIAL_MS << shift, WS_BACKOFF_MAX_MS) + ) + }) + return this.proxyDiscovery + } + private sendWsFrame(frame: object): boolean { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return false try { @@ -354,12 +425,14 @@ export class WebTransport implements Transport { } } - // Returns false when there's no token to connect with (caller decides how to + // Returns false when there are no credentials to connect with (caller decides how to // surface that — probeHealth treats it as unauthorized rather than a silent // dead-end). Returns true once a socket has been created. private connectWs(): boolean { + if (!hasCodegCredentials()) return false + // Empty when a proxy signed this browser in: the socket then carries only + // the event protocol, and the proxy's assertion rides on the upgrade. const token = getToken() - if (!token) return false // Drop any lingering socket before opening a new one. teardownWs detaches // handlers first, so the old socket's async onclose can't fire after we've @@ -497,8 +570,8 @@ export class WebTransport implements Transport { private async probeHealth(): Promise { if (this.destroyed || this.healthProbeInFlight) return const token = getToken() - if (!token) { - // No token to validate — treat as unauthorized rather than spin. + if (!hasCodegCredentials()) { + // No credentials to validate — treat as unauthorized rather than spin. this.markUnauthorized() return } @@ -515,7 +588,7 @@ export class WebTransport implements Transport { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, + ...codegAuthHeaders(token), }, body: "{}", signal: controller.signal, @@ -524,13 +597,16 @@ export class WebTransport implements Transport { // a stale completion must not reopen the socket or re-arm the backoff. if (this.destroyed || epoch !== this.probeEpoch) return if (res.status === 401) { - // Server is up and rejected the token → session genuinely expired. - this.markUnauthorized() + // Server (or the proxy in front of it) rejected the request → the + // session genuinely expired. + this.markUnauthorized( + unauthorizedSource(res.headers.get("www-authenticate")) + ) return } if (res.ok) { // Server up + token valid → rebuild the WS. onopen and the `__ready__` - // frame will flip the dialog back to "connected". If the token vanished + // frame will flip the dialog back to "connected". If the credentials vanished // between probe start and now (e.g. logout in another tab), connectWs // reports false → surface unauthorized rather than a stuck "reconnecting". if (!this.connectWs()) this.markUnauthorized() @@ -578,6 +654,10 @@ export class WebTransport implements Transport { clearTimeout(this.reconnectTimer) this.reconnectTimer = null } + if (this.proxyDiscoveryTimer) { + clearTimeout(this.proxyDiscoveryTimer) + this.proxyDiscoveryTimer = null + } // Abort any in-flight health probe; the `destroyed` guard already blocks // its post-await branches, but this frees the request promptly. this.invalidateProbe()