Repository navigation
feat(server): optional sign-in through an identity-aware proxy (CODEG_AUTH=proxy-jwt) - #985
Conversation
…necting forever Behind an identity-aware proxy (Cloudflare Access in the reporter's setup), an expired proxy session made the API fetch receive a 302 to the proxy's login page on another origin. fetch cannot follow that, so the health probe saw a network error and the reconnect dialog never resolved; only a manual reload got out (spacering-net#981). API calls, health probes and uploads now send X-Requested-With: XMLHttpRequest, which Cloudflare Access answers with a 401 instead of the redirect (measured against a live Access application: 302 without the header, 401 with it). A 401 alone would then bounce the user to the token login and wipe a token that is still valid. Codeg's own 401 now carries WWW-Authenticate: Bearer realm="codeg", and the client reads that challenge to tell the two apart: Codeg's 401 keeps today's "Session expired" / "Go to login", any other 401 shows "Sign-in expired" with Reload (the proxy then runs its own login flow; the service worker passes navigations through, so a reload reaches it) and keeps the token. "Go to login" stays in that dialog as the way out if the proxy keeps refusing. The root page no longer clears the token on a proxy's 401, and the login page no longer calls the token invalid for one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…_AUTH=proxy-jwt) Behind a proxy that already signs users in (Cloudflare Access in spacering-net#981), Codeg's token is a second login on top of the proxy's, kept and restored by hand. With CODEG_AUTH=proxy-jwt, codeg-server also admits a request whose assertion header holds a JWT that verifies against the proxy's published keys with the expected iss and aud and an unexpired exp. The token keeps working for clients that bypass the proxy (codeg-cli --url). Token-only stays the default, and the desktop web service is unchanged. It fails closed throughout: a half or misspelled configuration, or proxy settings without the mode, stops startup with exit code 2 naming the variable; a missing, malformed, expired, foreign or unknown-key assertion is a 401; no keys fetched means no admission without the token. The key decides the algorithm (RS256 / ES256), so alg none and HS256 confusion never reach a verifier. Keys are cached for an hour, refreshed early on an unseen kid at most every 30 s, and kept when a refresh fails. Signatures use ring, already in the lock tree through rustls, so no crate is added. The client records when the server admits it without a token, skips the token prompt, opens the event socket without the token sub-protocol, and answers any 401 with the Reload from the previous commit, since there is no token to re-enter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… sent The proxy's assertion rides on its session cookie, and the browser attaches that cookie to requests other sites' pages make as well: Cloudflare sets its application cookie SameSite=None unless the operator changes it. With CODEG_AUTH=proxy-jwt, any page the signed-in user opened could therefore open /ws/events or /ws/browser-tunnel (a TCP tunnel from the server's side), or post a multipart upload, as that user; neither needs a CORS preflight. A request admitted by an assertion now has to come from Codeg's own page: Sec-Fetch-Site same-origin or none, or, in a browser without Fetch Metadata, an Origin that names the Host or X-Forwarded-Host it was sent to. Anything else gets a 403 and a warning with the reason. The token path is unchanged: another site's page cannot send the token.
refresh() held the rate-limit mutex across the JWKS fetch, whose timeout is 10 s. Once the keys were an hour old, every request waited behind each refresh attempt, and while the key endpoint could not be reached each attempt waited out the whole timeout. Keys that hold the assertion's key id now answer at once, and a single refresh runs beside the request. An unknown key id (a rotation) still waits for its fetch, as before. The max age becomes a field so tests can make the keys stale.
The 256 KB cap was checked against Content-Length, and otherwise only after .bytes() had read the whole body, so a response without a length was buffered in full until it ended or the fetch timed out. The body is now read by chunk and the fetch fails as soon as it passes the cap.
Plain http is accepted only for a key server on this machine, but the JWKS client still took HTTP(S)_PROXY from the environment, so with a proxy set and no NO_PROXY entry the request went to a proxy that would dial its own loopback.
…me straight to the workspace Proxy sign-in was detected only on / and /login. A browser that opened /workspace directly (a bookmark, a restored tab, or after the root page could not reach the server) held neither the token nor the proxy mark: its HTTP calls went through on the proxy's assertion, but the event socket was never opened and nothing reported it, so the workspace stayed "connected" without live updates. When a consumer needs the socket and no credential is held, the transport now asks the server. Admitted: remember the mark and connect, and subscribe() still resolves only after __ready__. Refused: nothing changes, the token flow keeps surfacing its own 401s, and the login page (which subscribes too) has nothing to reconnect. Unreachable: asked again on the reconnect backoff, quietly for the same reason.
…sage Signed in through the proxy there is no token, and on the login page the token being typed is not stored yet. Without that clause the message is true wherever it shows.
Codeg admits every request the proxy vouches for with a valid assertion for this application, service tokens the policy allows included, so the policy is the user list.
probeProxyAuthentication() had no timeout. The root and login pages wait on it, and subscribe() now does too when no credential is held, so a server that took the request and never answered held them forever. It now counts as unreachable after 8 s, the transport's health probe timeout. The admission test also checks that subscribe() waits for the discovered socket's __ready__.
|
@MagTer thank you for this, and for the care that went into it. The fail-closed configuration, the key deciding the algorithm, the mutation pass over the verifier and the runs against real Access all made the review much easier. I went through it for production readiness and found one security issue that comes with cookie-based sign-in, plus a few smaller ones. Since maintainer edits are allowed on this branch, I pushed the fixes as separate commits on top of yours instead of sending you a list. Please look them over, and push back on anything you see differently. Summary
1. Requests from other sitesWith I reproduced it with the PR head before my commits (c51de28), Chrome 155, and a small stand-in for Access: a The fix (auth.rs): a request admitted by an assertion now also goes through a check on where the browser says it came from. One thing worth knowing: in Chrome 155, WebSocket handshakes carry no Fetch Metadata. I checked same-origin and cross-site handshakes with a server that logs headers: both carried The stand-in run, before and after
2. Keys past their age
3. Size cap and 4. loopback JWKS
5. Opening
|
The waits for a background refresh to start or land only have to catch a hang, so they get 30 s instead of 5 s. And the single-flight test no longer assumes the first refresh is over 600 ms after it started: it waits for the refresh flag to clear before the request that should start the next one. A slow machine can then only hide an extra fetch, never fail a correct run.
|
One more commit since the comment above, test-only: 4876460 keeps the key-refresh tests from depending on timing, so a stalled CI machine cannot fail a correct run. The CI run for the new head is approved. |
|
Thanks for the review and the fixes. I went through the nine commits before building, and found nothing I see differently. I ran the live Access check again on Startup: Loopback,
Through the real tunnel, in a fresh private window with no stored token:
The cross-site check, live: while signed in to Access, I opened the devtools console on another Access-protected app and ran That line is written only after the assertion has verified, so the browser sent the Access cookie with the cross-origin handshake and the new check returned the 403. The browser does not expose a failed handshake's status to the page, so 403 here is read from the log line and the code path, not seen in devtools. The same line confirms two things you expected. The handshake carried no One point for the docs: our application's cookie was already Afterwards the host went back to the signed v0.38.0 in token mode. |
Browsers apply SameSite by site, the scheme and registrable domain, not by origin, so a page on another subdomain of the same domain sends the proxy's cookie even when it is Lax or Strict. The cross-origin check is what refuses that page, and the docs no longer read as if Lax made it optional. The section also says "another origin" where it meant that, since a same-site sibling is refused too.
…in refusal A page on a sibling subdomain is same-site, and the check refuses it as well, so the warning now reads "refused a request sent by a page on another origin (...)". The doc comment and the two test names that said "another site" follow. What is admitted does not change.
|
@MagTer thank you for the second live run, and for going further than I asked. A handshake from a sibling subdomain is the same-site, cross-origin case that You're right about
The unit tests already cover both shapes of a sibling's request: I also ran the stand-in end to end again on the new head. Opening Cookies in Chrome 155, by the page that sent the request
From my side, nothing else is open on this PR. I've approved the CI run for the new head. |
Part 2 of #981. Builds on #984 (it reuses that PR's 401 handling in the client); please review that one first — this branch contains its commit.
What it does
With
CODEG_AUTH=proxy-jwt,codeg-serveralso admits a request whose proxy assertion header holds a JWT that verifies against the proxy's published keys, with the expectedissandaudand an unexpiredexp. The browser then needs no Codeg token: the proxy's login is the login. The token keeps working for clients that do not pass through the proxy (codeg-cli --url, scripts). Default behaviour is unchanged; the desktop app's web service stays token-only (build_routerkeeps its signature and stays token-only;build_router_with_proxy_authis used bycodeg-serveralone).Configuration (env, standalone server), documented in
docs/proxy-auth.md:CODEG_AUTHproxy-jwtturns it on; unset/token= todayCODEG_PROXY_AUTH_HEADERCf-Access-Jwt-AssertionCODEG_PROXY_AUTH_JWKS_URLhttps://<team>.cloudflareaccess.com/cdn-cgi/access/certs(https; http only for loopback)CODEG_PROXY_AUTH_ISSUERissCODEG_PROXY_AUTH_AUDIENCEaudNaming is a proposal — happy to rename, or to add a settings entry / desktop exposure if you prefer (#981 left both open). The configuration page on docs.codeg.app would need these variables; I did not find its source in this repo.
Design
web/proxy_auth.rs— config parsing, JWKS parsing, JWS verification, key cache. Fails closed everywhere: incomplete/misspelled config, or proxy variables without the mode, stops startup with exit code 2 naming the variable (checked next to the upload-quota validation); a missing/malformed/expired/wrong-iss/wrong-audassertion, an unknown key, or an algorithm other than RS256/ES256 is a 401; no keys fetched yet → every request without the token is refused.algonly has to agree. That rules outalg: none, HS256-with-the-public-key and other confusion.critis rejected (RFC 7515 §4.1.11). 60 s clock leeway onexp/nbf. Assertions over 16 KB are refused before parsing.ring, which is already in the lock tree through rustls, so this adds no new crate (Cargo.lockonly gains the edge). I chose that overjsonwebtokento avoid a new dependency; switching is easy if you prefer it.kid(rotation), at most once per 30 s so unknown kids cannot drive a fetch per request; a failed refresh keeps the previous keys. No redirects followed; 256 KB cap; 10 s timeout.web/auth.rs:require_auth= token, or (proxy mode only) a verified assertion. Token mode ignores assertions entirely (tested). Rejections are logged by reason only, never the assertion./api/health→ 200), the app records that (codeg_proxy_authinlocalStorage), skips the token prompt (root and/login), opens the WebSocket without the token sub-protocol (the proxy's header rides on the upgrade), and treats any 401 as the proxy's (Reload — there is no token to re-enter). "Go to login" clears the mark.Tests
proxy_auth.rs: 31 unit tests — RS256/ES256 accepted; expired, not-yet-valid, wrong issuer, wrong audience (string, array, missing), tampered payload, foreign key, unknown kid,algnone/HS256/RS512/PS256, header/key algorithm mismatch,crit, malformed structure, non-object claims; JWKS parsing incl. Cloudflare'spublic_cert(s)and keys to skip; every config error; the cache (fetch once, refresh on rotation, rate limit, unreachable JWKS, keep keys on failed refresh) against a local key server.use, JWKalg, EC point length, https rule, rate limit, cache, keep-on-failure, refresh-on-unknown-kid, mode-less settings, blank values); every mutant made at least one test fail.proxy_auth.rsembeds a throwaway 2048-bit RSA test key (PKCS#8, labelled TEST-ONLY), because ring cannot generate RSA keys; a secret scanner may flag it.auth.rs: proxy mode admits a valid assertion without the token, still admits the token, refuses missing/foreign-audience/forged assertions and a wrong token with Codeg's challenge; token mode ignores a valid assertion; an empty token never matches an empty bearer.web-auth,web-transport, root and login pages), all seen failing against part 1's code. Two existing tests adjusted, with reasons in the diff: the login header test answers the new mount-time probe first; the version-notice mock gainshasCodegCredentials.public_cert/public_certs).pnpm lint .,tsc --noEmit,pnpm test,pnpm build;cargo test+clippy -D warningsfor the server and cli cells. Not run locally: the desktop cells (no webkit dev libraries here); the new code is not cfg-gated, andtests/still calls the unchangedbuild_router.codeg-server, local key server, fake proxy that signs ES256 assertions into every request including WebSocket upgrades): a half configuration exits 2 naming the missing variable; straight at the server, no credentials → 401, token → 200, valid assertion → 200, foreign key / other audience / other issuer / expired /alg: none→ 401; through the proxy the workspace opens with no token prompt and no token stored, the event socket stays up,/loginforwards to the workspace, an expired proxy session shows Sign-in expired, Reload reaches the proxy's login, and signing in again restores the workspace; no assertion appears in the server log.Against real Cloudflare Access (this branch, release build from source,
CODEG_AUTH=proxy-jwtwith the team's JWKS, issuer and the application's AUD tag, behind a live Access application): the server loaded the team's two keys at startup, and a private browser window with no stored token opened the workspace without the token prompt. On loopback, the same server refused forged assertions with 401 and logged the reason:alg: nonenaming a real kid ("algorithm not accepted"), a real kid with a bad signature ("bad signature"), an unknown kid, and a malformed header. The token still admitted. Session expiry through real Access is described in the part 1 PR.Part 2 of #981.
🤖 Generated with Claude Code