also looking at this
Close unauthenticated /api/requests CRUD, and make the session cookie HttpOnly
#77
11 files
+552
-57
| @@ -0,0 +1,91 @@ | ||
| 1 | import type { RequestInstance } from '@stacksjs/types' | |
| 2 | import { Action } from '@stacksjs/actions' | |
| 3 | import { Auth, createTwoFactorChallenge, getTwoFactorState } from '@stacksjs/auth' | |
| 4 | import { User } from '@stacksjs/orm' | |
| 5 | import { response } from '@stacksjs/router' | |
| 6 | import { schema } from '@stacksjs/validation' | |
| 7 | import { 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 | */ | |
| 33 | export 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 | }) | |
| @@ -0,0 +1,31 @@ | ||
| 1 | import { Action } from '@stacksjs/actions' | |
| 2 | import { Auth } from '@stacksjs/auth' | |
| 3 | import { 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 | */ | |
| 19 | export 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 | }) | |
| @@ -0,0 +1,70 @@ | ||
| 1 | import type { RequestInstance } from '@stacksjs/types' | |
| 2 | import { Action } from '@stacksjs/actions' | |
| 3 | import { Auth, register } from '@stacksjs/auth' | |
| 4 | import { dispatch } from '@stacksjs/events' | |
| 5 | import { response } from '@stacksjs/router' | |
| 6 | import { schema } from '@stacksjs/validation' | |
| 7 | import { 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 | */ | |
| 18 | export 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 | }) | |
| @@ -13,7 +13,31 @@ export default defineModel({ | ||
| 13 | 13 | useSeeder: { |
| 14 | 14 | count: 50, |
| 15 | 15 | }, |
| 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 | }, | |
| 17 | 41 | }, |
| 18 | 42 | |
| 19 | 43 | attributes: { |
| @@ -0,0 +1,80 @@ | ||
| 1 | import { 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. */ | |
| 58 | export 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. */ | |
| 71 | export 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 | } | |
| @@ -44,7 +44,7 @@ | ||
| 44 | 44 | await adminStore.signOut() |
| 45 | 45 | } |
| 46 | 46 | |
| 47 | // Hydration check: if we have a token (auth.token) but no admin | |
| 47 | // Hydration check: if we have a session but no admin | |
| 48 | 48 | // profile in memory yet, refresh it. If the user is not actually an |
| 49 | 49 | // admin, bounce to /admin/login. Runs on top-level mount AND |
| 50 | 50 | // onMount so both hard-reload and SPA nav are covered (see memory |
| @@ -55,8 +55,11 @@ | ||
| 55 | 55 | if (bootstrapped) return |
| 56 | 56 | bootstrapped = true |
| 57 | 57 | |
| 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()) { | |
| 60 | 63 | if (typeof window !== 'undefined' && window.location.pathname !== '/admin/login') |
| 61 | 64 | navigate('/admin/login', true) |
| 62 | 65 | return |
| @@ -30,6 +30,13 @@ interface RegisterPayload { | ||
| 30 | 30 | // USER cookie is a project-side mirror so `<script server>` blocks |
| 31 | 31 | // and the pre-paint auth-guard.stx can read user data without an |
| 32 | 32 | // 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 | |
| 33 | 40 | const AUTH_COOKIE = 'auth-token' |
| 34 | 41 | const USER_COOKIE = 'auth-user' |
| 35 | 42 | const COOKIE_MAX_AGE = 60 * 60 * 24 * 30 // 30 days |
| @@ -42,13 +49,23 @@ function secureAttr(): string { | ||
| 42 | 49 | return (typeof window !== 'undefined' && window.location?.protocol === 'https:') ? '; Secure' : '' |
| 43 | 50 | } |
| 44 | 51 | |
| 45 | function 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. | |
| 52 | 69 | |
| 53 | 70 | function setUserCookie(user: UserProfile): void { |
| 54 | 71 | if (typeof document === 'undefined') return |
| @@ -56,11 +73,31 @@ function setUserCookie(user: UserProfile): void { | ||
| 56 | 73 | } |
| 57 | 74 | |
| 58 | 75 | function clearAuthCookies(): void { |
| 76 | sessionToken = '' | |
| 59 | 77 | 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. | |
| 61 | 88 | document.cookie = `${USER_COOKIE}=; path=/; max-age=0; SameSite=Lax` |
| 62 | 89 | } |
| 63 | 90 | |
| 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 | */ | |
| 99 | let sessionToken = '' | |
| 100 | ||
| 64 | 101 | function readCookie(name: string): string { |
| 65 | 102 | if (typeof document === 'undefined') return '' |
| 66 | 103 | const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)) |
| @@ -130,17 +167,25 @@ function isAuthExempt(url: string): boolean { | ||
| 130 | 167 | * module-load. |
| 131 | 168 | */ |
| 132 | 169 | async 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' | |
| 144 | 189 | |
| 145 | 190 | const res = await fetch(url, options) |
| 146 | 191 | |
| @@ -160,7 +205,12 @@ defineStore('auth', () => { | ||
| 160 | 205 | // we've fetched user details (or return an empty user object on |
| 161 | 206 | // register), and the UI should treat token presence as "logged in". |
| 162 | 207 | // 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()) | |
| 164 | 214 | const notifications = state<NotificationItem[]>([]) |
| 165 | 215 | |
| 166 | 216 | const unreadCount = derived<number>(() => { |
| @@ -172,10 +222,10 @@ defineStore('auth', () => { | ||
| 172 | 222 | // same session as the SPA. Migrated from prior localStorage scheme. |
| 173 | 223 | if (typeof window !== 'undefined') { |
| 174 | 224 | 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. | |
| 176 | 228 | const storedUser = readCookie(USER_COOKIE) |
| 177 | if (storedToken) | |
| 178 | token.set(storedToken) | |
| 179 | 229 | if (storedUser) |
| 180 | 230 | user.set(JSON.parse(storedUser)) |
| 181 | 231 | } |
| @@ -201,7 +251,9 @@ defineStore('auth', () => { | ||
| 201 | 251 | function persistAuth(authToken: string, userData?: UserProfile): void { |
| 202 | 252 | if (authToken) { |
| 203 | 253 | 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 | |
| 205 | 257 | } |
| 206 | 258 | if (userData) { |
| 207 | 259 | user.set(userData) |
| @@ -267,9 +319,11 @@ defineStore('auth', () => { | ||
| 267 | 319 | // ends up on /login as expected. |
| 268 | 320 | try { |
| 269 | 321 | 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' }) | |
| 273 | 327 | } |
| 274 | 328 | catch {} |
| 275 | 329 | |
| @@ -22,7 +22,11 @@ defineStore('notifications', () => { | ||
| 22 | 22 | |
| 23 | 23 | async function fetchNotifications(): Promise<void> { |
| 24 | 24 | 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 | |
| 26 | 30 | loading.set(true) |
| 27 | 31 | try { |
| 28 | 32 | const res = await authStore.authFetch(`/api/me/notifications?filter=${filter()}&limit=30`) |
| @@ -1,4 +1,29 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 1 | import { afterAll, beforeAll, expect, test } from 'bun:test' | |
| 2 | import { 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 | ||
| 20 | beforeAll(() => { | |
| 21 | GlobalRegistrator.register() | |
| 22 | }) | |
| 23 | ||
| 24 | afterAll(async () => { | |
| 25 | await GlobalRegistrator.unregister() | |
| 26 | }) | |
| 2 | 27 | |
| 3 | 28 | test('dom test', () => { |
| 4 | 29 | document.body.innerHTML = `<button>My button</button>` |
| @@ -1,4 +1,3 @@ | ||
| 1 | import { GlobalRegistrator } from '@happy-dom/global-registrator' | |
| 2 | 1 | import { setupTestEnvironment } from '@stacksjs/testing' |
| 3 | 2 | |
| 4 | 3 | /** |
| @@ -8,33 +7,31 @@ import { setupTestEnvironment } from '@stacksjs/testing' | ||
| 8 | 7 | * e.g. you may abstract your module mocks here, if you want |
| 9 | 8 | * to prevent the original module from being evaluated. |
| 10 | 9 | * |
| 11 | * ## Why happy-dom is registered here | |
| 10 | * ## Why the DOM is NOT registered here | |
| 12 | 11 | * |
| 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. | |
| 21 | 16 | * |
| 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: | |
| 25 | 21 | * |
| 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" | |
| 29 | 26 | * |
| 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. | |
| 36 | 35 | */ |
| 37 | 36 | |
| 38 | GlobalRegistrator.register() | |
| 39 | ||
| 40 | 37 | setupTestEnvironment() |