ReviewOS

also looking at this

stacks/bench-review

Close unauthenticated /api/requests CRUD, and make the session cookie HttpOnly

#77
Merged glennmichael123 wants to merge fix/request-autocrud-and-httponly into main
11 files +552 -57
app/Actions/Auth/LoginAction.tsadded+91-0
Changes to app/Actions/Auth/LoginAction.ts
@@ -0,0 +1,91 @@
1import type { RequestInstance } from '@stacksjs/types'
2import { Action } from '@stacksjs/actions'
3import { Auth, createTwoFactorChallenge, getTwoFactorState } from '@stacksjs/auth'
4import { User } from '@stacksjs/orm'
5import { response } from '@stacksjs/router'
6import { schema } from '@stacksjs/validation'
7import { withSessionCookie } from '../../Support/sessionCookie'
8
9/**
10 * Login — app-local override of `storage/framework/defaults/app/Actions/Auth/LoginAction.ts`.
11 *
12 * Actions resolve `app/` first and fall back to the framework defaults (the
13 * same mechanism noted in `routes/api.ts`), so this file wins without touching
14 * the vendored tree — which matters because `storage/framework/` is replaced
15 * wholesale on a framework sync.
16 *
17 * The ONLY behavioural difference from the default is that the session token is
18 * also returned as an `HttpOnly` cookie instead of being left for the browser
19 * to write via `document.cookie`. See `app/Support/sessionCookie.ts` for why.
20 *
21 * The JSON body is kept byte-compatible with the framework's shape
22 * (`access_token` / `refresh_token` / `token_type` / `expires_in` / the legacy
23 * `token` / `user`) so any non-browser client keeps working, and so a future
24 * framework sync does not silently change the contract underneath the SPA.
25 *
26 * NOTE on 2FA: the `requires_two_factor` branch mints no token, so it sets no
27 * cookie — correct, since no session exists until the code is verified. The
28 * token for that path comes from `VerifyTwoFactorLoginAction`, which this app
29 * does not route (`routes/api.ts` declares no 2FA verify endpoint), so a 2FA
30 * account cannot complete login here. That is pre-existing, not introduced
31 * here; wire that route and give it the same cookie treatment if 2FA is turned on.
32 */
33export default new Action({
34 name: 'LoginAction',
35 description: 'Login to the application',
36 method: 'POST',
37
38 validations: {
39 email: {
40 rule: schema.string().email(),
41 message: 'Email must be a valid email address.',
42 },
43 password: {
44 rule: schema.string().min(6).max(255),
45 message: 'Password must be between 6 and 255 characters.',
46 },
47 },
48
49 async handle(request: RequestInstance) {
50 const email = request.get('email')
51 const password = request.get('password')
52
53 // Verify credentials WITHOUT minting tokens yet — if the account has TOTP
54 // 2FA enabled, no token pack should exist until the code is also verified.
55 const isValid = await Auth.attempt({ email, password })
56 if (!isValid)
57 return response.unauthorized('Incorrect email or password')
58
59 const authedUser = await User.where('email', '=', email).first()
60 if (!authedUser)
61 return response.unauthorized('Incorrect email or password')
62
63 const { enabled: twoFactorEnabled } = await getTwoFactorState(authedUser.id as number)
64 if (twoFactorEnabled) {
65 const challengeToken = await createTwoFactorChallenge(authedUser.id as number)
66 return response.json({
67 requires_two_factor: true,
68 challenge_token: challengeToken,
69 })
70 }
71
72 const result = await Auth.loginUsingId(authedUser.id as number)
73 if (!result)
74 return response.unauthorized('Incorrect email or password')
75
76 const user = result.user
77
78 return withSessionCookie({
79 access_token: result.token,
80 refresh_token: result.refreshToken,
81 token_type: 'Bearer',
82 expires_in: result.expiresIn,
83 token: result.token,
84 user: {
85 id: user?.id,
86 email: user?.email,
87 name: user?.name,
88 },
89 }, result.token)
90 },
91})
app/Actions/Auth/LogoutAction.tsadded+31-0
Changes to app/Actions/Auth/LogoutAction.ts
@@ -0,0 +1,31 @@
1import { Action } from '@stacksjs/actions'
2import { Auth } from '@stacksjs/auth'
3import { withClearedSessionCookie } from '../../Support/sessionCookie'
4
5/**
6 * Logout — app-local override of the framework default, which revoked the token
7 * server-side but left the cookie in the browser for the SPA to clear.
8 *
9 * That split no longer works: with an `HttpOnly` cookie the SPA physically
10 * cannot clear it, so the server has to. Without this the cookie would survive
11 * logout and keep being sent — pointing at a revoked token, so requests would
12 * fail closed rather than open, but the stale credential would linger in the
13 * browser for its full 30-day lifetime.
14 *
15 * `Auth.logout()` revokes the underlying token, and it finds that token through
16 * `requestToken()`, which falls back to the cookie — so this works for a
17 * cookie-only client with no `Authorization` header.
18 */
19export default new Action({
20 name: 'LogoutAction',
21 description: 'Logout from the application',
22 method: 'POST',
23
24 async handle() {
25 await Auth.logout()
26
27 return withClearedSessionCookie({
28 message: 'Successfully logged out',
29 })
30 },
31})
app/Actions/Auth/RegisterAction.tsadded+70-0
Changes to app/Actions/Auth/RegisterAction.ts
@@ -0,0 +1,70 @@
1import type { RequestInstance } from '@stacksjs/types'
2import { Action } from '@stacksjs/actions'
3import { Auth, register } from '@stacksjs/auth'
4import { dispatch } from '@stacksjs/events'
5import { response } from '@stacksjs/router'
6import { schema } from '@stacksjs/validation'
7import { withSessionCookie } from '../../Support/sessionCookie'
8
9/**
10 * Register — app-local override of the framework default, identical except that
11 * the new session arrives as an `HttpOnly` cookie rather than being written by
12 * the browser. See `app/Support/sessionCookie.ts`.
13 *
14 * Registration logs the user straight in, so it mints a session and therefore
15 * needs the same cookie treatment as login; without it a freshly registered
16 * user would be the one remaining path that persisted a script-readable token.
17 */
18export default new Action({
19 name: 'RegisterAction',
20 description: 'Register a new user',
21 method: 'POST',
22
23 validations: {
24 email: {
25 rule: schema.string().email(),
26 message: 'Email must be a valid email address.',
27 },
28 password: {
29 rule: schema.string().min(6).max(255),
30 message: 'Password must be between 6 and 255 characters.',
31 },
32 name: {
33 rule: schema.string().min(2).max(255),
34 message: 'Name must be between 2 and 255 characters.',
35 },
36 },
37
38 async handle(request: RequestInstance) {
39 const email = request.get('email')
40 const password = request.get('password')
41 const name = request.get('name')
42
43 const result = await register({ email, password, name })
44
45 if (result) {
46 const user = await Auth.getUserFromToken(result.token)
47
48 // Fire `user:registered` so app/Events.ts listeners (welcome email, CRM
49 // sync, etc.) run. Fire-and-forget — listener errors are caught by the
50 // wildcard handler so a flaky welcome email doesn't fail registration.
51 dispatch('user:registered', {
52 id: user?.id,
53 email: user?.email,
54 name: user?.name,
55 to: user?.email,
56 })
57
58 return withSessionCookie({
59 token: result.token,
60 user: {
61 id: user?.id,
62 email: user?.email,
63 name: user?.name,
64 },
65 }, result.token)
66 }
67
68 return response.error('Registration failed')
69 },
70})
app/Models/Request.tsmodified+25-1
Changes to app/Models/Request.ts
@@ -13,7 +13,31 @@ export default defineModel({
1313 useSeeder: {
1414 count: 50,
1515 },
16 useApi: true,
16
17 // SECURITY: no auto-generated routes at all.
18 //
19 // `useApi.routes` is opt-OUT, not opt-in: `useApi: true` (what this model
20 // carried) makes the ORM generator emit index, show, store, update,
21 // destroy AND a bulk-delete at /api/requests — and the generated write
22 // handlers have no 401 path. `storage/framework/orm/routes.ts` resolves
23 // the authed user only to feed the optional `authedFill` hook, and its
24 // sole 401 branch sits inside `if (own.enforced)`, which needs an
25 // `ownership` config this model does not declare.
26 //
27 // This table is request telemetry — it holds `ip_address`, `user_agent`,
28 // `path` and `status_code`. Readable, that is an unmetered log of who
29 // visited what, which on a site whose whole premise is anonymous reviews
30 // is a de-anonymisation primitive: correlate an IP against a
31 // POST /api/reviews hit and the "anonymous" author is named. Writable, it
32 // lets anyone forge or wipe telemetry that the framework dashboard renders
33 // (defaults/routes/dashboard.ts -> Dashboard/Infrastructure/RequestIndexAction).
34 //
35 // Nothing in this app reads or writes the table today, so there was no
36 // live leak — but the endpoints were real. `routes: []` is the opt-out.
37 useApi: {
38 uri: 'requests',
39 routes: [],
40 },
1741 },
1842
1943 attributes: {
app/Support/sessionCookie.tsadded+80-0
Changes to app/Support/sessionCookie.ts
@@ -0,0 +1,80 @@
1import { authCookie, clearAuthCookie } from '@stacksjs/auth'
2
3/**
4 * Session-cookie plumbing for the auth endpoints.
5 *
6 * ## Why this exists
7 *
8 * The session credential used to be written by the BROWSER:
9 * `resources/stores/auth.ts` took the token out of the login JSON and did
10 * `document.cookie = 'auth-token=…'`. A cookie set that way cannot be
11 * `HttpOnly` — the whole point of `document.cookie` is that scripts can
12 * write it, and anything a script can write a script can read. So the
13 * session token sat in `document.cookie` for 30 days, readable by any
14 * injected script. On an app whose core feature is rendering user-submitted
15 * rich HTML, that turns a single XSS into full session theft, and the reviewer
16 * anonymity the product promises is downstream of those sessions.
17 *
18 * The fix is to move the write to the server, where `HttpOnly` is available.
19 *
20 * ## Why this needed almost no new machinery
21 *
22 * The framework already does cookie auth end-to-end — the app simply wasn't
23 * using it:
24 *
25 * - `authCookie()` (`@stacksjs/auth`) serialises the cookie and ALWAYS
26 * includes `HttpOnly`. It resolves the name through `authCookieName()`
27 * (`config.auth.cookie.name`, else `defaultTokenName`, else `auth-token`),
28 * the lifetime from `config.auth.tokenExpiry`, and `Secure` from
29 * `shouldSecureAuthCookie()`, which deliberately omits `Secure` on
30 * loopback so sessions still work over plain-http localhost.
31 * - `requestToken()` (which `Auth.user()`, `Auth.logout()` and the `auth`
32 * middleware all route through) checks `bearerToken()`, then the
33 * `Authorization: Bearer` header, then FALLS BACK TO THE COOKIE. So the
34 * API already authenticates a cookie-only request. Nothing server-side had
35 * to change to accept this.
36 *
37 * `config/auth.ts` sets `defaultTokenName: 'auth-token'` and declares no
38 * `cookie` block, so the resolved name is `auth-token` — which is already what
39 * `resources/stores/auth.ts` and `resources/middleware/auth.ts` key off. The
40 * three stay in lock-step for free.
41 *
42 * ## Consequences worth knowing
43 *
44 * - **The `/api/*` proxy is now load-bearing for auth, not just convenience.**
45 * The cookie is `SameSite=Lax`, so it is only sent on same-site requests. The
46 * deploy contract already requires the host to proxy `/api/*` onto the page
47 * origin (see DEPLOY.md); without it the cookie is never sent and every
48 * request is anonymous.
49 * - **CSRF posture is unchanged, not worsened.** `requestToken()` already
50 * accepted the cookie before this change, so cookie-borne requests were
51 * always possible; `SameSite=Lax` is what blocks cross-site state change, and
52 * it withholds the cookie on cross-site POST/PUT/DELETE. That matters because
53 * every mutating route in `routes/api.ts` carries `.skipCsrf()`. It holds as
54 * long as no GET mutates state — currently true, and worth keeping true.
55 */
56
57/** JSON response that also installs the HttpOnly session cookie. */
58export function withSessionCookie(body: unknown, token: string, status = 200): Response {
59 return new Response(JSON.stringify(body), {
60 status,
61 headers: {
62 'Content-Type': 'application/json',
63 'Set-Cookie': authCookie(token),
64 // A cached auth response would hand one visitor another's session.
65 'Cache-Control': 'no-store',
66 },
67 })
68}
69
70/** JSON response that clears the session cookie. */
71export function withClearedSessionCookie(body: unknown, status = 200): Response {
72 return new Response(JSON.stringify(body), {
73 status,
74 headers: {
75 'Content-Type': 'application/json',
76 'Set-Cookie': clearAuthCookie(),
77 'Cache-Control': 'no-store',
78 },
79 })
80}
resources/components/Bench/Admin/AdminShell.stxmodified+6-3
Changes to resources/components/Bench/Admin/AdminShell.stx
@@ -44,7 +44,7 @@
4444 await adminStore.signOut()
4545 }
4646
47 // Hydration check: if we have a token (auth.token) but no admin
47 // Hydration check: if we have a session but no admin
4848 // profile in memory yet, refresh it. If the user is not actually an
4949 // admin, bounce to /admin/login. Runs on top-level mount AND
5050 // onMount so both hard-reload and SPA nav are covered (see memory
@@ -55,8 +55,11 @@
5555 if (bootstrapped) return
5656 bootstrapped = true
5757
58 // Not authenticated at all → straight to login.
59 if (!authStore.token()) {
58 // Not authenticated at all → straight to login. Uses isAuthenticated()
59 // rather than token(): the token is in-memory only now that the session
60 // lives in an HttpOnly cookie, so on a hard reload of /admin it would be
61 // empty and bounce a perfectly valid admin session to the login page.
62 if (!authStore.isAuthenticated()) {
6063 if (typeof window !== 'undefined' && window.location.pathname !== '/admin/login')
6164 navigate('/admin/login', true)
6265 return
resources/stores/auth.tsmodified+81-27
Changes to resources/stores/auth.ts
@@ -30,6 +30,13 @@ interface RegisterPayload {
3030// USER cookie is a project-side mirror so `<script server>` blocks
3131// and the pre-paint auth-guard.stx can read user data without an
3232// extra API roundtrip.
33// Retained as documentation even though this file no longer reads it: the name
34// is a four-way contract between config/auth.ts (defaultTokenName), the
35// framework's authCookieName(), resources/middleware/auth.ts (which reads
36// ctx.cookies['auth-token'] server-side, and still can — HttpOnly only blocks
37// scripts, not the server), and the Set-Cookie the auth actions now emit.
38// Deleting it would delete the only place that relationship is written down.
39// eslint-disable-next-line pickier/no-unused-vars -- documents the shared cookie-name contract; see above
3340const AUTH_COOKIE = 'auth-token'
3441const USER_COOKIE = 'auth-user'
3542const COOKIE_MAX_AGE = 60 * 60 * 24 * 30 // 30 days
@@ -42,13 +49,23 @@ function secureAttr(): string {
4249 return (typeof window !== 'undefined' && window.location?.protocol === 'https:') ? '; Secure' : ''
4350}
4451
45function setAuthCookie(token: string): void {
46 if (typeof document === 'undefined') return
47 // SameSite=Lax so navigations from external referrers still send it
48 // (so the server-side auth gate can see the session on a deep-link
49 // back into /profile).
50 document.cookie = `${AUTH_COOKIE}=${encodeURIComponent(token)}; path=/; max-age=${COOKIE_MAX_AGE}; SameSite=Lax${secureAttr()}`
51}
52// NOTE: there is deliberately no setAuthCookie().
53//
54// The session credential is now written by the SERVER as an HttpOnly cookie
55// (app/Support/sessionCookie.ts, applied by the app-local Login/Register
56// actions). A cookie written here via document.cookie could never be HttpOnly
57// — by definition scripts can write it, and whatever a script can write a
58// script can read — which left the session token readable for 30 days by any
59// injected script. On an app that renders user-submitted rich HTML that turns
60// one XSS into session theft.
61//
62// Nothing client-side needs to read it either: the browser attaches it to
63// same-origin requests automatically, and the framework's requestToken() falls
64// back to the cookie after checking the Authorization header.
65//
66// `sessionToken` below is an in-memory mirror, kept only for the Authorization
67// header within the current tab. It is never persisted and dies on reload,
68// after which the HttpOnly cookie carries the session on its own.
5269
5370function setUserCookie(user: UserProfile): void {
5471 if (typeof document === 'undefined') return
@@ -56,11 +73,31 @@ function setUserCookie(user: UserProfile): void {
5673}
5774
5875function clearAuthCookies(): void {
76 sessionToken = ''
5977 if (typeof document === 'undefined') return
60 document.cookie = `${AUTH_COOKIE}=; path=/; max-age=0; SameSite=Lax`
78 // Only the USER cookie — it is a non-credential UI mirror, so it stays
79 // script-writable. The auth cookie is HttpOnly and a document.cookie
80 // expiry for it is simply ignored by the browser; the server clears it
81 // (LogoutAction -> clearAuthCookie()).
82 //
83 // On the 401-expiry path (signOutAndRedirect) no logout request is sent, so
84 // the auth cookie does linger until it expires. That fails CLOSED: it names
85 // a token the server has already rejected, and clearing the user cookie here
86 // means isAuthenticated() is false so nothing retries in a loop. The next
87 // successful login overwrites it.
6188 document.cookie = `${USER_COOKIE}=; path=/; max-age=0; SameSite=Lax`
6289}
6390
91/**
92 * In-memory copy of the current tab's token, used only to set an
93 * `Authorization` header. Belt-and-braces: the HttpOnly cookie is the real
94 * credential and is sufficient on its own, but sending the header too means a
95 * deployment that has not yet wired the same-origin `/api/*` proxy still
96 * authenticates inside the tab that logged in, instead of appearing to log in
97 * and then silently failing every request.
98 */
99let sessionToken = ''
100
64101function readCookie(name: string): string {
65102 if (typeof document === 'undefined') return ''
66103 const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`))
@@ -130,17 +167,25 @@ function isAuthExempt(url: string): boolean {
130167 * module-load.
131168 */
132169async function _apiFetch(url: string, options: RequestInit = {}): Promise<Response> {
133 const token = readCookie(AUTH_COOKIE)
134 const carriedToken = !!token
135
136 if (token) {
137 const headers = new Headers(options.headers)
138 if (!headers.has('Authorization'))
139 headers.set('Authorization', `Bearer ${token}`)
140 if (!headers.has('Accept'))
141 headers.set('Accept', 'application/json')
142 options.headers = headers
143 }
170 // The HttpOnly auth cookie is unreadable from here and does not need to be
171 // read: the browser attaches it to this same-origin request itself. The
172 // header is only added when this tab still holds the token in memory.
173 const headers = new Headers(options.headers)
174 if (sessionToken && !headers.has('Authorization'))
175 headers.set('Authorization', `Bearer ${sessionToken}`)
176 if (!headers.has('Accept'))
177 headers.set('Accept', 'application/json')
178 options.headers = headers
179
180 // Whether we BELIEVED we had a session, which is what makes a 401 mean
181 // "expired" rather than "not logged in". The credential itself is no longer
182 // observable from script, so the non-credential user cookie stands in for it.
183 const carriedToken = !!sessionToken || !!readCookie(USER_COOKIE)
184
185 // Explicit, though it is already the default: the session now rides on a
186 // cookie, so it must be sent. See DEPLOY.md — the host's same-origin
187 // /api/* proxy is load-bearing for auth, not just a convenience.
188 options.credentials = options.credentials ?? 'same-origin'
144189
145190 const res = await fetch(url, options)
146191
@@ -160,7 +205,12 @@ defineStore('auth', () => {
160205 // we've fetched user details (or return an empty user object on
161206 // register), and the UI should treat token presence as "logged in".
162207 // This matches training's pattern.
163 const isAuthenticated = derived<boolean>(() => !!token())
208 // Keyed off the USER object, not the token: the token is in-memory only now,
209 // so it is empty after a reload while the session is still perfectly valid.
210 // `user` is rehydrated synchronously from the user cookie below, so this
211 // survives a hard refresh. External callers that used `token()` as an
212 // "am I signed in" flag (notifications store, AdminShell) now read this.
213 const isAuthenticated = derived<boolean>(() => !!user())
164214 const notifications = state<NotificationItem[]>([])
165215
166216 const unreadCount = derived<number>(() => {
@@ -172,10 +222,10 @@ defineStore('auth', () => {
172222 // same session as the SPA. Migrated from prior localStorage scheme.
173223 if (typeof window !== 'undefined') {
174224 try {
175 const storedToken = readCookie(AUTH_COOKIE)
225 // Only the user half is restorable — the credential is HttpOnly by
226 // design. The session itself survives because the browser keeps sending
227 // that cookie; this just rebuilds the UI's idea of who is signed in.
176228 const storedUser = readCookie(USER_COOKIE)
177 if (storedToken)
178 token.set(storedToken)
179229 if (storedUser)
180230 user.set(JSON.parse(storedUser))
181231 }
@@ -201,7 +251,9 @@ defineStore('auth', () => {
201251 function persistAuth(authToken: string, userData?: UserProfile): void {
202252 if (authToken) {
203253 token.set(authToken)
204 setAuthCookie(authToken)
254 // In-memory only. The server already set the HttpOnly cookie on the
255 // response that carried this token.
256 sessionToken = authToken
205257 }
206258 if (userData) {
207259 user.set(userData)
@@ -267,9 +319,11 @@ defineStore('auth', () => {
267319 // ends up on /login as expected.
268320 try {
269321 const headers: Record<string, string> = { Accept: 'application/json' }
270 if (token())
271 headers.Authorization = `Bearer ${token()}`
272 await fetch('/api/auth/logout', { method: 'POST', headers })
322 if (sessionToken)
323 headers.Authorization = `Bearer ${sessionToken}`
324 // credentials matter here: the server needs the cookie both to find the
325 // token it is revoking and to send back the Set-Cookie that clears it.
326 await fetch('/api/auth/logout', { method: 'POST', headers, credentials: 'same-origin' })
273327 }
274328 catch {}
275329
resources/stores/notifications.tsmodified+5-1
Changes to resources/stores/notifications.ts
@@ -22,7 +22,11 @@ defineStore('notifications', () => {
2222
2323 async function fetchNotifications(): Promise<void> {
2424 const authStore = useStore('auth')
25 if (!authStore.token()) return
25 // isAuthenticated(), not token(): the token is in-memory only since the
26 // session moved to an HttpOnly cookie, so it is empty after a reload while
27 // the session is still valid. Keying off it here meant the bell silently
28 // stopped fetching on every hard refresh.
29 if (!authStore.isAuthenticated()) return
2630 loading.set(true)
2731 try {
2832 const res = await authStore.authFetch(`/api/me/notifications?filter=${filter()}&limit=30`)
tests/feature/ui.test.tsmodified+26-1
Changes to tests/feature/ui.test.ts
@@ -1,4 +1,29 @@
1import { expect, test } from 'bun:test'
1import { afterAll, beforeAll, expect, test } from 'bun:test'
2import { GlobalRegistrator } from '@happy-dom/global-registrator'
3
4/**
5 * DOM-dependent tests.
6 *
7 * The DOM is registered HERE rather than in `tests/setup.ts`, because
8 * `GlobalRegistrator.register()` swaps `Request`/`Response`/`Headers` for
9 * happy-dom's browser implementations, and a browser `Headers` silently drops
10 * `Set-Cookie` and `Cookie` (the Fetch spec calls them forbidden header names).
11 * Registering it globally therefore made `tests/unit/session-cookie.test.ts`
12 * unable to see the `HttpOnly` session cookie it exists to assert. See
13 * `tests/setup.ts` for the measurement.
14 *
15 * Bun runs test files in one process, so the registration is bracketed: any
16 * file that needs a DOM takes it for its own duration and hands the native
17 * globals back afterwards, instead of mutating the whole run.
18 */
19
20beforeAll(() => {
21 GlobalRegistrator.register()
22})
23
24afterAll(async () => {
25 await GlobalRegistrator.unregister()
26})
227
328test('dom test', () => {
429 document.body.innerHTML = `<button>My button</button>`
tests/setup.tsmodified+21-24
Changes to tests/setup.ts
@@ -1,4 +1,3 @@
1import { GlobalRegistrator } from '@happy-dom/global-registrator'
21import { setupTestEnvironment } from '@stacksjs/testing'
32
43/**
@@ -8,33 +7,31 @@ import { setupTestEnvironment } from '@stacksjs/testing'
87 * e.g. you may abstract your module mocks here, if you want
98 * to prevent the original module from being evaluated.
109 *
11 * ## Why happy-dom is registered here
10 * ## Why the DOM is NOT registered here
1211 *
13 * `bun test` runs in a plain server runtime with no `document`, and
14 * `setupTestEnvironment()` does not add one — `@stacksjs/testing` depends only
15 * on `@stacksjs/auth` and `@stacksjs/ts-cloud`, and calling it leaves
16 * `globalThis.document` undefined. So `tests/feature/ui.test.ts`, which came
17 * with the project scaffold and touches `document.body`, failed with
18 * `ReferenceError: document is not defined` from the first commit onward. The
19 * `test` job has been red on `main` ever since, which also meant it could not
20 * report a real regression.
12 * `bun test` has no `document`, and `setupTestEnvironment()` does not add one
13 * (`@stacksjs/testing` depends only on `@stacksjs/auth` and
14 * `@stacksjs/ts-cloud`). happy-dom supplies it — but registering it *globally*
15 * from this preload breaks server-side tests, so it is done per-file instead.
2116 *
22 * `GlobalRegistrator.register()` installs happy-dom's `window`/`document` onto
23 * the global scope — the pattern Bun documents for DOM testing — so a test that
24 * needs a DOM has one, and the suite stops being decorative.
17 * `GlobalRegistrator.register()` swaps `Request`, `Response` and `Headers` for
18 * happy-dom's BROWSER implementations, and the Fetch spec classes `Set-Cookie`
19 * and `Cookie` as forbidden header names — so a browser `Headers` silently
20 * drops them. Measured directly:
2521 *
26 * Registered BEFORE `setupTestEnvironment()` deliberately: anything the
27 * framework's setup does that sniffs for a browser-ish global should see the
28 * DOM already in place rather than racing it.
22 * new Response('x', { headers: { 'Set-Cookie': 'a=b; HttpOnly' } })
23 * .headers.get('set-cookie')
24 * // happy-dom registered -> null
25 * // after unregister() -> "a=b; HttpOnly"
2926 *
30 * Note this makes every test file DOM-ish. That is fine for this project (the
31 * only non-DOM concerns here are pure functions and `Response` objects, neither
32 * of which care), but if a test ever needs to assert genuinely server-only
33 * behaviour, move that file out from under this preload rather than
34 * conditionally unregistering — partial teardown of a registered global DOM is
35 * its own source of cross-test bleed.
27 * With a global DOM, `tests/unit/session-cookie.test.ts` — which exists
28 * precisely to prove the session cookie carries `HttpOnly` — could not observe
29 * a single `Set-Cookie` header, and three of its assertions failed against code
30 * that was working correctly. A test environment that silently hides the
31 * security property under test is worse than one with no DOM at all.
32 *
33 * So: a test that needs a DOM registers it itself and tears it down after. See
34 * `tests/feature/ui.test.ts` for the pattern.
3635 */
3736
38GlobalRegistrator.register()
39
4037setupTestEnvironment()
tests/unit/session-cookie.test.tsadded+116-0
Changes to tests/unit/session-cookie.test.ts
@@ -0,0 +1,116 @@
1import { describe, expect, test } from 'bun:test'
2import { authCookie, authCookieName, authCookieToken, clearAuthCookie } from '@stacksjs/auth'
3import { withClearedSessionCookie, withSessionCookie } from '../../app/Support/sessionCookie'
4
5/**
6 * The security property these tests exist to pin down:
7 *
8 * the session credential must not be readable from JavaScript.
9 *
10 * Before this, `resources/stores/auth.ts` wrote the token with
11 * `document.cookie`, which can never be HttpOnly. The fix moved the write to
12 * the server. These tests assert the three things that has to mean in
13 * practice: the cookie carries HttpOnly, the server can read back what it set,
14 * and logout actually expires it.
15 *
16 * They are deliberately about the cookie contract rather than a full login
17 * round-trip — that needs a running API and a seeded user, so it belongs in a
18 * feature test against the dev server.
19 */
20
21const TOKEN = 'test-plain-token:encrypted-id-payload'
22
23describe('session cookie', () => {
24 test('is HttpOnly, scoped to the whole site, and SameSite=Lax', () => {
25 const cookie = authCookie(TOKEN)
26
27 // The entire point of the change.
28 expect(cookie).toContain('HttpOnly')
29 expect(cookie).toContain('Path=/')
30 // Lax, not Strict: a deep link back into /profile from an external
31 // referrer still has to carry the session so the server-side page gate
32 // (resources/middleware/auth.ts) sees it. Lax is also what withholds the
33 // cookie on cross-site POST, which is what keeps `.skipCsrf()` defensible.
34 expect(cookie).toContain('SameSite=Lax')
35 expect(cookie).toMatch(/Max-Age=\d+/)
36 })
37
38 test('uses the cookie name the rest of the app keys off', () => {
39 // config/auth.ts sets defaultTokenName: 'auth-token' and declares no
40 // cookie block, so this must resolve to 'auth-token' — the same literal
41 // resources/middleware/auth.ts reads out of ctx.cookies. If this ever
42 // drifts, the page-level auth gate stops seeing sessions.
43 expect(authCookieName()).toBe('auth-token')
44 expect(authCookie(TOKEN).startsWith('auth-token=')).toBe(true)
45 })
46
47 test('round-trips: the server can read back the token it set', () => {
48 // authCookieToken() is what requestToken() falls back to, so this is the
49 // actual path a cookie-only request authenticates through.
50 const request = new Request('https://benchreview.org/api/me', {
51 headers: { cookie: authCookie(TOKEN).split(';')[0] },
52 })
53
54 expect(authCookieToken(request)).toBe(TOKEN)
55 })
56
57 test('clearing expires the cookie rather than setting a value', () => {
58 const cleared = clearAuthCookie()
59
60 expect(cleared).toContain('Max-Age=0')
61 expect(cleared).toContain('HttpOnly')
62 expect(cleared.startsWith('auth-token=')).toBe(true)
63 })
64
65 test('omits Secure on loopback so local http sessions still work', () => {
66 // Browsers drop Secure cookies on plain http, so forcing it would break
67 // every localhost login. shouldSecureAuthCookie() handles this; the test
68 // guards the behaviour rather than the implementation.
69 const cookie = authCookie(TOKEN, { secure: false })
70 expect(cookie).not.toContain('Secure')
71
72 expect(authCookie(TOKEN, { secure: true })).toContain('Secure')
73 })
74})
75
76describe('auth responses', () => {
77 test('withSessionCookie installs the cookie and stays uncacheable', async () => {
78 const res = withSessionCookie({ token: TOKEN, user: { id: 1 } }, TOKEN)
79
80 const setCookie = res.headers.get('set-cookie') ?? ''
81 expect(setCookie).toContain('HttpOnly')
82 expect(setCookie).toContain('auth-token=')
83 // A cached auth response would hand one visitor another's session.
84 expect(res.headers.get('cache-control')).toBe('no-store')
85 expect(res.headers.get('content-type')).toBe('application/json')
86 expect(res.status).toBe(200)
87
88 // The JSON body still carries the framework's shape for non-browser clients.
89 expect(await res.json()).toEqual({ token: TOKEN, user: { id: 1 } })
90 })
91
92 test('withClearedSessionCookie expires the cookie', async () => {
93 const res = withClearedSessionCookie({ message: 'Successfully logged out' })
94
95 expect(res.headers.get('set-cookie') ?? '').toContain('Max-Age=0')
96 expect(res.headers.get('cache-control')).toBe('no-store')
97 expect(await res.json()).toEqual({ message: 'Successfully logged out' })
98 })
99})
100
101describe('Request model is not an auto-CRUD surface', () => {
102 test('declares routes: [] so the ORM generator emits nothing', async () => {
103 const model = (await import('../../app/Models/Request')).default as {
104 traits?: { useApi?: unknown }
105 }
106 const useApi = model.traits?.useApi as { uri?: string, routes?: string[] } | boolean | undefined
107
108 // `useApi: true` (the previous value) means the generator defaults to
109 // index + show + store + update + destroy + bulk-delete, all with no 401
110 // path. The `requests` table holds ip_address and user_agent, so a
111 // readable one is a de-anonymisation primitive on an app built around
112 // anonymous reviews.
113 expect(typeof useApi).toBe('object')
114 expect((useApi as { routes?: string[] }).routes).toEqual([])
115 })
116})