Skip to content

Commit 0ef8de0

Browse files
authored
fix: Overriden custom cookies with pages router (#4427)
* fix overriden cookies in pages router * Create stale-keys-guess.md * fix and add test * fix missing tests
1 parent 107254e commit 0ef8de0

File tree

3 files changed

+254
-5
lines changed

3 files changed

+254
-5
lines changed

.changeset/stale-keys-guess.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@blitzjs/auth": patch
3+
---
4+
5+
fix: Overriden custom cookies with pages router
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import {expect, describe, it, beforeEach} from "vitest"
2+
import {ServerResponse} from "http"
3+
import {Writable} from "stream"
4+
import {append} from "./auth-sessions"
5+
6+
class MockServerResponse extends Writable {
7+
private headers: Map<string, string | string[]> = new Map()
8+
9+
getHeader(name: string) {
10+
return this.headers.get(name)
11+
}
12+
13+
setHeader(name: string, value: string | string[]) {
14+
this.headers.set(name, value)
15+
}
16+
17+
getHeaders() {
18+
return Object.fromEntries(this.headers)
19+
}
20+
21+
_write(_chunk: unknown, _encoding: string, callback: (error?: Error | null) => void): void {
22+
callback()
23+
}
24+
}
25+
26+
describe("append", () => {
27+
let res: ServerResponse
28+
const COOKIE_PREFIX = "auth-tests-cookie-prefix_s"
29+
30+
beforeEach(() => {
31+
res = new MockServerResponse() as unknown as ServerResponse
32+
})
33+
34+
describe("Blitz Auth Flows", () => {
35+
const anonymousSessionCookie = `${COOKIE_PREFIX}AnonymousSessionToken=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJibGl0empzIjp7ImlzQW5vbnltb3VzIjp0cnVlLCJoYW5kbGUiOiJEVjk4OVZadFpra0lpWHFSOFRPX3Fvem44MHBwWFBnaDphand0IiwicHVibGljRGF0YSI6eyJ1c2VySWQiOm51bGx9LCJhbnRpQ1NSRlRva2VuIjoiM25BdDBZWVI0b0xDNnAtTm1fQW1CeFQxRmJmVmpiaXMifSwiaWF0IjoxNzQwODA0NTE4LCJhdWQiOiJibGl0empzIiwiaXNzIjoiYmxpdHpqcyIsInN1YiI6ImFub255bW91cyJ9.ZpMxWh3Yq2Qe4BXzZ61d4V0YGV2luswF7ovE90DxURM; Path=/; Expires=Thu, 28 Feb 2030 04:48:38 GMT; HttpOnly; SameSite=Lax`
36+
const antiCsrfCookie = `${COOKIE_PREFIX}AntiCsrfToken=3nAt0YYR4oLC6p-Nm_AmBxT1FbfVjbis; Path=/; Expires=Thu, 28 Feb 2030 04:48:38 GMT; SameSite=Lax`
37+
const publicDataCookie = `${COOKIE_PREFIX}PublicDataToken=eyJ1c2VySWQiOm51bGx9; Path=/; Expires=Thu, 28 Feb 2030 04:48:38 GMT; SameSite=Lax`
38+
39+
const expiredSessionCookie = `${COOKIE_PREFIX}SessionToken=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Lax`
40+
const expiredAnonymousCookie = `${COOKIE_PREFIX}AnonymousSessionToken=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Lax`
41+
42+
// Login cookies
43+
const loginAntiCsrfCookie = `${COOKIE_PREFIX}AntiCsrfToken=1s3yaYs0yThO-DwOuiepJLzycvN090tO; Path=/; Expires=Mon, 31 Mar 2025 04:48:38 GMT; SameSite=Lax`
44+
const loginPublicDataCookie = `${COOKIE_PREFIX}PublicDataToken=eyJ1c2VySWQiOjEsInJvbGUiOiJ1c2VyIn0%3D; Path=/; Expires=Mon, 31 Mar 2025 04:48:38 GMT; SameSite=Lax`
45+
const loginSessionCookie = `${COOKIE_PREFIX}SessionToken=aGNjc0o5anJ5eTF4bDdqRE5VN09LeEx5QUJoR2toUjc6b3RzO1NaWC1la3YydGR4UGNjWVp6QkM0SlBQbUdWWmZEMlpFOzhhYWU1MDI2M2Q0YmUyNDIxZWYwNDBmMmFhZGI2MDk4YTNiNjhjMTAyZjlmNmNjYTQ4NzUzMGZiYjc0ZTdhYmI7djA%3D; Path=/; Expires=Mon, 31 Mar 2025 04:48:38 GMT; HttpOnly; SameSite=Lax`
46+
47+
it("should handle anonymous session cookies", () => {
48+
append(res, "Set-Cookie", [anonymousSessionCookie, antiCsrfCookie, publicDataCookie])
49+
50+
const cookies = res.getHeader("Set-Cookie") as string[]
51+
expect(cookies).toHaveLength(3)
52+
expect(cookies[0]).toBe(anonymousSessionCookie)
53+
expect(cookies[1]).toBe(antiCsrfCookie)
54+
expect(cookies[2]).toBe(publicDataCookie)
55+
})
56+
57+
it("should deduplicate cookies when the same one is set twice", () => {
58+
append(res, "Set-Cookie", anonymousSessionCookie)
59+
append(res, "Set-Cookie", anonymousSessionCookie)
60+
61+
const cookies = res.getHeader("Set-Cookie") as string[]
62+
expect(cookies).toHaveLength(1)
63+
expect(cookies[0]).toBe(anonymousSessionCookie)
64+
})
65+
66+
it("should replace cookies with same name when values change", () => {
67+
append(res, "Set-Cookie", anonymousSessionCookie)
68+
69+
const updatedAnonymousCookie = `${COOKIE_PREFIX}AnonymousSessionToken=NEW_TOKEN_VALUE; Path=/; SameSite=Lax`
70+
append(res, "Set-Cookie", updatedAnonymousCookie)
71+
72+
const cookies = res.getHeader("Set-Cookie") as string[]
73+
expect(cookies).toHaveLength(1)
74+
expect(cookies[0]).toBe(updatedAnonymousCookie)
75+
})
76+
77+
it("should handle session expiration", () => {
78+
// First add anonymous session
79+
append(res, "Set-Cookie", [anonymousSessionCookie, antiCsrfCookie, publicDataCookie])
80+
81+
append(res, "Set-Cookie", [expiredSessionCookie, expiredAnonymousCookie])
82+
83+
const cookies = res.getHeader("Set-Cookie") as string[]
84+
expect(cookies).toHaveLength(4)
85+
86+
expect(cookies.find((c) => c === expiredSessionCookie)).toBeDefined()
87+
expect(cookies.find((c) => c === expiredAnonymousCookie)).toBeDefined()
88+
})
89+
90+
it("should handle login flow cookies", () => {
91+
// First anonymous session
92+
append(res, "Set-Cookie", [anonymousSessionCookie, antiCsrfCookie, publicDataCookie])
93+
94+
// Then login, which expires anonymous and sets new session
95+
append(res, "Set-Cookie", [
96+
expiredAnonymousCookie,
97+
loginSessionCookie,
98+
loginAntiCsrfCookie,
99+
loginPublicDataCookie,
100+
])
101+
102+
const cookies = res.getHeader("Set-Cookie") as string[]
103+
104+
// Should have 4 cookies:
105+
// - Original antiCsrf cookie (should be replaced by login one)
106+
// - Expired anonymous cookie
107+
// - Login session cookie
108+
// - Login publicData cookie
109+
expect(cookies).toHaveLength(4)
110+
111+
// Check proper replacement by extracting cookie names
112+
const cookieNames = cookies.map((c) => {
113+
const namePart = c.substring(0, c.indexOf("="))
114+
return namePart
115+
})
116+
117+
expect(cookieNames.filter((n) => n === `${COOKIE_PREFIX}AntiCsrfToken`)).toHaveLength(1)
118+
expect(cookieNames.filter((n) => n === `${COOKIE_PREFIX}PublicDataToken`)).toHaveLength(1)
119+
expect(cookieNames.filter((n) => n === `${COOKIE_PREFIX}SessionToken`)).toHaveLength(1)
120+
// the expired cookie
121+
expect(cookieNames.filter((n) => n === `${COOKIE_PREFIX}AnonymousSessionToken`)).toHaveLength(
122+
1,
123+
)
124+
})
125+
126+
it("should properly combine multiple append calls with different cookie groups", () => {
127+
append(res, "Set-Cookie", [anonymousSessionCookie, antiCsrfCookie])
128+
129+
append(res, "Set-Cookie", [publicDataCookie, loginAntiCsrfCookie])
130+
131+
const cookies = res.getHeader("Set-Cookie") as string[]
132+
133+
expect(cookies).toHaveLength(3)
134+
135+
const antiCsrfCookies = cookies.filter((c) => c.includes(`${COOKIE_PREFIX}AntiCsrfToken`))
136+
expect(antiCsrfCookies).toHaveLength(1)
137+
expect(antiCsrfCookies[0]).toBe(loginAntiCsrfCookie)
138+
})
139+
140+
it("should handle the full session flow", () => {
141+
append(res, "Set-Cookie", [anonymousSessionCookie, antiCsrfCookie, publicDataCookie])
142+
143+
const initialCookies = res.getHeader("Set-Cookie") as string[]
144+
expect(initialCookies).toHaveLength(3)
145+
146+
append(res, "Set-Cookie", [
147+
expiredAnonymousCookie,
148+
loginSessionCookie,
149+
loginAntiCsrfCookie,
150+
loginPublicDataCookie,
151+
])
152+
const loginCookies = res.getHeader("Set-Cookie") as string[]
153+
expect(loginCookies).toHaveLength(4)
154+
155+
append(res, "Set-Cookie", [
156+
expiredSessionCookie,
157+
anonymousSessionCookie,
158+
antiCsrfCookie,
159+
publicDataCookie,
160+
])
161+
const logoutCookies = res.getHeader("Set-Cookie") as string[]
162+
expect(logoutCookies).toHaveLength(4)
163+
164+
const cookies = res.getHeader("Set-Cookie") as string[]
165+
166+
const cookieNames = cookies.map((c) => c.substring(0, c.indexOf("=")))
167+
168+
const counts = cookieNames.reduce((acc, name) => {
169+
acc[name] = (acc[name] || 0) + 1
170+
return acc
171+
}, {} as Record<string, number>)
172+
173+
expect(Object.keys(counts).length).toBe(4)
174+
175+
Object.values(counts).forEach((count) => {
176+
expect(count).toBeLessThanOrEqual(3)
177+
})
178+
})
179+
180+
it("should handle cookies with quoted values and special characters", () => {
181+
const specialCookie = `${COOKIE_PREFIX}PublicDataToken="eyJ1c2VySWQiOjEsIm5hbWUiOiJKb2huIERvZSwgSnIuIn0%3D"; Path=/; SameSite=Lax`
182+
append(res, "Set-Cookie", specialCookie)
183+
184+
const cookies = res.getHeader("Set-Cookie") as string[]
185+
expect(cookies).toHaveLength(1)
186+
expect(cookies[0]).toBe(specialCookie)
187+
})
188+
189+
it("should properly merge with existing custom cookies already in the response", () => {
190+
const customCookie1 = "custom1=value1; Path=/; HttpOnly"
191+
const customCookie2 = "custom2=value2; Path=/; HttpOnly"
192+
const existingAuthCookie = `${COOKIE_PREFIX}AntiCsrfToken=old-token; Path=/; SameSite=Lax`
193+
194+
res.setHeader("Set-Cookie", [customCookie1, customCookie2, existingAuthCookie])
195+
196+
// login
197+
append(res, "Set-Cookie", [anonymousSessionCookie, loginAntiCsrfCookie, publicDataCookie])
198+
199+
const cookies = res.getHeader("Set-Cookie") as string[]
200+
201+
expect(cookies).toHaveLength(5)
202+
203+
// Custom cookies should be preserved
204+
expect(cookies).toContain(customCookie1)
205+
expect(cookies).toContain(customCookie2)
206+
207+
// Auth cookies should be correctly applied, with antiCsrf being updated
208+
expect(cookies).toContain(anonymousSessionCookie)
209+
expect(cookies).toContain(loginAntiCsrfCookie)
210+
expect(cookies).toContain(publicDataCookie)
211+
212+
// The old auth cookie should be replaced
213+
expect(cookies).not.toContain(existingAuthCookie)
214+
215+
// Verify we have the right counts of each cookie type
216+
const cookieNames = cookies.map((c) => c.substring(0, c.indexOf("=")))
217+
expect(cookieNames.filter((n) => n === "custom1")).toHaveLength(1)
218+
expect(cookieNames.filter((n) => n === "custom2")).toHaveLength(1)
219+
expect(cookieNames.filter((n) => n === `${COOKIE_PREFIX}AnonymousSessionToken`)).toHaveLength(
220+
1,
221+
)
222+
expect(cookieNames.filter((n) => n === `${COOKIE_PREFIX}AntiCsrfToken`)).toHaveLength(1)
223+
expect(cookieNames.filter((n) => n === `${COOKIE_PREFIX}PublicDataToken`)).toHaveLength(1)
224+
})
225+
})
226+
})

packages/blitz-auth/src/server/auth-sessions.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,7 @@ export class SessionContextClass implements SessionContext {
468468
if (response instanceof Response) {
469469
response.headers.append("Set-Cookie", cookieHeaders!)
470470
} else {
471-
response.setHeader("Set-Cookie", splitCookiesString(cookieHeaders!))
471+
append(response, "Set-Cookie", splitCookiesString(cookieHeaders!))
472472
}
473473

474474
const headers = this._headers.entries()
@@ -1249,12 +1249,31 @@ export async function setPublicDataForUser(userId: PublicData["userId"], data: R
12491249
* @param {string} field
12501250
* @param {string| string[]} val
12511251
*/
1252-
function append(res: ServerResponse, field: string, val: string | string[]) {
1252+
export function append(res: ServerResponse, field: string, val: string | string[]) {
12531253
let prev: string | string[] | undefined = res.getHeader(field) as string | string[] | undefined
12541254
let value = val
12551255

1256-
if (prev !== undefined) {
1257-
// concat the new and prev vals
1256+
if (field.toLowerCase() === "set-cookie") {
1257+
const prevCookies = prev ? (Array.isArray(prev) ? prev : [prev]) : []
1258+
const newCookies = Array.isArray(val) ? val : [val]
1259+
1260+
const allCookies = [...prevCookies, ...newCookies].reduce((acc: string[], cookieHeader) => {
1261+
return acc.concat(splitCookiesString(cookieHeader))
1262+
}, [])
1263+
1264+
const cookieMap = new Map()
1265+
allCookies.forEach((cookieStr) => {
1266+
const firstSemicolon = cookieStr.indexOf(";")
1267+
const cookieNameValue = firstSemicolon > -1 ? cookieStr.slice(0, firstSemicolon) : cookieStr
1268+
const parsed = cookie.parse(cookieNameValue)
1269+
const name = Object.keys(parsed)[0]
1270+
if (name) {
1271+
cookieMap.set(name, cookieStr)
1272+
}
1273+
})
1274+
1275+
value = Array.from(cookieMap.values())
1276+
} else if (prev !== undefined) {
12581277
value = Array.isArray(prev)
12591278
? prev.concat(val)
12601279
: Array.isArray(val)
@@ -1263,7 +1282,6 @@ function append(res: ServerResponse, field: string, val: string | string[]) {
12631282
}
12641283

12651284
value = Array.isArray(value) ? value.map(String) : String(value)
1266-
12671285
res.setHeader(field, value)
12681286
return res
12691287
}

0 commit comments

Comments
 (0)