Server-side sessions with opaque credentials#186
Open
sdumetz wants to merge 13 commits into
Open
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
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
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