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
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