Repository navigation
Keep the session token out of page scripts - #2108
Merged
Merged
Conversation
The SPA kept its 24-hour JWT in localStorage, so any script that ran in the page could read it and send it anywhere. The allowlist sanitizer and CSP narrow how such a script gets in; this removes what it could take. Sign-in, invitation accept and password change now set the token in an HttpOnly, SameSite=Strict cookie scoped to /api, Secure exactly when the request is HTTPS and living as long as the token. The bearer handler reads it only when no Authorization header is present, so API clients, the CLI, MCP and tests are untouched. A write that rides the cookie must carry X-CodeSpace-Csrf, which no form or preflight-free request from another site can set. The stamp check runs whichever way the token arrived, so every way a session ends still ends the cookie's. A script in the page can send the cookie and that header too, so a response to a request the cookie carried never puts the minted token in its body. Otherwise accepting an invitation the script's author sent would mint the victim a fresh, portable token. Bearer callers still get it. The SPA settles a session inside the mutation that received it and keeps the response without the token, and drops those mutations with the page: react-query keeps results and variables (the passwords) reachable from the DOM for minutes. /api/auth/session answers "am I signed in" (GET), trades a stored token for the cookie without extending it (POST), and signs the browser out (DELETE). The SPA moves onto the cookie only after the API confirms the browser sends it back; until then, including against an API on another site or pods from before this change, it keeps the bearer token it already had. Once on the cookie, a 401 is checked against the API before it signs anyone out, because a pre-cookie pod reads only the header. That check can reach such a pod too, so the API must finish rolling out before the SPA does; rolling the API back later signs cookie users out once.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
localStorageinto an HttpOnly cookie. Before this, any script running in the SPA could read the 24-hour JWT (codespace.jwt) and send it anywhere. The allowlist sanitizer and CSP (Harden frontend rendering of untrusted content #2098) narrow how such a script gets in; this removes what it could take. API clients, the CLI, MCP and tests keep theAuthorization: Bearerheader and see no change.Behaviour changes
API
POST /api/auth/sign-in,POST /api/auth/change-passwordandPOST /api/invitations/{token}/acceptnow also sendSet-Cookie: codespace_session=<jwt>with these attributes (backend/src/CodeSpace.Core/Services/Auth/SessionCookie.cs):HttpOnly.SameSite=Strict. Lax would add only top-level GET navigations from other sites, and nothing needs one: the SPA reaches/apiby fetch from its own site; the OAuth callback (CredentialOAuthController.Callback) is anonymous and carries its own Lax nonce cookie; and the CSP'sform-action 'none'rules out forms.Path=/api.Max-Ageequal to the token's remaining lifetime.Secureexactly whenRequest.IsHttps, the same rule as the OAuth nonce cookie.Authorizationheader first. It reads the cookie only when the request has no such header, so a dead header is never rescued by a live cookie. The security-stamp check (RejectDeadSessionsAsync) runs whichever way the token arrived, so a password change, a reset or a deactivation ends cookie sessions too.X-CodeSpace-Csrf. Without it the cookie is ignored and the request is anonymous. Bearer requests need no such header.AllowCredentialsstays. Its comment now says why: a cross-origin SPA (VITE_API_URL) needs it to send and keep the cookie.tokenis omitted from the body of those three responses when the request was authenticated by the cookie. The cookie is still set, and bearer callers still gettoken. In the C# records,SignInResponse.TokenandChangePasswordResponse.Tokenare nowstring?withJsonIgnore(WhenWritingNull)./api/auth/sessionendpoints:GETanswers "am I signed in" with a 204 when the request carries a live credential.POSTwith a bearer header sets the cookie to that same token. It never extends the token and never puts it in the body.DELETEclears the cookie with the attributes it was set with. Sessions in other browsers stay live; changing the password is what ends every session.SPA
localStorageonce the API has confirmed the cookie, which takes a POST then a GET to/api/auth/session. The only thing stored is the non-secret flagcodespace.session=cookie, which drivesisAuthenticated().localStorageonly while the API has not confirmed a cookie. That happens with a pre-cookie API pod, or with an API on another site, whereSameSite=Strictwithholds the cookie (seefrontend/.env.example).credentials: "include"and sendsX-CodeSpace-Csrf: 1. "include" rather than the default "same-origin", because the deployed SPA may call the API atVITE_API_URL, another origin. Artifact downloads and the run-ledger stream (SSE over fetch) go through the same path.adoptSessionFrom) and keep the response withouttoken. They also setgcTime: 0, so react-query drops the result and the password variables once the page moves on, instead of keeping them for 5 minutes where the DOM can reach them./signin, with one exception: a browser on the cookie first re-asksGET /api/auth/session. A 204 means the 401 came from a pod that cannot read the cookie. The session is kept and the 401 surfaces as an ordinary error, which queries retry.DELETE /api/auth/session, then clears local state even if the API cannot be reached.Deploy order
Authorizationheader. If a browser that has moved onto the cookie meets one, and the follow-up probe meets one too, that browser is signed out.POST /api/auth/sessionanswers 404, so the SPA keeps the token.nginx.confchanges only a comment; no header or path changes.Test plan
SessionCookieTests. Wire names are pinned. Issue and Clear set the expected attributes over http and https. Header-first precedence. Safe vs unsafe methods.Authenticatedis true only for a principal the cookie established. Full unit suite passes (13400).SessionCookieAuthenticationTests. Cookie-only authentication. Stamp rotation kills the cookie. A dead header is not rescued by a cookie. A live header decides the identity. Cookie writes are refused without the CSRF header. Bearer writes need no CSRF header. Only a principal from the cookie counts as carried by it.SessionCookieE2ETests):tokenin the body when the cookie carried the request, and the token is still returned to bearer callers.Surface=HttpE2E pass (305).localStorage./signinonly when the probe also fails.tsc --noEmit -p tsconfig.app.json,pnpm lint(0 errors) andpnpm build.