From 4edc8f251358b19c01646a37bf8cd8d4163b59ad Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 13 Jun 2026 22:09:37 +0100 Subject: [PATCH] feat(auth): move session auth to HttpOnly cookies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop exposing the auth token to page JavaScript (review finding 1.8). The server now delivers the session token as an HttpOnly, SameSite=Lax cookie (Secure in production) and the SPA keeps no token at all. Server: - Set the session cookie on register/login; clear it on logout. - Read the token from the cookie or the Authorization header (readSessionToken), including on the /ws upgrade — so the WebSocket authenticates from the cookie and the token no longer rides in the ws query string. - Add GET /auth/session so the client can restore a session from the cookie on load instead of reading a token out of localStorage. - Origin-aware CORS: credentialed (cookie) requests from configured origins (CORS_ALLOWED_ORIGINS, default the dev client) get that origin echoed plus Access-Control-Allow-Credentials; everyone else keeps the non-credentialed wildcard. New config: corsAllowedOrigins, cookieSecure. Client: - All authenticated requests use `credentials: "include"` and drop the Authorization header; the token is never held in JS. - Restore the session via GET /auth/session on load; remove all localStorage session storage. logout() clears the server cookie. - NetClient connects without a token in the URL (cookie carries it). - Dedupe the signed-in top-bar visibility into revealSignedInChrome() (finding 8.6), fixing the divergent edit-character button reveal. Verified end-to-end in a real browser (Playwright/Chromium): register sets an HttpOnly cookie with no token in document.cookie or localStorage; reload restores the session and connects the WebSocket from the cookie alone; logout clears it and returns to login. Server cookie/CORS behavior also covered by router tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/client/src/app/createApp.ts | 156 ++++------- apps/client/src/auth/AuthClient.test.ts | 144 +++++----- apps/client/src/auth/AuthClient.ts | 65 +++-- apps/client/src/friends/FriendClient.test.ts | 18 +- apps/client/src/friends/FriendClient.ts | 22 +- apps/client/src/game/Game.ts | 8 +- apps/client/src/game/NetClient.test.ts | 33 ++- apps/client/src/game/NetClient.ts | 12 +- apps/client/src/rooms/RoomClient.test.ts | 8 +- apps/client/src/rooms/RoomClient.ts | 8 +- apps/server/src/config.test.ts | 5 + apps/server/src/config.ts | 14 + apps/server/src/http/router.test.ts | 50 ++++ apps/server/src/http/router.ts | 267 +++++++++++++------ apps/server/src/index.ts | 8 +- 15 files changed, 486 insertions(+), 332 deletions(-) diff --git a/apps/client/src/app/createApp.ts b/apps/client/src/app/createApp.ts index 583c3fb..10d5647 100644 --- a/apps/client/src/app/createApp.ts +++ b/apps/client/src/app/createApp.ts @@ -1,6 +1,12 @@ import { DEFAULT_AVATAR_APPEARANCE } from "@tilezo/protocol/appearance"; import { DEFAULT_ROOM_ID } from "../assets"; -import { type AuthSession, authenticate, updateAppearance } from "../auth/AuthClient"; +import { + type AuthUser, + authenticate, + fetchSession, + logout as requestLogout, + updateAppearance, +} from "../auth/AuthClient"; import { addFriend, listFriends, removeFriend } from "../friends/FriendClient"; import type { Game } from "../game/Game"; import { createRoom, listRoomTemplates } from "../rooms/RoomClient"; @@ -29,7 +35,8 @@ export function createApp(root: HTMLElement): void { const logOut = document.createElement("button"); const chat = new ChatPanel(); const clientLogger = new ClientLogger(); - let session: AuthSession | undefined; + // The auth token lives only in an HttpOnly cookie; the page keeps just the user profile. + let user: AuthUser | undefined; let game: Game | undefined; let gameStarted = false; let joinedRoom = false; @@ -80,12 +87,12 @@ export function createApp(root: HTMLElement): void { const friendsPanel = new FriendsPanel({ async onAdd(username) { - if (!session) { + if (!user) { return; } try { - await addFriend(session.token, username); + await addFriend(username); await refreshFriends(); status.textContent = "friend added"; } catch (error) { @@ -106,12 +113,12 @@ export function createApp(root: HTMLElement): void { void refreshFriends(); }, async onRemove(friendId) { - if (!session) { + if (!user) { return; } try { - await removeFriend(session.token, friendId); + await removeFriend(friendId); await refreshFriends(); status.textContent = "friend removed"; } catch (error) { @@ -141,15 +148,12 @@ export function createApp(root: HTMLElement): void { joinedRoom = true; chat.clear(); chat.show(); - browseRooms.classList.remove("hidden"); - friendsButton.classList.remove("hidden"); - createRoomButton.classList.remove("hidden"); - editCharacter.classList.remove("hidden"); + revealSignedInChrome({ editCharacter: true }); roomBrowser.setCurrentRoom(snapshot.roomId); roomBrowser.hide(); }, onDisconnected() { - if (!session || !gameStarted) { + if (!user || !gameStarted) { return; } @@ -173,16 +177,15 @@ export function createApp(root: HTMLElement): void { const characterEditor = new CharacterEditor({ initialAppearance: DEFAULT_AVATAR_APPEARANCE, async onSubmit(appearance) { - if (!session) { + if (!user) { return; } status.textContent = "saving character"; try { - const savedAppearance = await updateAppearance(session.token, appearance); - session.user.appearance = savedAppearance; - writeStoredSession(session); + const savedAppearance = await updateAppearance(appearance); + user.appearance = savedAppearance; characterEditor.hide(); if (joinedRoom) { @@ -193,11 +196,9 @@ export function createApp(root: HTMLElement): void { } status.textContent = "connecting"; - await (await ensureGame()).start(session.token); + await (await ensureGame()).start(); gameStarted = true; - browseRooms.classList.remove("hidden"); - friendsButton.classList.remove("hidden"); - createRoomButton.classList.remove("hidden"); + revealSignedInChrome(); roomBrowser.show(); status.textContent = "choose room"; } catch (error) { @@ -220,13 +221,12 @@ export function createApp(root: HTMLElement): void { status.textContent = mode === "register" ? "creating account" : "logging in"; try { - session = await authenticate({ mode, username, password }); - void clientLogger.event(`auth.${mode}.succeeded`, { userId: session.user.id }); - writeStoredSession(session); + user = await authenticate({ mode, username, password }); + void clientLogger.event(`auth.${mode}.succeeded`, { userId: user.id }); logOut.classList.remove("hidden"); friendsButton.classList.remove("hidden"); characterEditor.setSubmitLabel("Enter room"); - characterEditor.show(session.user.appearance); + characterEditor.show(user.appearance); status.textContent = "choose character"; } catch (error) { status.textContent = error instanceof Error ? error.message : "connection failed"; @@ -240,26 +240,23 @@ export function createApp(root: HTMLElement): void { const createRoomDialog = new CreateRoomDialog({ async onSubmit(room) { - if (!session) { + if (!user) { return; } status.textContent = "creating room"; try { - const created = await createRoom(session.token, room); + const created = await createRoom(room); createRoomDialog.hide(); roomBrowser.hide(); if (!gameStarted) { - await (await ensureGame()).start(session.token); + await (await ensureGame()).start(); gameStarted = true; } - browseRooms.classList.remove("hidden"); - friendsButton.classList.remove("hidden"); - createRoomButton.classList.remove("hidden"); - editCharacter.classList.remove("hidden"); + revealSignedInChrome({ editCharacter: true }); game?.joinRoom(created.roomId); status.textContent = "joining new room"; void clientLogger.event("room.created", { @@ -280,13 +277,13 @@ export function createApp(root: HTMLElement): void { }); editCharacter.addEventListener("click", () => { - if (!session) { + if (!user) { return; } editCharacter.classList.add("hidden"); characterEditor.setSubmitLabel("Save character"); - characterEditor.show(session.user.appearance); + characterEditor.show(user.appearance); }); browseRooms.addEventListener("click", () => { @@ -298,7 +295,7 @@ export function createApp(root: HTMLElement): void { }); createRoomButton.addEventListener("click", () => { - if (!session) { + if (!user) { return; } @@ -307,7 +304,7 @@ export function createApp(root: HTMLElement): void { logOut.addEventListener("click", () => { clearReconnectSchedule(); - clearStoredSession(); + void requestLogout(); if (gameStarted) { game?.stop(); @@ -329,30 +326,42 @@ export function createApp(root: HTMLElement): void { ); root.replaceChildren(shell); - const storedSession = readStoredSession(); + void restoreExistingSession(); + + // Reveals the signed-in top-bar controls. `editCharacter` only appears once the player is + // in a room (it is hidden while choosing a character), so callers opt into it explicitly. + function revealSignedInChrome(options: { editCharacter?: boolean } = {}): void { + browseRooms.classList.remove("hidden"); + friendsButton.classList.remove("hidden"); + createRoomButton.classList.remove("hidden"); + logOut.classList.remove("hidden"); - if (storedSession) { - void restoreSession(storedSession); + if (options.editCharacter) { + editCharacter.classList.remove("hidden"); + } } - async function restoreSession(stored: AuthSession): Promise { - session = stored; + async function restoreExistingSession(): Promise { + const existing = await fetchSession(); + + if (!existing) { + return; + } + + user = existing; login.hide(); logOut.classList.remove("hidden"); friendsButton.classList.remove("hidden"); status.textContent = "connecting"; try { - await (await ensureGame()).start(stored.token); + await (await ensureGame()).start(); gameStarted = true; - browseRooms.classList.remove("hidden"); - friendsButton.classList.remove("hidden"); - createRoomButton.classList.remove("hidden"); + revealSignedInChrome(); roomBrowser.show(); status.textContent = "choose room"; } catch (error) { - session = undefined; - clearStoredSession(); + user = undefined; login.element.classList.remove("hidden"); logOut.classList.add("hidden"); chat.hide(); @@ -375,7 +384,7 @@ export function createApp(root: HTMLElement): void { } async function reconnectAfterDisconnect(mode: "resume" | "lobby"): Promise { - if (!session || reconnecting) { + if (!user || reconnecting) { return; } @@ -390,12 +399,9 @@ export function createApp(root: HTMLElement): void { try { const activeGame = await ensureGame(); void clientLogger.event("room.connection.retry", { mode }); - await activeGame.reconnect(session.token); + await activeGame.reconnect(); gameStarted = true; - browseRooms.classList.remove("hidden"); - friendsButton.classList.remove("hidden"); - createRoomButton.classList.remove("hidden"); - editCharacter.classList.remove("hidden"); + revealSignedInChrome({ editCharacter: true }); if (mode === "lobby") { activeGame.joinRoom(DEFAULT_ROOM_ID); @@ -427,12 +433,12 @@ export function createApp(root: HTMLElement): void { } async function refreshFriends(): Promise { - if (!session) { + if (!user) { return; } try { - friendsPanel.setFriends(await listFriends(session.token)); + friendsPanel.setFriends(await listFriends()); } catch (error) { const message = error instanceof Error ? error.message : "Friends failed"; status.textContent = message; @@ -452,47 +458,3 @@ export function createApp(root: HTMLElement): void { } } } - -const SESSION_STORAGE_KEY = "tilezo.authSession"; - -function readStoredSession(): AuthSession | undefined { - try { - const raw = localStorage.getItem(SESSION_STORAGE_KEY); - - if (!raw) { - return undefined; - } - - const parsed = JSON.parse(raw) as Partial; - - if ( - typeof parsed.token !== "string" || - !parsed.user || - typeof parsed.user.id !== "string" || - typeof parsed.user.username !== "string" - ) { - return undefined; - } - - return parsed as AuthSession; - } catch { - clearStoredSession(); - return undefined; - } -} - -function writeStoredSession(session: AuthSession): void { - try { - localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session)); - } catch { - // Private browsing or storage quota errors should not block play. - } -} - -function clearStoredSession(): void { - try { - localStorage.removeItem(SESSION_STORAGE_KEY); - } catch { - // Ignore unavailable browser storage. - } -} diff --git a/apps/client/src/auth/AuthClient.test.ts b/apps/client/src/auth/AuthClient.test.ts index 3b352fe..8b64395 100644 --- a/apps/client/src/auth/AuthClient.test.ts +++ b/apps/client/src/auth/AuthClient.test.ts @@ -1,13 +1,15 @@ import { afterEach, describe, expect, test } from "bun:test"; import { type AvatarAppearance, DEFAULT_AVATAR_APPEARANCE } from "@tilezo/protocol/appearance"; import { DEFAULT_API_URL } from "../assets"; -import { authenticate, updateAppearance } from "./AuthClient"; +import { authenticate, fetchSession, logout, updateAppearance } from "./AuthClient"; const originalFetch = globalThis.fetch; const originalProcess = Object.getOwnPropertyDescriptor(globalThis, "process"); const originalPublicApiUrl = Bun.env.PUBLIC_API_URL; type FetchArgs = Parameters; +const user = { id: "user_1", username: "dan", appearance: DEFAULT_AVATAR_APPEARANCE }; + describe("authenticate", () => { afterEach(() => { globalThis.fetch = originalFetch; @@ -15,51 +17,36 @@ describe("authenticate", () => { restorePublicApiUrl(); }); - test("posts credentials to the selected auth endpoint", async () => { + test("posts credentials with cookies included and returns the user", async () => { delete Bun.env.PUBLIC_API_URL; - const session = { - user: { - id: "user_1", - username: "dan", - appearance: DEFAULT_AVATAR_APPEARANCE, - }, - token: "session-token", - }; const requests: Array<{ url: string; init?: RequestInit }> = []; globalThis.fetch = (async (url: FetchArgs[0], init?: FetchArgs[1]) => { requests.push({ url: String(url), init }); - return Response.json(session); + return Response.json({ user, token: "session-token" }); }) as unknown as typeof fetch; await expect( authenticate({ mode: "register", username: "dan", password: "secret" }), - ).resolves.toEqual(session); + ).resolves.toEqual(user); - expect(requests).toHaveLength(1); - expect(requests[0]).toEqual({ - url: `${DEFAULT_API_URL}/auth/register`, - init: { - method: "POST", - headers: { - "content-type": "application/json", + expect(requests).toEqual([ + { + url: `${DEFAULT_API_URL}/auth/register`, + init: { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username: "dan", password: "secret" }), }, - body: JSON.stringify({ - username: "dan", - password: "secret", - }), }, - }); + ]); }); test("throws the server error message when authentication fails", async () => { delete Bun.env.PUBLIC_API_URL; globalThis.fetch = (async () => Response.json( - { - error: { - message: "Invalid credentials", - }, - }, + { error: { message: "Invalid credentials" } }, { status: 401 }, )) as unknown as typeof fetch; @@ -70,35 +57,15 @@ describe("authenticate", () => { test("uses public API URL overrides", async () => { Bun.env.PUBLIC_API_URL = "http://localhost:4567"; - const requests: Array<{ url: string; init?: RequestInit }> = []; - globalThis.fetch = (async (url: FetchArgs[0], init?: FetchArgs[1]) => { - requests.push({ url: String(url), init }); - return Response.json({ - user: { id: "user_1", username: "dan", appearance: DEFAULT_AVATAR_APPEARANCE }, - token: "session-token", - }); - }) as unknown as typeof fetch; - - await authenticate({ mode: "login", username: "dan", password: "secret" }); - - expect(requests[0]?.url).toBe("http://localhost:4567/auth/login"); - }); - - test("falls back to the default API URL when process is unavailable", async () => { - delete Bun.env.PUBLIC_API_URL; - Reflect.deleteProperty(globalThis, "process"); const requests: string[] = []; globalThis.fetch = (async (url: FetchArgs[0]) => { requests.push(String(url)); - return Response.json({ - user: { id: "user_1", username: "dan", appearance: DEFAULT_AVATAR_APPEARANCE }, - token: "session-token", - }); + return Response.json({ user, token: "session-token" }); }) as unknown as typeof fetch; await authenticate({ mode: "login", username: "dan", password: "secret" }); - expect(requests).toEqual([`${DEFAULT_API_URL}/auth/login`]); + expect(requests[0]).toBe("http://localhost:4567/auth/login"); }); test("does not use the client page origin as the API fallback", async () => { @@ -112,10 +79,7 @@ describe("authenticate", () => { const requests: string[] = []; globalThis.fetch = (async (url: FetchArgs[0]) => { requests.push(String(url)); - return Response.json({ - user: { id: "user_1", username: "dan", appearance: DEFAULT_AVATAR_APPEARANCE }, - token: "session-token", - }); + return Response.json({ user, token: "session-token" }); }) as unknown as typeof fetch; await authenticate({ mode: "register", username: "dan", password: "secret" }); @@ -139,13 +103,73 @@ describe("authenticate", () => { }); }); +describe("fetchSession", () => { + afterEach(() => { + globalThis.fetch = originalFetch; + restorePublicApiUrl(); + }); + + test("returns the user from the session cookie when signed in", async () => { + delete Bun.env.PUBLIC_API_URL; + const requests: Array<{ url: string; init?: RequestInit }> = []; + globalThis.fetch = (async (url: FetchArgs[0], init?: FetchArgs[1]) => { + requests.push({ url: String(url), init }); + return Response.json({ user }); + }) as unknown as typeof fetch; + + await expect(fetchSession()).resolves.toEqual(user); + expect(requests[0]).toEqual({ + url: `${DEFAULT_API_URL}/auth/session`, + init: { credentials: "include" }, + }); + }); + + test("returns undefined when not signed in or the request fails", async () => { + delete Bun.env.PUBLIC_API_URL; + globalThis.fetch = (async () => new Response(null, { status: 401 })) as unknown as typeof fetch; + await expect(fetchSession()).resolves.toBeUndefined(); + + globalThis.fetch = (async () => { + throw new Error("offline"); + }) as unknown as typeof fetch; + await expect(fetchSession()).resolves.toBeUndefined(); + }); +}); + +describe("logout", () => { + afterEach(() => { + globalThis.fetch = originalFetch; + restorePublicApiUrl(); + }); + + test("posts to the logout endpoint with credentials and never throws", async () => { + delete Bun.env.PUBLIC_API_URL; + const requests: Array<{ url: string; init?: RequestInit }> = []; + globalThis.fetch = (async (url: FetchArgs[0], init?: FetchArgs[1]) => { + requests.push({ url: String(url), init }); + return Response.json({ ok: true }); + }) as unknown as typeof fetch; + + await expect(logout()).resolves.toBeUndefined(); + expect(requests[0]).toEqual({ + url: `${DEFAULT_API_URL}/auth/logout`, + init: { method: "POST", credentials: "include" }, + }); + + globalThis.fetch = (async () => { + throw new Error("offline"); + }) as unknown as typeof fetch; + await expect(logout()).resolves.toBeUndefined(); + }); +}); + describe("updateAppearance", () => { afterEach(() => { globalThis.fetch = originalFetch; restorePublicApiUrl(); }); - test("puts the selected appearance with the session token", async () => { + test("puts the selected appearance using the session cookie", async () => { delete Bun.env.PUBLIC_API_URL; const appearance: AvatarAppearance = { ...DEFAULT_AVATAR_APPEARANCE, @@ -158,17 +182,15 @@ describe("updateAppearance", () => { return Response.json({ appearance }); }) as unknown as typeof fetch; - await expect(updateAppearance("session-token", appearance)).resolves.toEqual(appearance); + await expect(updateAppearance(appearance)).resolves.toEqual(appearance); expect(requests).toEqual([ { url: `${DEFAULT_API_URL}/me/appearance`, init: { method: "PUT", - headers: { - authorization: "Bearer session-token", - "content-type": "application/json", - }, + credentials: "include", + headers: { "content-type": "application/json" }, body: JSON.stringify({ appearance }), }, }, diff --git a/apps/client/src/auth/AuthClient.ts b/apps/client/src/auth/AuthClient.ts index 5182d3a..76c0c25 100644 --- a/apps/client/src/auth/AuthClient.ts +++ b/apps/client/src/auth/AuthClient.ts @@ -3,50 +3,65 @@ import { DEFAULT_API_URL } from "../assets"; export type AuthMode = "login" | "register"; -export type AuthSession = { - user: { - id: string; - username: string; - appearance: AvatarAppearance; - }; - token: string; +export type AuthUser = { + id: string; + username: string; + appearance: AvatarAppearance; }; +// The token is never returned to page JavaScript: the server delivers it as an HttpOnly +// session cookie, and every authenticated request below uses `credentials: "include"`. export async function authenticate(options: { mode: AuthMode; username: string; password: string; -}): Promise { +}): Promise { const response = await fetch(`${getApiUrl()}/auth/${options.mode}`, { method: "POST", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify({ - username: options.username, - password: options.password, - }), + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username: options.username, password: options.password }), }); - const body = await readJson(response); + const body = await readJson<{ user?: AuthUser } | { error?: { message?: string } }>(response); if (!response.ok) { throw new Error(body && "error" in body ? body.error?.message : "Login failed"); } - return body as AuthSession; + return (body as { user: AuthUser }).user; +} + +// Restores the signed-in user from the session cookie on page load (returns undefined when +// there is no valid session). This is what replaces reading a token out of localStorage. +export async function fetchSession(): Promise { + try { + const response = await fetch(`${getApiUrl()}/auth/session`, { credentials: "include" }); + + if (!response.ok) { + return undefined; + } + + const body = await readJson<{ user?: AuthUser }>(response); + return body?.user; + } catch { + return undefined; + } +} + +export async function logout(): Promise { + try { + await fetch(`${getApiUrl()}/auth/logout`, { method: "POST", credentials: "include" }); + } catch { + // A failed logout call must not block the local sign-out. + } } -export async function updateAppearance( - token: string, - appearance: AvatarAppearance, -): Promise { +export async function updateAppearance(appearance: AvatarAppearance): Promise { const response = await fetch(`${getApiUrl()}/me/appearance`, { method: "PUT", - headers: { - authorization: `Bearer ${token}`, - "content-type": "application/json", - }, + credentials: "include", + headers: { "content-type": "application/json" }, body: JSON.stringify({ appearance }), }); diff --git a/apps/client/src/friends/FriendClient.test.ts b/apps/client/src/friends/FriendClient.test.ts index df3cb94..83638af 100644 --- a/apps/client/src/friends/FriendClient.test.ts +++ b/apps/client/src/friends/FriendClient.test.ts @@ -11,7 +11,7 @@ describe("FriendClient", () => { globalThis.fetch = originalFetch; }); - test("lists friends with the bearer token", async () => { + test("lists friends using the session cookie", async () => { const friends = [ { id: "user_2", @@ -28,11 +28,11 @@ describe("FriendClient", () => { return Response.json({ friends }); }) as unknown as typeof fetch; - await expect(listFriends("token")).resolves.toEqual(friends); + await expect(listFriends()).resolves.toEqual(friends); expect(requests).toEqual([ { url: `${DEFAULT_API_URL}/friends`, - init: { headers: { authorization: "Bearer token" } }, + init: { credentials: "include" }, }, ]); }); @@ -51,18 +51,16 @@ describe("FriendClient", () => { return Response.json(String(url).endsWith("/friends") ? { friend } : { ok: true }); }) as unknown as typeof fetch; - await expect(addFriend("token", "Kai")).resolves.toEqual(friend); - await expect(removeFriend("token", "user_2")).resolves.toBeUndefined(); + await expect(addFriend("Kai")).resolves.toEqual(friend); + await expect(removeFriend("user_2")).resolves.toBeUndefined(); expect(requests).toEqual([ { url: `${DEFAULT_API_URL}/friends`, init: { method: "POST", - headers: { - authorization: "Bearer token", - "content-type": "application/json", - }, + credentials: "include", + headers: { "content-type": "application/json" }, body: JSON.stringify({ username: "Kai" }), }, }, @@ -70,7 +68,7 @@ describe("FriendClient", () => { url: `${DEFAULT_API_URL}/friends/user_2`, init: { method: "DELETE", - headers: { authorization: "Bearer token" }, + credentials: "include", }, }, ]); diff --git a/apps/client/src/friends/FriendClient.ts b/apps/client/src/friends/FriendClient.ts index e632abf..0fa0c06 100644 --- a/apps/client/src/friends/FriendClient.ts +++ b/apps/client/src/friends/FriendClient.ts @@ -10,12 +10,8 @@ export type FriendSummary = { canJoinRoom: boolean; }; -export async function listFriends(token: string): Promise { - const response = await fetch(`${getApiUrl()}/friends`, { - headers: { - authorization: `Bearer ${token}`, - }, - }); +export async function listFriends(): Promise { + const response = await fetch(`${getApiUrl()}/friends`, { credentials: "include" }); const body = await readJson<{ friends?: FriendSummary[] } | { error?: { message?: string } }>( response, ); @@ -29,13 +25,11 @@ export async function listFriends(token: string): Promise { : []; } -export async function addFriend(token: string, username: string): Promise { +export async function addFriend(username: string): Promise { const response = await fetch(`${getApiUrl()}/friends`, { method: "POST", - headers: { - authorization: `Bearer ${token}`, - "content-type": "application/json", - }, + credentials: "include", + headers: { "content-type": "application/json" }, body: JSON.stringify({ username }), }); const body = await readJson<{ friend?: FriendSummary } | { error?: { message?: string } }>( @@ -49,12 +43,10 @@ export async function addFriend(token: string, username: string): Promise { +export async function removeFriend(friendId: string): Promise { const response = await fetch(`${getApiUrl()}/friends/${encodeURIComponent(friendId)}`, { method: "DELETE", - headers: { - authorization: `Bearer ${token}`, - }, + credentials: "include", }); const body = await readJson<{ ok?: boolean } | { error?: { message?: string } }>(response); diff --git a/apps/client/src/game/Game.ts b/apps/client/src/game/Game.ts index 9f493b2..516cb37 100644 --- a/apps/client/src/game/Game.ts +++ b/apps/client/src/game/Game.ts @@ -27,7 +27,7 @@ export class Game { constructor(private readonly options: GameOptions) {} - async start(token: string): Promise { + async start(): Promise { await this.app.init({ antialias: false, autoDensity: true, @@ -89,7 +89,7 @@ export class Game { }), ); - await this.net.connect(token); + await this.net.connect(); this.connected = true; this.refreshRooms(); @@ -126,12 +126,12 @@ export class Game { this.sendIfConnected({ type: "avatar.appearance.update", appearance }); } - async reconnect(token: string): Promise { + async reconnect(): Promise { // Drop stale avatars before reconnecting so the player does not see a frozen copy of // the previous room while the server re-sends a snapshot (or, in edge cases where no // snapshot arrives, an honest empty scene instead of a misleading stale one). this.scene?.clear(); - await this.net.connect(token); + await this.net.connect(); this.connected = true; this.app.ticker.start(); this.refreshRooms(); diff --git a/apps/client/src/game/NetClient.test.ts b/apps/client/src/game/NetClient.test.ts index 5de0ad2..8f2a85a 100644 --- a/apps/client/src/game/NetClient.test.ts +++ b/apps/client/src/game/NetClient.test.ts @@ -28,7 +28,7 @@ describe("NetClient", () => { const statuses: string[] = []; client.onStatus((status) => statuses.push(status)); - const connected = client.connect("session-token"); + const connected = client.connect(); const socket = currentSocket(); socket.open(); await connected; @@ -36,11 +36,8 @@ describe("NetClient", () => { const message: ClientMessage = { type: "ping", sentAt: "now" }; client.send(message); - expect(socket.url).toBe("ws://localhost:3000/ws?token=session-token"); - expect(statuses).toEqual([ - "connecting to ws://localhost:3000/ws?token=session-token", - "connected", - ]); + expect(socket.url).toBe("ws://localhost:3000/ws"); + expect(statuses).toEqual(["connecting to ws://localhost:3000/ws", "connected"]); expect(socket.sent).toEqual([JSON.stringify(message)]); }); @@ -48,12 +45,12 @@ describe("NetClient", () => { installBrowserFakes("https:"); const client = new NetClient(); - const connected = client.connect("session-token"); + const connected = client.connect(); const socket = currentSocket(); socket.open(); await connected; - expect(socket.url).toBe("wss://localhost:3000/ws?token=session-token"); + expect(socket.url).toBe("wss://localhost:3000/ws"); }); test("uses public websocket URL overrides", async () => { @@ -61,12 +58,12 @@ describe("NetClient", () => { Bun.env.PUBLIC_WS_URL = "ws://localhost:4567/ws"; const client = new NetClient(); - const connected = client.connect("session-token"); + const connected = client.connect(); const socket = currentSocket(); socket.open(); await connected; - expect(socket.url).toBe("ws://localhost:4567/ws?token=session-token"); + expect(socket.url).toBe("ws://localhost:4567/ws"); }); test("does not use the client dev server origin as the websocket fallback", async () => { @@ -74,12 +71,12 @@ describe("NetClient", () => { Reflect.deleteProperty(globalThis, "process"); const client = new NetClient(); - const connected = client.connect("session-token"); + const connected = client.connect(); const socket = currentSocket(); socket.open(); await connected; - expect(socket.url).toBe("ws://localhost:3000/ws?token=session-token"); + expect(socket.url).toBe("ws://localhost:3000/ws"); }); test("falls back to the default websocket URL when process is unavailable", async () => { @@ -87,12 +84,12 @@ describe("NetClient", () => { Reflect.deleteProperty(globalThis, "process"); const client = new NetClient(); - const connected = client.connect("session-token"); + const connected = client.connect(); const socket = currentSocket(); socket.open(); await connected; - expect(socket.url).toBe("ws://localhost:3000/ws?token=session-token"); + expect(socket.url).toBe("ws://localhost:3000/ws"); }); test("emits parsed server messages and ignores unsubscribed handlers", () => { @@ -101,7 +98,7 @@ describe("NetClient", () => { const received: unknown[] = []; const unsubscribe = client.onMessage((message) => received.push(message)); - void client.connect("session-token"); + void client.connect(); const socket = currentSocket(); socket.message(JSON.stringify({ type: "connected", userId: "user_1" })); unsubscribe(); @@ -120,7 +117,7 @@ describe("NetClient", () => { disconnects += 1; }); - const connected = client.connect("session-token"); + const connected = client.connect(); const socket = currentSocket(); socket.message("{"); socket.error(); @@ -129,7 +126,7 @@ describe("NetClient", () => { socket.close(); expect(statuses).toEqual([ - "connecting to ws://localhost:3000/ws?token=session-token", + "connecting to ws://localhost:3000/ws", "received invalid server message", "connection error", "disconnected", @@ -152,7 +149,7 @@ describe("NetClient", () => { disconnects += 1; }); - const connected = client.connect("session-token"); + const connected = client.connect(); const socket = currentSocket(); socket.open(); await connected; diff --git a/apps/client/src/game/NetClient.ts b/apps/client/src/game/NetClient.ts index a61ef69..38e69f5 100644 --- a/apps/client/src/game/NetClient.ts +++ b/apps/client/src/game/NetClient.ts @@ -12,8 +12,8 @@ export class NetClient { private readonly statusHandlers = new Set(); private readonly disconnectHandlers = new Set(); - async connect(token: string): Promise { - const wsUrl = getWebSocketUrl(token); + async connect(): Promise { + const wsUrl = getWebSocketUrl(); this.emitStatus(`connecting to ${wsUrl}`); await new Promise((resolve, reject) => { @@ -125,16 +125,16 @@ export class NetClient { } } -function getWebSocketUrl(token: string): string { +function getWebSocketUrl(): string { + // No token in the URL: the browser sends the HttpOnly session cookie on the WebSocket + // handshake, so the server authenticates the upgrade from the cookie instead. const runtimeConfigured = typeof window === "undefined" ? undefined : window.TILEZO_CONFIG?.PUBLIC_WS_URL; const buildConfigured = typeof process === "undefined" ? undefined : process.env.PUBLIC_WS_URL; const browserDefault = getBrowserWebSocketUrl(); const baseUrl = runtimeConfigured ?? buildConfigured ?? browserDefault ?? DEFAULT_WS_URL; - const url = new URL(baseUrl); - url.searchParams.set("token", token); - return url.toString(); + return new URL(baseUrl).toString(); } function getBrowserWebSocketUrl(): string | undefined { diff --git a/apps/client/src/rooms/RoomClient.test.ts b/apps/client/src/rooms/RoomClient.test.ts index c832347..2300799 100644 --- a/apps/client/src/rooms/RoomClient.test.ts +++ b/apps/client/src/rooms/RoomClient.test.ts @@ -57,17 +57,15 @@ describe("RoomClient", () => { return Response.json(created, { status: 201 }); }) as unknown as typeof fetch; - await expect(createRoom("session-token", request)).resolves.toEqual(created); + await expect(createRoom(request)).resolves.toEqual(created); expect(requests).toEqual([ { url: `${DEFAULT_API_URL}/rooms`, init: { method: "POST", - headers: { - authorization: "Bearer session-token", - "content-type": "application/json", - }, + credentials: "include", + headers: { "content-type": "application/json" }, body: JSON.stringify(request), }, }, diff --git a/apps/client/src/rooms/RoomClient.ts b/apps/client/src/rooms/RoomClient.ts index 507b971..b5c4603 100644 --- a/apps/client/src/rooms/RoomClient.ts +++ b/apps/client/src/rooms/RoomClient.ts @@ -44,13 +44,11 @@ export async function listRoomTemplates(): Promise { : []; } -export async function createRoom(token: string, room: CreateRoomRequest): Promise { +export async function createRoom(room: CreateRoomRequest): Promise { const response = await fetch(`${getApiUrl()}/rooms`, { method: "POST", - headers: { - authorization: `Bearer ${token}`, - "content-type": "application/json", - }, + credentials: "include", + headers: { "content-type": "application/json" }, body: JSON.stringify(room), }); const body = await readJson(response); diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts index df834b4..3094f1e 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -30,6 +30,8 @@ describe("getConfig", () => { maxAuthBodyBytes: 4096, trustProxy: false, metricsToken: undefined, + corsAllowedOrigins: ["http://localhost:3001", "http://127.0.0.1:3001"], + cookieSecure: false, nodeEnv: "development", }); }); @@ -54,6 +56,7 @@ describe("getConfig", () => { MAX_AUTH_BODY_BYTES: "2048", TRUST_PROXY: "true", METRICS_TOKEN: "metrics-secret", + CORS_ALLOWED_ORIGINS: "https://play.tilezo.example", NODE_ENV: "production", }), ).toEqual({ @@ -77,6 +80,8 @@ describe("getConfig", () => { maxAuthBodyBytes: 2048, trustProxy: true, metricsToken: "metrics-secret", + corsAllowedOrigins: ["https://play.tilezo.example"], + cookieSecure: true, nodeEnv: "production", }); }); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index fc13542..a580b9a 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -21,6 +21,8 @@ export type ServerConfig = { maxAuthBodyBytes: number; trustProxy: boolean; metricsToken?: string; + corsAllowedOrigins: string[]; + cookieSecure: boolean; nodeEnv: string; }; @@ -119,6 +121,16 @@ export function getConfig(env = Bun.env): ServerConfig { ); const trustProxy = parseBoolean("TRUST_PROXY", env.TRUST_PROXY, false); const metricsToken = env.METRICS_TOKEN?.trim() || undefined; + // Origins permitted to make credentialed (cookie-bearing) cross-origin requests. The + // dev client runs on :3001 against the API on :3000, so those are the defaults. + const corsAllowedOrigins = ( + env.CORS_ALLOWED_ORIGINS ?? "http://localhost:3001,http://127.0.0.1:3001" + ) + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean); + // Session cookies are marked Secure in production (HTTPS); dev runs over plain HTTP. + const cookieSecure = parseBoolean("COOKIE_SECURE", env.COOKIE_SECURE, isProduction); if (!Number.isInteger(port) || port < 1 || port > 65535) { throw new Error("PORT must be an integer from 1 to 65535"); @@ -155,6 +167,8 @@ export function getConfig(env = Bun.env): ServerConfig { maxAuthBodyBytes, trustProxy, metricsToken, + corsAllowedOrigins, + cookieSecure, nodeEnv, }; } diff --git a/apps/server/src/http/router.test.ts b/apps/server/src/http/router.test.ts index 08a0fef..11e148a 100644 --- a/apps/server/src/http/router.test.ts +++ b/apps/server/src/http/router.test.ts @@ -404,6 +404,56 @@ describe("createHttpRouter", () => { }); }); + describe("cookie sessions", () => { + test("delivers an HttpOnly session cookie on login and clears it on logout", async () => { + const route = createHttpRouter(makeDeps()); + + const loggedIn = await route(request("/auth/login", { body: credentials }), "ip"); + const setCookie = loggedIn.headers.get("set-cookie") ?? ""; + expect(setCookie).toContain("tilezo_session="); + expect(setCookie).toContain("HttpOnly"); + expect(setCookie).toContain("SameSite=Lax"); + + const loggedOut = await route(request("/auth/logout", { token: "good-token" }), "ip"); + expect(loggedOut.headers.get("set-cookie")).toContain("Max-Age=0"); + }); + + test("authenticates GET /auth/session from the cookie", async () => { + const route = createHttpRouter(makeDeps()); + + const signedIn = await route( + request("/auth/session", { + method: "GET", + headers: { cookie: "tilezo_session=good-token" }, + }), + "ip", + ); + expect(signedIn.status).toBe(200); + expect(await signedIn.json()).toMatchObject({ user: { id: "user_1" } }); + + const anon = await route(request("/auth/session", { method: "GET" }), "ip"); + expect(anon.status).toBe(401); + }); + + test("echoes an allowed origin with credentials but not a wildcard", async () => { + const route = createHttpRouter(makeDeps()); + + const allowed = await route( + request("/auth/login", { body: credentials, headers: { origin: "http://localhost:3001" } }), + "ip", + ); + expect(allowed.headers.get("access-control-allow-origin")).toBe("http://localhost:3001"); + expect(allowed.headers.get("access-control-allow-credentials")).toBe("true"); + + const disallowed = await route( + request("/auth/login", { body: credentials, headers: { origin: "http://evil.example" } }), + "ip", + ); + expect(disallowed.headers.get("access-control-allow-origin")).toBe("*"); + expect(disallowed.headers.get("access-control-allow-credentials")).toBeNull(); + }); + }); + describe("metrics", () => { test("serves metrics openly in development and resets", async () => { const route = createHttpRouter(makeDeps()); diff --git a/apps/server/src/http/router.ts b/apps/server/src/http/router.ts index ca9b654..876393f 100644 --- a/apps/server/src/http/router.ts +++ b/apps/server/src/http/router.ts @@ -58,99 +58,109 @@ export function createHttpRouter( path: url.pathname, }); const ctx: RouteContext = { ...deps, request, url, clientKey, requestLogger }; + const response = await dispatch(ctx); + // Set the final, origin-aware CORS headers (allowing credentialed cookie requests + // from configured origins) on whatever the handler returned. + applyCors(response, ctx); + return response; + }; +} - if (request.method === "OPTIONS") { - return new Response(null, { headers: corsHeaders() }); - } +async function dispatch(ctx: RouteContext): Promise { + const { request, url, requestLogger, clientKey } = ctx; - if (url.pathname === "/auth/register" && request.method === "POST") { - const limited = enforceRateLimit( - ctx, - ctx.registerRateLimiter, - clientKey, - "auth.register.rate_limited", - "Too many account registrations, try again shortly", - ); - if (limited) { - return limited; - } - return handleAuthRequest(ctx, "register"); - } + if (request.method === "OPTIONS") { + return new Response(null, { headers: corsHeaders() }); + } - if (url.pathname === "/auth/login" && request.method === "POST") { - const limited = enforceRateLimit( - ctx, - ctx.loginRateLimiter, - `ip:${clientKey}`, - "auth.login.rate_limited", - "Too many login attempts, try again shortly", - ); - if (limited) { - return limited; - } - return handleAuthRequest(ctx, "login"); - } + if (url.pathname === "/auth/session" && request.method === "GET") { + return handleSessionRequest(ctx); + } - if (url.pathname === "/auth/logout" && request.method === "POST") { - return handleLogoutRequest(ctx); + if (url.pathname === "/auth/register" && request.method === "POST") { + const limited = enforceRateLimit( + ctx, + ctx.registerRateLimiter, + clientKey, + "auth.register.rate_limited", + "Too many account registrations, try again shortly", + ); + if (limited) { + return limited; } + return handleAuthRequest(ctx, "register"); + } - if (url.pathname === "/me/appearance") { - return handleAppearanceRequest(ctx); + if (url.pathname === "/auth/login" && request.method === "POST") { + const limited = enforceRateLimit( + ctx, + ctx.loginRateLimiter, + `ip:${clientKey}`, + "auth.login.rate_limited", + "Too many login attempts, try again shortly", + ); + if (limited) { + return limited; } + return handleAuthRequest(ctx, "login"); + } - if (url.pathname === "/client-events" && request.method === "POST") { - return handleClientEventRequest(ctx); - } + if (url.pathname === "/auth/logout" && request.method === "POST") { + return handleLogoutRequest(ctx); + } - if (url.pathname === "/friends" || url.pathname.startsWith("/friends/")) { - return handleFriendsRequest(ctx); - } + if (url.pathname === "/me/appearance") { + return handleAppearanceRequest(ctx); + } - if (url.pathname === "/room-templates" && request.method === "GET") { - requestLogger.info("room_templates.listed"); - return authJson({ templates: listRoomCreationTemplates() }, 200); - } + if (url.pathname === "/client-events" && request.method === "POST") { + return handleClientEventRequest(ctx); + } - if (url.pathname === "/rooms" && request.method === "POST") { - return handleCreateRoomRequest(ctx); - } + if (url.pathname === "/friends" || url.pathname.startsWith("/friends/")) { + return handleFriendsRequest(ctx); + } - if (url.pathname === "/health") { - return Response.json({ ok: true }, { headers: corsHeaders() }); - } + if (url.pathname === "/room-templates" && request.method === "GET") { + requestLogger.info("room_templates.listed"); + return authJson({ templates: listRoomCreationTemplates() }, 200); + } - if (url.pathname === "/debug/metrics" && request.method === "GET") { - if (!metricsAccessAllowed(ctx)) { - return Response.json( - { error: { code: "NOT_FOUND", message: "Not found" } }, - { status: 404 }, - ); - } - // Operational endpoint: no wildcard CORS so arbitrary web origins cannot scrape it. - return Response.json(ctx.metrics.snapshot(ctx.rooms.getMetrics())); - } + if (url.pathname === "/rooms" && request.method === "POST") { + return handleCreateRoomRequest(ctx); + } - if (url.pathname === "/debug/metrics/reset" && request.method === "POST") { - if (ctx.config.nodeEnv === "production") { - return Response.json( - { error: { code: "NOT_FOUND", message: "Not found" } }, - { status: 404, headers: corsHeaders() }, - ); - } + if (url.pathname === "/health") { + return Response.json({ ok: true }, { headers: corsHeaders() }); + } - ctx.metrics.reset(); - requestLogger.info("metrics.reset"); - return Response.json({ ok: true }, { headers: corsHeaders() }); + if (url.pathname === "/debug/metrics" && request.method === "GET") { + if (!metricsAccessAllowed(ctx)) { + return Response.json({ error: { code: "NOT_FOUND", message: "Not found" } }, { status: 404 }); } + // Operational endpoint: no wildcard CORS so arbitrary web origins cannot scrape it. + return Response.json(ctx.metrics.snapshot(ctx.rooms.getMetrics())); + } - return new Response("Tilezo room server", { - headers: { - ...corsHeaders(), - "content-type": "text/plain;charset=utf-8", - }, - }); - }; + if (url.pathname === "/debug/metrics/reset" && request.method === "POST") { + if (ctx.config.nodeEnv === "production") { + return Response.json( + { error: { code: "NOT_FOUND", message: "Not found" } }, + { status: 404, headers: corsHeaders() }, + ); + } + + ctx.metrics.reset(); + requestLogger.info("metrics.reset"); + return Response.json({ ok: true }, { headers: corsHeaders() }); + } + + return new Response("Tilezo room server", { + headers: { + ...corsHeaders(), + "content-type": "text/plain;charset=utf-8", + }, + }); } async function handleAuthRequest(ctx: RouteContext, mode: AuthMode): Promise { @@ -199,7 +209,11 @@ async function handleAuthRequest(ctx: RouteContext, mode: AuthMode): Promise { ); } - const user = await auth.verifyToken(readBearerToken(ctx.request) ?? ""); + const user = await auth.verifyToken(readSessionToken(ctx.request) ?? ""); if (user) { await auth.logout(user.id); requestLogger.info("auth.logout", { userId: user.id }); } - // Idempotent: succeeds whether or not the token was still valid. - return authJson({ ok: true }, 200); + // Idempotent: succeeds whether or not the token was still valid. Always clear the cookie. + return authJson({ ok: true }, 200, { "set-cookie": clearedSessionCookie(ctx.config) }); +} + +async function handleSessionRequest(ctx: RouteContext): Promise { + const { auth } = ctx; + + if (!auth) { + return authJson( + { error: { code: "DATABASE_REQUIRED", message: "Database is required for sessions" } }, + 503, + ); + } + + const user = await auth.verifyToken(readSessionToken(ctx.request) ?? ""); + + if (!user) { + return authJson({ error: { code: "UNAUTHENTICATED", message: "Not signed in" } }, 401); + } + + return authJson({ user }, 200); } async function handleAppearanceRequest(ctx: RouteContext): Promise { @@ -254,7 +287,7 @@ async function handleAppearanceRequest(ctx: RouteContext): Promise { ); } - const user = await auth.verifyToken(readBearerToken(ctx.request) ?? ""); + const user = await auth.verifyToken(readSessionToken(ctx.request) ?? ""); if (!user) { requestLogger.warn("appearance.unauthenticated"); @@ -316,7 +349,7 @@ async function handleClientEventRequest(ctx: RouteContext): Promise { return badBody(body.reason, "INVALID_CLIENT_EVENT", "Invalid client event"); } - const user = await auth?.verifyToken(readBearerToken(ctx.request) ?? ""); + const user = await auth?.verifyToken(readSessionToken(ctx.request) ?? ""); const payload = body.value as { event?: unknown; fields?: unknown; level?: unknown }; const eventName = sanitizeClientEventName(payload.event); const level = sanitizeClientLogLevel(payload.level); @@ -350,7 +383,7 @@ async function handleFriendsRequest(ctx: RouteContext): Promise { ); } - const user = await auth.verifyToken(readBearerToken(ctx.request) ?? ""); + const user = await auth.verifyToken(readSessionToken(ctx.request) ?? ""); if (!user) { requestLogger.warn("friends.unauthenticated"); @@ -434,7 +467,7 @@ async function handleCreateRoomRequest(ctx: RouteContext): Promise { ); } - const user = await auth.verifyToken(readBearerToken(ctx.request) ?? ""); + const user = await auth.verifyToken(readSessionToken(ctx.request) ?? ""); if (!user) { requestLogger.warn("room.create.unauthenticated"); @@ -670,12 +703,78 @@ function authHeaders(error: AuthError): Record { return error.code === "AUTH_BUSY" ? { "retry-after": "1" } : {}; } +export const SESSION_COOKIE_NAME = "tilezo_session"; +const SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; + export function readBearerToken(request: Request): string | undefined { const authorization = request.headers.get("authorization"); const [scheme, token] = authorization?.split(" ") ?? []; return scheme?.toLocaleLowerCase("en-US") === "bearer" ? token : undefined; } +// Resolve the session token from the Authorization header (non-browser/API clients) or +// the HttpOnly session cookie (the SPA, which never stores the token in JS). +export function readSessionToken(request: Request): string | undefined { + return readBearerToken(request) ?? readCookie(request, SESSION_COOKIE_NAME); +} + +export function readCookie(request: Request, name: string): string | undefined { + const header = request.headers.get("cookie"); + + if (!header) { + return undefined; + } + + for (const part of header.split(";")) { + const separator = part.indexOf("="); + + if (separator === -1) { + continue; + } + + if (part.slice(0, separator).trim() === name) { + return decodeURIComponent(part.slice(separator + 1).trim()); + } + } + + return undefined; +} + +export function sessionCookie(token: string, config: { cookieSecure: boolean }): string { + return [ + `${SESSION_COOKIE_NAME}=${encodeURIComponent(token)}`, + "HttpOnly", + "SameSite=Lax", + "Path=/", + `Max-Age=${SESSION_COOKIE_MAX_AGE_SECONDS.toString()}`, + ...(config.cookieSecure ? ["Secure"] : []), + ].join("; "); +} + +export function clearedSessionCookie(config: { cookieSecure: boolean }): string { + return [ + `${SESSION_COOKIE_NAME}=`, + "HttpOnly", + "SameSite=Lax", + "Path=/", + "Max-Age=0", + ...(config.cookieSecure ? ["Secure"] : []), + ].join("; "); +} + +// Sets the final CORS headers on a response. Requests from a configured allowed origin get +// that exact origin echoed plus allow-credentials (required for cookie-bearing fetches); +// everyone else keeps the wildcard from `corsHeaders()` (which forbids credentials). +function applyCors(response: Response, ctx: RouteContext): void { + const origin = ctx.request.headers.get("origin"); + + if (origin && ctx.config.corsAllowedOrigins.includes(origin)) { + response.headers.set("access-control-allow-origin", origin); + response.headers.set("access-control-allow-credentials", "true"); + response.headers.append("vary", "origin"); + } +} + function authJson(body: unknown, status: number, headers: Record = {}): Response { return Response.json(body, { status, headers: { ...corsHeaders(), ...headers } }); } diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index fdc11ce..1d08ac6 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -7,7 +7,7 @@ import { getConfig, type ServerConfig } from "./config"; import { createDatabase } from "./db/db"; import { DrizzlePersistenceStore, type PersistenceStore } from "./db/persistence"; import { DrizzleFriendStore, FriendService } from "./friends/friends"; -import { corsHeaders, createHttpRouter } from "./http/router"; +import { corsHeaders, createHttpRouter, readSessionToken } from "./http/router"; import { handleClose, handleMessage, handleOpen } from "./net/handleMessage"; import type { SocketData } from "./net/socketTypes"; import { createLogger, parseLogLevel } from "./observability/logger"; @@ -161,7 +161,11 @@ async function handleWebSocketUpgrade( bunServer: Server, url: URL, ): Promise { - const user = await auth?.verifyToken(url.searchParams.get("token") ?? ""); + // Browsers send the HttpOnly session cookie on the WS handshake; fall back to the query + // token for non-browser clients. Either way the token stays out of page JavaScript. + const user = await auth?.verifyToken( + readSessionToken(request) ?? url.searchParams.get("token") ?? "", + ); if (!user) { logger.warn("websocket.auth.rejected");