Server-side sessions with opaque credentials#186
Conversation
50492a1 to
798925c
Compare
…tity The session cookie now carries only an opaque high-entropy sid; identity and expiry live in the new user_sessions table (sid stored as a sha256 digest) and are resolved from the database on every request. As a result: - sessions are inspectable (GET /auth/sessions, GET /users/:uid/sessions) and revocable (DELETE /auth/sessions/:id) - a password change evicts every session for the user - level changes (demotions) apply to live sessions immediately - pre-migration cookies are rejected (one forced re-login at upgrade) The auth logic moves out of routes/index.ts into utils/authenticate.ts, which resolves a request-scoped req.user consumed via getUser(): - header (Basic) authentication no longer writes to the session, so it stops minting 31-day cookies on every authenticated request - fixes the sliding-renewal condition that re-emitted the session cookie on every request (was always-true) Also: baseline security headers as an explicit middleware (utils/headers.ts: nosniff, Referrer-Policy, HSTS in production — embedding requirements rule out frame/cross-origin-isolation headers, so no dependency is warranted for the rest), explicit httpOnly cookies with the Secure flag scoped to the connection (set when the request is HTTPS, honoring trust-proxy / X-Forwarded-Proto, rather than pinned to NODE_ENV — so a TLS-terminating proxy gets Secure cookies while plain-HTTP access keeps working), rate-limiting on POST /auth/login (the remaining scrypt brute-force surface). https://claude.ai/code/session_019U8ABr32AxzHtBLfr26Qx9
API tokens are opaque ('ecorpus_<id>_<secret>', sha256-stored, constant
prefix for secret scanners), inspectable and revocable by owner and
admin. The only scope is 'all': a token grants exactly what its owner
could do in a session — identity and level are re-read from the users
row on every request, so demoting a user instantly degrades their
tokens and deleting them revokes everything. Scope strings are never
reinterpreted: restrictions (scenes:read|write|admin) arrive as new
scopes in a later commit.
The OAuth2 layer (authorization code + mandatory PKCE S256) mints into
the same store: admin-registered clients, consent page (session-only:
a stolen token must not be able to grant itself more access),
single-use hashed codes bound to client+redirect_uri+scope+challenge,
client_secret_basic and client_secret_post at the token endpoint,
RFC7009 revocation, RFC8414 discovery. Personal access tokens cover
the no-OAuth case: created in the user settings page, shown exactly
once.
Basic authentication survives unchanged next to the Bearer branch
until the next commit migrates the test suite off it; its failures
now fall through to anonymous so client_secret_basic on the token
endpoint isn't intercepted.
https://claude.ai/code/session_019U8ABr32AxzHtBLfr26Qx9
The mechanical companion to the previous commit, kept separate so that
one stays reviewable.
Services now authenticate with revocable Bearer tokens, never with the
user's password. BREAKING: WebDAV-only clients holding Basic
credentials must switch to a personal access token. A Basic header is
simply ignored (no login data, no cookie), like any unknown
Authorization scheme.
The test suite migrates accordingly: ~280 '.auth(user, password)' call
sites across 32 test files (plus the e2e setup) become
'.set("Authorization", await bearer(user))' on the shared fixture's
token-minting helper, and the login/authenticate tests asserting Basic
behavior now assert its absence.
https://claude.ai/code/session_019U8ABr32AxzHtBLfr26Qx9
…me denial Unsafe methods (incl. WebDAV's MKCOL/MOVE/COPY/PROPPATCH) authenticated by the session cookie are CSRF-checked; Bearer-token and anonymous requests are exempt, since the Authorization header doesn't travel cross-site — there is nothing to forge, and this keeps non-browser clients working unchanged. Complements the sameSite=lax cookie for older browsers and sibling-subdomain attackers. Fetch Metadata is the primary signal: the browser sets Sec-Fetch-Site and web content can neither forge nor strip it, so a present value is trusted outright — only same-origin (incl. form-navigation POSTs) and none (direct navigation) are accepted; same-site and cross-site are rejected. The Origin/host comparison is only a fallback for the rare client that sends no Fetch Metadata, and is skipped when Sec-Fetch-Site is present: a strict Referrer-Policy makes browsers send Origin: null on form navigations, and behind a reverse proxy the reconstructed host may differ from the public origin — either would wrongly reject a genuine same-origin request if the Origin check ran unconditionally. Hence Referrer-Policy is same-origin rather than no-referrer, which also restores the same-origin Referer the user-creation form redirects on. The /auth router denies framing (X-Frame-Options: DENY + 'frame-ancestors none'): a clickjacked click on the OAuth consent page would grant a token. Framing can't be denied site-wide because scene embedding depends on it, so the auth scope opts back in. The Content-Security-Policy ships Report-Only (in utils/headers.ts): scene templates inject inline scripts and Voyager loads workers/assets from blob:, so an enforced policy needs its own effort; this surfaces violations in the browser console without breaking anything. Log-redaction audit: the access log records method/url/status only (never the Authorization header), token errors never echo the presented credential, and tokens are not accepted in query strings. https://claude.ai/code/session_019U8ABr32AxzHtBLfr26Qx9
…ault A token grants only what its scopes name, always within the limits of what its owner can do; everything unnamed is denied. The system fails closed: future capability families are not retroactively granted to restriction-scoped tokens already in the wild, and negative scopes never need to exist. Two kinds of guard implement this. requireScope(...) sits on the routes a restriction scope may grant — scene creation and zip import (scenes:create), the tasks API (tasks:read|write) — always paired with the user-level guard: isCreator checks the user, requireScope checks the token. Full authority (isFullAccess/isFullUser, ie. a session or an 'all'-scoped token) is required strictly where no restriction scope may ever reach: account management (sessions, tokens, password changes — anything that would escalate a token back to its owner's full authority) and the manage/admin-level guards (user administration, groups, instance config, OAuth clients, login links). GET /auth/ (identity and level: the packaging service's "does this user exist" question) answers any valid token. Within their family, scenes:read|write|admin grant the per-scene route guards (canRead/canWrite/canAdmin, and /tags' per-scene checks) at the named access level at most — never restricting visibility: effective access = min(computed access, granted level), admin bypass included, so an admin's scenes:read token reads every scene the admin usually sees and writes none of them. A denied write is a 401 (insufficient rights), not a 404 (hidden); a grant never extends what the owner could do. scenes:create is deliberately outside that hierarchy (an import token combines it with scenes:write). The token's scope travels with the request identity (setUser(..., scope), getSceneCap/hasScope/isFullAccess); sessions carry no scope and keep full authority. The user tokens page gains a multi-select scope picker and OAuth clients may request the new scopes (advertised in the discovery document). https://claude.ai/code/session_019U8ABr32AxzHtBLfr26Qx9
The new POST /auth/login brute-force limiter (10/min/IP) trips during the e2e suite: every test logs in from a single host/container IP, so logins accumulate past the limit and later tests get 429 on their beforeEach login. The app already exposes a TEST escape hatch (raises the limit to 10000), used by the server unit tests; wire it into both e2e environments. - docker-compose: pass TEST through to the app, empty by default so real deployments started from this compose keep the limit active. - build_docker workflow: export TEST=1 for the compose run. - Test End-to-End job: set TEST=1 for the dev-server run. https://claude.ai/code/session_019U8ABr32AxzHtBLfr26Qx9
Adds an admin-only page to register and manage OAuth2 clients, backed by the existing POST/GET/DELETE /auth/oauth/clients routes. - GET /ui/admin/oauth (isAdministrator): lists clients. - admin/oauth.hbs: client table (name, id, redirect URIs, confidential vs public, created), a registration dialog (name, redirect URIs, confidential toggle) and a one-time client_secret reveal. Create uses fetch so it can surface the secret from the response (submit-fragment only exposes the request body); delete reuses the submit-fragment pattern. Both go through the browser's fetch/XHR, so they carry Sec-Fetch-Site and pass CSRF. - Admin nav link, en/fr locale strings, and a template render test. https://claude.ai/code/session_019U8ABr32AxzHtBLfr26Qx9
…d in Once a user approves a client, the consent is stored (oauth_grants: the union of every approved scope set). Later authorization requests whose scope is covered skip the consent page and issue the code directly, so an active user renews an expired token with a redirect and no clicks. There is intentionally no renewal for signed-out users (that would be refresh tokens, still out of scope). The `prompt` parameter follows the OIDC convention: `none` never interacts and reports login_required/consent_required through the redirect URI (hidden-frame renewal); `consent` always re-prompts. Users manage consents under "Authorized applications" on the tokens page (GET/DELETE /auth/oauth/grants[/:clientId]): withdrawing one stops silent renewal and revokes every token that client obtained for them, while personal tokens survive. https://claude.ai/code/session_019U8ABr32AxzHtBLfr26Qx9
theme.scss hot-linked the Noto Serif stylesheet from fonts.googleapis.com (and its woff2 from fonts.gstatic.com), which sends every visitor's IP to Google (a GDPR concern) and would force third-party origins into the CSP. Bundle the latin + latin-ext woff2 (SIL OFL 1.1) under assets/fonts, emit them to /dist/fonts through a webpack asset/resource rule (mirroring the image rule), and declare them with local @font-face rules keeping the original unicode-ranges. The Report-Only CSP stays first-party (style-src/font-src 'self'). Separately, webpack's eval-source-map (development devtool) runs modules through eval(), which tripped script-src; the production bundle uses external .map files and never evals. Allow 'unsafe-eval' only when NODE_ENV != production, so the dev console is free of false positives while production stays strict. https://claude.ai/code/session_019U8ABr32AxzHtBLfr26Qx9
Add an end-to-end spec for /ui/admin/oauth: register a confidential client (one-time secret reveal), a public client (no secret), surface a server-side validation error in the dialog, and delete a client from the list. Fill the integration gaps these exercise in oauth.test.ts: a public client's response/list shape (client_secret null, confidential false, no digest leaked), the confidential default, duplicate-name 409, and the missing-name 400. https://claude.ai/code/session_019U8ABr32AxzHtBLfr26Qx9
Drive the full flow through a real browser as a third-party app would: a logged-in user consents, the browser is redirected to an intercepted callback with a code, the code is exchanged for a token, and the Bearer token then authenticates an API call. Also covers the anonymous→login bounce, consent denial (access_denied), silent renewal (no consent page on return), prompt=consent re-prompt, prompt=none→consent_required, a restricted scope shown on the page and carried by the token, and a public (PKCE-only) client. Fill the integration gap these expose in oauth.test.ts: a public client exchanges a code with PKCE and no secret, and is rejected (invalid_client) if it presents one. https://claude.ai/code/session_019U8ABr32AxzHtBLfr26Qx9
Cover /ui/user/tokens end to end: create a personal token (secret shown once), use it as a Bearer credential, and revoke it from the list (Bearer then 401); create a scope-restricted token via the picker; and the "Authorized applications" section — after an OAuth consent the client is listed, and revoking it removes the row and revokes the client's token. Fill the adjacent gaps in tokens.test.ts: an explicit expiry round-trips in the create response (with client:null / lastUsed:null for a personal token), and an unparseable expires is rejected with 400. https://claude.ai/code/session_019U8ABr32AxzHtBLfr26Qx9
UserManager.createClient/authenticateClient had no direct unit tests — only route-level coverage. Add a focused unit block exercising the full matrix: confidential vs public creation, name/redirect-URI validation, duplicate names, and the security-sensitive client authentication branches (correct secret, wrong/missing secret, public client presenting a secret, unknown id). https://claude.ai/code/session_019U8ABr32AxzHtBLfr26Qx9
2385387 to
5c7ce25
Compare
| limit: process.env["TEST"] ? 10000 : 10, | ||
| standardHeaders: 'draft-7', | ||
| legacyHeaders: false, | ||
| validate: {trustProxy: false}, |
There was a problem hiding this comment.
In production, when the app is sitting behind a proxy, wouldn't that cause all requests to be filed under the same rate-limit key?
There was a problem hiding this comment.
Good question, but no — not all under one key. The limiter keys on req.ip, and the app sets trust proxy from config (trust_proxy, default true), so Express derives req.ip from X-Forwarded-For, i.e. the real client IP rather than the proxy's. Requests are bucketed per client even behind a proxy (it's the same setting the Secure-cookie / X-Forwarded-Proto logic relies on).
validate: {trustProxy: false} is unrelated to the key — it only silences express-rate-limit's startup check that warns when trust proxy is permissive; it doesn't change how the key is computed.
The real caveat is the opposite one: trust proxy: true trusts X-Forwarded-For unconditionally, so a client could spoof it to rotate IPs and evade the limit. If you'd like to close that off, we can set trust_proxy to the exact proxy hop-count (or the proxy's subnet) in production instead of true — happy to wire that up.
Generated by Claude Code
| */ | ||
| export async function postToken(req: Request, res: Response){ | ||
| const requester = getUser(req)!; | ||
| if(getAuthMethod(res) === "token"){ |
There was a problem hiding this comment.
Should we reverse the condition and ensure method == "session" instead? Seems more secure if we ever add other methods so they would be blacklisted by default?
There was a problem hiding this comment.
Done in 6c4af82 — inverted to getAuthMethod(res) !== "session". Behaviour is identical today (the only auth methods are session/token, and POST /tokens already sits behind isFullUser), but it now fails closed: any auth method added later is denied by default instead of silently allowed to mint tokens. Kept the error string unchanged so the existing test still matches.
Generated by Claude Code
| //Changing one's own password evicted every session (see UserManager.patchUser): | ||
| //keep the requester logged in by minting a fresh one. | ||
| const expires = new Date(Date.now() + sessionMaxAge); | ||
| const {sid} = await userManager.createSession(targetUid, {expires, userAgent: req.get("User-Agent")}); |
There was a problem hiding this comment.
Isn't User-Agent in the process of being phased-out? should we default to the replacement headers already?
There was a problem hiding this comment.
I'd keep User-Agent here. It isn't actually being removed — what's happening is Chrome's "UA reduction", which freezes/trims the granularity of the string, but the header itself stays and every browser still sends it. The intended replacement, User-Agent Client Hints (Sec-CH-UA*), is Chromium-only (Firefox/Safari don't send it) and requires an Accept-CH negotiation round-trip, so for a portable, human-readable session label ("last seen from…") the UA string is still the right, broadly-supported choice. We can enrich it with Client Hints later if we ever want structured device info, but I wouldn't make it the default. Left as-is — let me know if you'd prefer I add Client-Hints negotiation.
Generated by Claude Code
| * Methods that may change state. Includes the WebDAV methods served by the | ||
| * scenes routes. | ||
| */ | ||
| const UNSAFE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE", "MKCOL", "MOVE", "COPY", "PROPPATCH"]); |
There was a problem hiding this comment.
WOuldn't it be better to filter by SAFE_METHODS instead? So we wouldn't miss any of the (unsafe) WebDAV methods if some ever gets added? WOuld make the array smaller, too.
There was a problem hiding this comment.
Done in f05ca1e — flipped to a SAFE_METHODS = {GET, HEAD, OPTIONS} allowlist; everything else is checked, so any WebDAV verb we add later is CSRF-protected by default (and the array is smaller, as you noted). It does pull PROPFIND into scope, which the old denylist omitted — harmless in practice: it's read-only, real WebDAV clients use token auth (which is exempt), and same-origin browser/supertest requests still pass. Full server suite stays green (1432 passing).
Generated by Claude Code
| const cspReportOnly = buildCspReportOnly({ dev }); | ||
| return function setSecurityHeaders(req, res, next) { | ||
| //Browsers must not sniff content types away from Content-Type | ||
| res.set("X-Content-Type-Options", "nosniff"); |
There was a problem hiding this comment.
Why? isn't this harmless?
There was a problem hiding this comment.
It's not harmless to drop for eCorpus specifically: we serve user-supplied content (scene files, glTF/.bin models, documents, thumbnails). Without X-Content-Type-Options: nosniff, a browser may MIME-sniff a response and ignore the Content-Type we send — e.g. treat an uploaded file as text/html and execute embedded script (stored XSS), or sniff a JSON API response as HTML. nosniff forces the declared type to be honoured; it's a one-liner with no downside for first-party assets, so I'd keep it. (Line 43 on my side is the nosniff header — if you were pointing at a different line, point me at it.)
Generated by Claude Code
Flip the CSRF method gate from an UNSAFE_METHODS denylist to a
SAFE_METHODS allowlist ({GET, HEAD, OPTIONS}); every other verb is
checked. A WebDAV method added to the scenes routes later (or any
non-standard verb) is now covered by default instead of silently
slipping past the list. PROPFIND is newly in scope and stays harmless:
it is read-only, real WebDAV clients authenticate with tokens (exempt),
and same-origin browser/test requests still pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vz1BLkUkiwudUTDRjGBdn9
Invert the guard in postToken from `=== "token"` (denylist) to `!== "session"` (allowlist). Behaviour is identical today -- the only auth methods are session and token, and POST /tokens already sits behind isFullUser -- but it now fails closed: any auth method added later is denied by default rather than silently permitted to mint tokens. The error string is unchanged so the existing test still holds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vz1BLkUkiwudUTDRjGBdn9
Summary
Replaces eCorpus's stateless signed-cookie authentication with a full server-side authentication & authorization stack: revocable sessions, an OAuth2 provider backed by a revocable opaque-token store, scoped tokens, and the supporting security hardening and UIs. Identity and authorization are resolved from the database on every request, so revocation and privilege changes take effect immediately.
Base:
main. History is squashed into focused, individually-reviewable commits (one work package each).What's included
Sessions & request-scoped identity
user_sessionstable; the cookie carries only an opaque high-entropysid(stored as a sha256 digest). Identity and expiry are resolved per-request inutils/authenticate.tsinto a request-scopedreq.user.GET/DELETE /auth/sessions,GET /users/:uid/sessions); a password change evicts all of a user's sessions; level demotions apply to live sessions immediately.Secureflag is scoped to the connection (HTTPS /X-Forwarded-Proto, honoringtrust proxy) rather than pinned toNODE_ENV.OAuth2 provider over a revocable token store
ecorpus_<id>_<secret>; onlysha256(secret)is stored; constant prefix for secret scanners), inspectable and revocable by owner and admin.client_secret_basic/client_secret_post, RFC7009 revocation and RFC8414 discovery. Personal access tokens cover the non-OAuth case (shown exactly once).auth/Token.ts.Breaking: HTTP Basic authentication removed
Authorization: restriction scopes (deny-by-default)
all,scenes:read|write|admin,scenes:create,tasks:read|write. Per-scene access is capped tomin(computed, granted)and never restricts visibility. Full authority (a session or anall-scoped token) is required for account management and admin guards.Security hardening
Sec-Fetch-Site) as the primary signal, with an Origin/host fallback only when Fetch Metadata is absent;Referrer-Policy: same-origin. Bearer and anonymous requests are exempt./auth(X-Frame-Options+frame-ancestors 'none') to stop clickjacking the consent page,nosniff, and HSTS in production.'unsafe-eval'is allowed only in development (webpackeval-source-map).UIs
oauth_grants) while signed in.Schema
009-auth.sql, createsuser_sessions,oauth_clients,api_tokens,oauth_codesandoauth_grants.Tests
https://claude.ai/code/session_01Vz1BLkUkiwudUTDRjGBdn9