also looking at this
Close unauthenticated /api/requests CRUD, and make the session cookie HttpOnly
#77
11 files
+552
-57
| @@ -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 | |