Skip to content

feat(server): optional sign-in through an identity-aware proxy (CODEG_AUTH=proxy-jwt) - #985

Merged
xintaofei merged 13 commits into
spacering-net:mainfrom
MagTer:feat/proxy-jwt-auth
Oct 20, 2026
Merged

xintaofei merged 13 commits into
spacering-net:mainfrom
MagTer:feat/proxy-jwt-auth

Conversation

@MagTer

@MagTer MagTer commented Oct 19, 2026

Copy link
Copy Markdown

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-server also admits a request whose proxy assertion header holds a JWT that verifies against the proxy's published keys, with the expected iss and aud and an unexpired exp. 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_router keeps its signature and stays token-only; build_router_with_proxy_auth is used by codeg-server alone).

Configuration (env, standalone server), documented in docs/proxy-auth.md:

Variable
CODEG_AUTH proxy-jwt turns it on; unset/token = today
CODEG_PROXY_AUTH_HEADER e.g. Cf-Access-Jwt-Assertion
CODEG_PROXY_AUTH_JWKS_URL e.g. https://<team>.cloudflareaccess.com/cdn-cgi/access/certs (https; http only for loopback)
CODEG_PROXY_AUTH_ISSUER exact iss
CODEG_PROXY_AUTH_AUDIENCE must be in aud

Naming 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-aud assertion, an unknown key, or an algorithm other than RS256/ES256 is a 401; no keys fetched yet → every request without the token is refused.
  • The key decides the algorithm; the header's alg only has to agree. That rules out alg: none, HS256-with-the-public-key and other confusion. crit is rejected (RFC 7515 §4.1.11). 60 s clock leeway on exp/nbf. Assertions over 16 KB are refused before parsing.
  • Signature checks use ring, which is already in the lock tree through rustls, so this adds no new crate (Cargo.lock only gains the edge). I chose that over jsonwebtoken to avoid a new dependency; switching is easy if you prefer it.
  • Key cache: fetched at startup (not fatal if it fails — logged, requests refused until a fetch works), kept for an hour, refreshed early when an assertion names an unseen 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.
  • Client: when the server admits the browser without a token (/api/health → 200), the app records that (codeg_proxy_auth in localStorage), 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, alg none/HS256/RS512/PS256, header/key algorithm mismatch, crit, malformed structure, non-object claims; JWKS parsing incl. Cloudflare's public_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.
  • Mutation check: each of 20 checks in the module was broken one at a time (signature, exp, leeway, nbf, iss, aud, aud-array, key-decides-alg, crit, length, use, JWK alg, 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.rs embeds 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.
  • Frontend: 13 new cases (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 gains hasCodegCredentials.
  • The module parses the real JWKS of a Cloudflare Access team (two RS256 keys plus public_cert/public_certs).
  • Run locally: pnpm lint ., tsc --noEmit, pnpm test, pnpm build; cargo test + clippy -D warnings for the server and cli cells. Not run locally: the desktop cells (no webkit dev libraries here); the new code is not cfg-gated, and tests/ still calls the unchanged build_router.
  • End to end (Playwright/Chromium, built 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, /login forwards 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-jwt with 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: none naming 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

Magnus Ternström and others added 2 commits October 18, 2026 22:19
…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__.
@xintaofei

Copy link
Copy Markdown
Collaborator

@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 sent by other sites' pages were admitted. This is the security issue, fixed in 8409e21.
  2. Keys past their age made requests wait for the JWKS fetch (87a5505).
  3. A JWKS body sent without Content-Length was read in full before the size cap was checked (7e3d446).
  4. A loopback JWKS went through HTTP(S)_PROXY (45c7b68).
  5. Opening /workspace directly, with neither a token nor the proxy mark, never opened the event socket (96daf78, 96b81e4).
  6. Wording and docs (1298d67, d847fc9).

1. Requests from other sites

With CODEG_AUTH=proxy-jwt the credential is no longer something only Codeg's page can send. The assertion rides on the proxy's session cookie, and Cloudflare sets that application cookie SameSite=None by default (Authorization cookie), so the browser attaches it to requests other sites' pages make as well. Nothing on the server checked where a request came from, and two kinds of request need no CORS preflight: WebSocket handshakes and multipart POSTs. Any page the signed-in user opened could therefore open /ws/events (the live event stream) or /ws/browser-tunnel (a TCP tunnel from the server's side), or post to the upload endpoints, as that user. In token mode none of this works, because another site's page cannot send the token.

I reproduced it with the PR head before my commits (c51de28), Chrome 155, and a small stand-in for Access: a SameSite=None; Secure session cookie, and an ES256 assertion injected into every request that carries a valid session, WebSocket upgrades included. A page on another origin opened /ws/events and received the __ready__ frame. Its no-cors multipart POST to /api/upload_attachment returned 200 and wrote the file into uploads/.

The fix (auth.rs): a request admitted by an assertion now also goes through a check on where the browser says it came from. Sec-Fetch-Site decides when present, and only same-origin and none pass. Otherwise an Origin, when present, has to match the Host the request was sent to, or the X-Forwarded-Host of a proxy that rewrites Host, by host and port. A request with neither header passes, since browsers send one of them on every WebSocket handshake and cross-origin POST. A failed check gets a 403 and a warning that names the reason. The token path is unchanged. After the fix, the same page gets a 403 for both requests, and the workspace's own socket still upgrades.

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 Origin and Host, and no Sec-Fetch-*. For the event socket opened through the proxy, the Origin check therefore decides. cloudflared keeps the browser's Host by default, and moves it to X-Forwarded-Host when httpHostHeader is set (origin_proxy.go), so your setup should pass either way. A proxy that rewrites Host without setting X-Forwarded-Host would have its socket refused, and the docs now say so (proxy-auth.md). Could you run your live Access check once more on the updated branch, mainly to confirm that the event socket still connects through the real tunnel?

The stand-in run, before and after
before (c51de289c):
HTTP 200 POST /api/upload_attachment origin=http://127.0.0.1:18744 sfs=cross-site
WS HTTP/1.1 101 Switching Protocols /ws/events origin=http://127.0.0.1:18744 cookie=true
the page received {"channel":"__ready__","payload":null}; uploads/anon/evil.txt was written

after (96b81e41e):
HTTP 403 POST /api/upload_attachment origin=http://127.0.0.1:18744 sfs=cross-site
WS HTTP/1.1 403 Forbidden /ws/events origin=http://127.0.0.1:18744 cookie=true
WARN [AUTH] proxy-jwt: refused a request sent by another site's page (Origin: http://127.0.0.1:18744; sent to: localhost:18741)

localhost:18741 is the stand-in proxy and 127.0.0.1:18744 the other site's page; cookie=true means the browser sent the session cookie with the cross-site request.

2. Keys past their age

refresh() holds the rate-limit mutex across the fetch, whose timeout is 10 s. Once the keys were an hour old, every request went through refresh(), and the requests that arrived while a fetch was out waited for it. With the key endpoint unreachable, that meant waits of up to 10 s every 30 s. Now keys that hold the assertion's kid answer at once, and a single refresh runs beside the request (proxy_auth.rs). An unknown kid still takes the waiting path, as before, within the 30 s rate limit.

3. Size cap and 4. loopback JWKS

fetch() checked the 256 KB cap against Content-Length, and otherwise only after .bytes() had read the whole body. It now reads by chunk and stops at the cap (proxy_auth.rs). Plain http is accepted only for a loopback key server, so its client now skips HTTP(S)_PROXY: with a proxy set and no NO_PROXY entry, the request went to a proxy that would dial its own loopback.

5. Opening /workspace directly

Proxy sign-in was discovered only on / and /login. A fresh browser that opened /workspace itself, from a bookmark, a restored tab, or after the root page could not reach the server, had neither the token nor the mark. Its HTTP calls went through the proxy, but the event socket never opened, and the UI stayed "connected" without live updates. The transport now asks the server when something needs the socket and no credential is held (web-transport.ts). If the server says no, nothing changes, because the login page subscribes too and must not get a dialog. If it cannot be reached, it is asked again on the reconnect backoff. Because subscribe() now waits on that probe, probeProxyAuthentication() also gives up after 8 s (96b81e4).

6. Wording and docs

"your access token is kept" is not true without a token, nor on the login page, where the token being typed has not been stored yet, so 1298d67 drops that clause in all ten locales. d847fc9 adds the "Who gets in" section discussed in #981, and 8409e21 adds the cross-site section and suggests setting the application's cookie to SameSite=Lax.

Tests and checks

Every new test was run against the code before its fix and failed there. Afterwards each fix was broken on purpose, ten on the Rust side and seven in the frontend, and each time at least one test failed. Run locally on the final head:

desktop  cargo check · clippy --all-targets --features test-utils -D warnings · test --features test-utils (6015 passed)
server   cargo check · clippy -D warnings · test --bin codeg-server --lib (5610 passed)
mcp      cargo check · clippy -D warnings
cli      cargo clippy -D warnings · test --lib cli:: (56 passed)
web      eslint . · tsc --noEmit · vitest run (10136 passed) · next build

One full vitest run hit the sidebar-tag-filter timing flake you mentioned in #984; it passed alone three times, and the next full run was clean.

The stand-in run also covered opening /workspace directly with no token, a session expiry followed by Sign-in expired, Reload and back in, the same with Host rewritten and the original in X-Forwarded-Host, and plain token mode.

Two things I left as they are, for the record. Without Sec-Fetch-Site, the Origin check compares host and port but not the scheme. And a browser that holds only a stale mark, after the server goes back to token mode, gets the proxy dialog again on Reload; "Go to login" recovers from that.

I've approved the CI runs for both PRs.

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.
@xintaofei

Copy link
Copy Markdown
Collaborator

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.

@MagTer

MagTer commented Oct 19, 2026

Copy link
Copy Markdown
Author

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 4876460bc: the release codeg-server built from that head, its out/ as the static dir, and the rest of the binaries from the signed v0.38.0. It ran on the same host and through the same tunnel and Access application as before, with CODEG_AUTH=proxy-jwt, the Cf-Access-Jwt-Assertion header, and the team's JWKS, issuer and the application's AUD.

Startup: proxy-jwt on with the configured header, issuer and audience, then loaded 2 verification key(s).

Loopback, POST /api/health:

Request Status Logged reason
No credential 401 –
Garbage assertion 401 malformed assertion: header
alg: none with a real kid 401 algorithm not accepted
Real kid, bad signature 401 bad signature
Unknown kid 401 unknown key id
The token 200 –

Through the real tunnel, in a fresh private window with no stored token:

  • The root page opened without the token prompt.
  • Opening /workspace directly, as the first page in a fresh private window, also came in without the prompt (your fix 5).
  • A session streamed agent output live, so the event socket connected through the tunnel. Nothing was refused as cross-site: the log has no refused a request sent by another site's page line from any of these requests.

The cross-site check, live: while signed in to Access, I opened the devtools console on another Access-protected app and ran new WebSocket("wss://<codeg host>/ws/events"). That app is on a sibling subdomain of the same registrable domain, under the same Access team. The handshake failed in the browser, and Codeg logged:

WARN [AUTH] proxy-jwt: refused a request sent by another site's page (Origin: https://<sibling host>; sent to: <codeg host>)

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 Sec-Fetch-Site, because the reason names Origin, not Fetch Metadata. And cloudflared passed the browser's Host through unchanged without adding X-Forwarded-Host, because "sent to" lists exactly one host.

One point for the docs: our application's cookie was already SameSite=Lax, and the cookie still went along. SameSite is decided by site, the registrable domain, not by origin, so a page on a sibling subdomain is same-site and Lax does not hold back its requests. Lax is worth setting against unrelated sites, but anyone who serves other apps under the same domain still depends on the server-side check. The cross-site section could say that, so nobody reads Lax as making the check optional.

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.
@xintaofei

Copy link
Copy Markdown
Collaborator

@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 SameSite cannot stop and only this check refuses, so it was the right one to try. Reading the reason off the warning (Origin rather than Sec-Fetch-Site, and one host after "sent to") also settles the two things I could only check locally or in cloudflared's source: no Sec-Fetch-Site on the handshake, and the browser's Host passed through.

You're right about SameSite, and it goes one step further than Lax. A site is the scheme plus the registrable domain, so a page on a sibling subdomain is same-site, and its requests carry the cookie even under Strict. I checked it in Chrome 155 with a Lax and a Strict cookie (details below), and pushed two commits:

  • 3a937e2 (docs): the section, now called "Requests from other origins", says that SameSite does not make the check optional, and why. The Cloudflare paragraph says what Lax does and does not hold back, and links there. The docs also say "another origin" where they meant that, since a same-site sibling is refused too.
  • 3c67ba1 (wording only, nothing changes in what is admitted): the warning now reads refused a request sent by a page on another origin (...). "Another site's page" was the wrong name for exactly the request you sent. The doc comment and two test names follow.

The unit tests already cover both shapes of a sibling's request: Sec-Fetch-Site: same-site, and, without Fetch Metadata, an Origin on another subdomain of the same domain as Host.

I also ran the stand-in end to end again on the new head. Opening /workspace directly, the session expiry with Reload, and the other origin's socket and upload, refused with the new wording, all behaved as before.

Cookies in Chrome 155, by the page that sent the request

app.example.com set lax=1; SameSite=Lax and strict=1; SameSite=Strict. A page on each origin below then opened a WebSocket to app.example.com and sent it a no-cors multipart POST, and the server logged the Cookie header it received. All three names mapped to 127.0.0.1 through --host-resolver-rules, over plain http.

page on                  WebSocket handshake   multipart POST
app.example.com          lax=1; strict=1       lax=1; strict=1
other.example.com        lax=1; strict=1       lax=1; strict=1
unrelated.example.net    (none)                (none)

From my side, nothing else is open on this PR. I've approved the CI run for the new head.

@xintaofei
xintaofei merged commit 2244511 into spacering-net:main Oct 20, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants