Skip to content

Server-side sessions with opaque credentials#186

Open
sdumetz wants to merge 13 commits into
mainfrom
claude/intelligent-wozniak-cc4kl6
Open

Server-side sessions with opaque credentials#186
sdumetz wants to merge 13 commits into
mainfrom
claude/intelligent-wozniak-cc4kl6

Conversation

@sdumetz

@sdumetz sdumetz commented Jun 11, 2026

Copy link
Copy Markdown
Member

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

  • New user_sessions table; the cookie carries only an opaque high-entropy sid (stored as a sha256 digest). Identity and expiry are resolved per-request in utils/authenticate.ts into a request-scoped req.user.
  • Sessions are inspectable and revocable (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.
  • The session cookie's Secure flag is scoped to the connection (HTTPS / X-Forwarded-Proto, honoring trust proxy) rather than pinned to NODE_ENV.

OAuth2 provider over a revocable token store

  • Opaque API tokens (ecorpus_<id>_<secret>; only sha256(secret) is stored; constant prefix for secret scanners), inspectable and revocable by owner and admin.
  • Authorization-code flow with mandatory PKCE (S256), admin-registered clients, a session-only consent page, single-use hashed codes, client_secret_basic/client_secret_post, RFC7009 revocation and RFC8414 discovery. Personal access tokens cover the non-OAuth case (shown exactly once).
  • Token (de)serialization lives in auth/Token.ts.

Breaking: HTTP Basic authentication removed

  • Services authenticate with revocable Bearer tokens, never the user's password; a Basic header is ignored. WebDAV-only clients must switch to a personal access token. The test suite and e2e setup are migrated to token auth.

Authorization: restriction scopes (deny-by-default)

  • A token grants only what its scopes name, always within its owner's rights: all, scenes:read|write|admin, scenes:create, tasks:read|write. Per-scene access is capped to min(computed, granted) and never restricts visibility. Full authority (a session or an all-scoped token) is required for account management and admin guards.

Security hardening

  • CSRF protection on cookie-authenticated unsafe methods using Fetch Metadata (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.
  • Report-Only CSP, frame denial on /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 (webpack eval-source-map).
  • Self-hosted Noto Serif (SIL OFL 1.1) instead of hot-linking Google Fonts: no visitor-IP leak and the CSP stays first-party.

UIs

  • Admin OAuth client-registration page; a user "Authorized applications" view with persisted consent and silent token renewal (oauth_grants) while signed in.

Schema

  • A single migration, 009-auth.sql, creates user_sessions, oauth_clients, api_tokens, oauth_codes and oauth_grants.

Tests

  • Unit and integration coverage for the OAuth/token manager, scopes, CSRF and sessions; Playwright e2e for the admin client-registration UI, the third-party authorization-code + PKCE flow, and the personal-token / authorized-applications UI.

https://claude.ai/code/session_01Vz1BLkUkiwudUTDRjGBdn9

Comment thread source/server/routes/auth/oauth.test.ts Dismissed
Comment thread source/server/routes/index.ts Dismissed
@sdumetz sdumetz force-pushed the claude/intelligent-wozniak-cc4kl6 branch from 50492a1 to 798925c Compare June 11, 2026 13:12
@sdumetz sdumetz changed the title Phase 1: Server-side sessions with opaque credentials Server-side sessions with opaque credentials Jun 11, 2026
claude added 13 commits June 15, 2026 12:26
…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
@sdumetz sdumetz force-pushed the claude/intelligent-wozniak-cc4kl6 branch from 2385387 to 5c7ce25 Compare June 15, 2026 12:59
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.

3 participants