Skip to content

Commit 263853b

Browse files
mrstorkpieh
andauthored
fix: cookies set in middleware accessible during the same request (#2847)
* fix: allow cookies in middleware to be accessible during the same request * test: validate functionality of mergeMiddlewareCookies * ci: pin deno version to 1.x until builds are ready for 2.x * ci: add vendor step before running deno tests * chore: include reference link for cookie regex * fix: directives in cookies are joined by a semi-colon with a space * fix: make cookies available during first request on rewrite as well --------- Co-authored-by: Michal Piechowiak <[email protected]>
1 parent bc87632 commit 263853b

File tree

4 files changed

+161
-19
lines changed

4 files changed

+161
-19
lines changed

.github/workflows/deno-test.yml

+3-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ jobs:
1111
- name: Setup Deno
1212
uses: denoland/setup-deno@v1
1313
with:
14-
deno-version: vx.x.x
14+
deno-version: v1.x.x
15+
- name: Vendor Deno modules
16+
run: deno vendor edge-runtime/vendor.ts --output=edge-runtime/vendor --force
1517
- name: Test
1618
run: deno test -A edge-runtime/

edge-runtime/lib/middleware.test.ts

+92
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { assertEquals } from 'https://deno.land/[email protected]/testing/asserts.ts'
2+
import { mergeMiddlewareCookies } from './middleware.ts'
3+
4+
const MIDDLEWARE_HEADER = 'x-middleware-set-cookie'
5+
6+
Deno.test('mergeMiddlewareCookies', async (t) => {
7+
await t.step('should handle empty cookies', async () => {
8+
const request = new Request('https://www.test-url.com')
9+
const response = new Response()
10+
11+
const result = mergeMiddlewareCookies(response, request)
12+
assertEquals(result, '')
13+
})
14+
15+
await t.step('should return request cookies when there are no middleware headers', async () => {
16+
const request = new Request('https://www.test-url.com')
17+
const response = new Response()
18+
19+
request.headers.set('Cookie', 'oatmeal=raisin')
20+
21+
const result = mergeMiddlewareCookies(response, request)
22+
assertEquals(result, 'oatmeal=raisin')
23+
})
24+
25+
await t.step('should not require cookies in request to be set', async () => {
26+
const request = new Request('https://www.test-url.com')
27+
const response = new Response()
28+
29+
response.headers.set(MIDDLEWARE_HEADER, 'peanut=butter; Path=/')
30+
31+
const result = mergeMiddlewareCookies(response, request)
32+
assertEquals(result, 'peanut=butter')
33+
})
34+
35+
await t.step('should merge request and middleware cookies', async () => {
36+
const request = new Request('https://www.test-url.com')
37+
const response = new Response()
38+
39+
request.headers.set('Cookie', 'oatmeal=raisin')
40+
response.headers.set(MIDDLEWARE_HEADER, 'peanut=butter; Path=/')
41+
42+
const result = mergeMiddlewareCookies(response, request)
43+
assertEquals(result, 'oatmeal=raisin; peanut=butter')
44+
})
45+
46+
await t.step('should overwrite request cookies with latest values', async () => {
47+
const request = new Request('https://www.test-url.com')
48+
const response = new Response()
49+
50+
request.headers.set('Cookie', 'oatmeal=chocolate')
51+
response.headers.set(MIDDLEWARE_HEADER, 'oatmeal=raisin; Path=/')
52+
53+
const result = mergeMiddlewareCookies(response, request)
54+
assertEquals(result, 'oatmeal=raisin')
55+
})
56+
57+
await t.step('should not decode middleware cookie values', async () => {
58+
const request = new Request('https://www.test-url.com')
59+
const response = new Response()
60+
61+
response.headers.set(MIDDLEWARE_HEADER, 'greeting=Hello%20from%20the%20cookie; Path=/')
62+
63+
const result = mergeMiddlewareCookies(response, request)
64+
assertEquals(result, 'greeting=Hello%20from%20the%20cookie')
65+
})
66+
67+
await t.step('should support multiple cookies being set in middleware', async () => {
68+
const request = new Request('https://www.test-url.com')
69+
const response = new Response()
70+
71+
response.headers.set(
72+
MIDDLEWARE_HEADER,
73+
'oatmeal=raisin; Path=/,peanut=butter; Path=/,chocolate=chip; Path=/',
74+
)
75+
76+
const result = mergeMiddlewareCookies(response, request)
77+
assertEquals(result, 'oatmeal=raisin; peanut=butter; chocolate=chip')
78+
})
79+
80+
await t.step('should ignore comma in middleware cookie expiry', async () => {
81+
const request = new Request('https://www.test-url.com')
82+
const response = new Response()
83+
84+
response.headers.set(
85+
MIDDLEWARE_HEADER,
86+
'oatmeal=raisin; Path=/; Expires=Wed, 23 Apr 2025 13:37:43 GMT; Max-Age=604800',
87+
)
88+
89+
const result = mergeMiddlewareCookies(response, request)
90+
assertEquals(result, 'oatmeal=raisin')
91+
})
92+
})

edge-runtime/lib/middleware.ts

+26
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Context } from '@netlify/edge-functions'
22

33
import type { ElementHandlers } from '../vendor/deno.land/x/[email protected]/src/index.ts'
4+
import { getCookies } from '../vendor/deno.land/[email protected]/http/cookie.ts'
45

56
type NextDataTransform = <T>(data: T) => T
67

@@ -58,3 +59,28 @@ export const addMiddlewareHeaders = async (
5859
})
5960
return response
6061
}
62+
63+
// This serves the same purpose as the mergeMiddlewareCookies in Next.js but has been customized to our domain
64+
// See: https://github.com/vercel/next.js/blob/6e4495f8430eab33b12cd11dffdd8e27eee6e0cf/packages/next/src/server/async-storage/request-store.ts#L78-L105
65+
export function mergeMiddlewareCookies(middlewareResponse: Response, lambdaRequest: Request) {
66+
let mergedCookies = getCookies(lambdaRequest.headers)
67+
const middlewareCookies = middlewareResponse.headers.get('x-middleware-set-cookie')
68+
69+
if (middlewareCookies) {
70+
// Targets commas that are not followed by whitespace
71+
// See: https://github.com/vercel/next.js/blob/e6145d3a37bb4c7b481fd58e05cdff9046ace8ad/packages/next/src/server/web/spec-extension/response.ts#L58-L66
72+
const regex = new RegExp(/,(?!\s)/)
73+
74+
middlewareCookies.split(regex).forEach((entry) => {
75+
// Extra directives within a cookie are joined on separated by "; "
76+
// See: https://github.com/vercel/next.js/blob/0edb1123066a010eff2aac274f948ca2c6e85c0f/packages/next/src/compiled/%40edge-runtime/cookies/index.js#L32-L47
77+
const [cookie] = entry.split('; ')
78+
const [name, value] = cookie.split('=')
79+
mergedCookies[name] = value
80+
})
81+
}
82+
83+
return Object.entries(mergedCookies)
84+
.map((kv) => kv.join('='))
85+
.join('; ')
86+
}

edge-runtime/lib/response.ts

+40-18
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ import {
66

77
import { updateModifiedHeaders } from './headers.ts'
88
import type { StructuredLogger } from './logging.ts'
9-
import { addMiddlewareHeaders, isMiddlewareRequest, isMiddlewareResponse } from './middleware.ts'
9+
import {
10+
addMiddlewareHeaders,
11+
isMiddlewareRequest,
12+
isMiddlewareResponse,
13+
mergeMiddlewareCookies,
14+
} from './middleware.ts'
1015
import { RequestData } from './next-request.ts'
1116
import {
1217
addBasePath,
@@ -116,12 +121,13 @@ export const buildResponse = async ({
116121
}
117122
return rewriter.transform(response.originResponse)
118123
}
119-
const res = new Response(result.response.body, result.response)
124+
125+
const edgeResponse = new Response(result.response.body, result.response)
120126
request.headers.set('x-nf-next-middleware', 'skip')
121127

122-
let rewrite = res.headers.get('x-middleware-rewrite')
123-
let redirect = res.headers.get('location')
124-
let nextRedirect = res.headers.get('x-nextjs-redirect')
128+
let rewrite = edgeResponse.headers.get('x-middleware-rewrite')
129+
let redirect = edgeResponse.headers.get('location')
130+
let nextRedirect = edgeResponse.headers.get('x-nextjs-redirect')
125131

126132
// Data requests (i.e. requests for /_next/data ) need special handling
127133
const isDataReq = request.headers.has('x-nextjs-data')
@@ -152,7 +158,7 @@ export const buildResponse = async ({
152158
// Data requests might be rewritten to an external URL
153159
// This header tells the client router the redirect target, and if it's external then it will do a full navigation
154160

155-
res.headers.set('x-nextjs-rewrite', relativeUrl)
161+
edgeResponse.headers.set('x-nextjs-rewrite', relativeUrl)
156162
}
157163

158164
if (rewriteUrl.origin !== baseUrl.origin) {
@@ -178,7 +184,7 @@ export const buildResponse = async ({
178184
})
179185
}
180186

181-
return addMiddlewareHeaders(fetch(proxyRequest, { redirect: 'manual' }), res)
187+
return addMiddlewareHeaders(fetch(proxyRequest, { redirect: 'manual' }), edgeResponse)
182188
}
183189

184190
if (isDataReq) {
@@ -197,9 +203,17 @@ export const buildResponse = async ({
197203
logger.withFields({ rewrite_url: rewrite }).debug('Rewrite url is same as original url')
198204
return
199205
}
200-
res.headers.set('x-middleware-rewrite', relativeUrl)
206+
edgeResponse.headers.set('x-middleware-rewrite', relativeUrl)
201207
request.headers.set('x-middleware-rewrite', target)
202-
return addMiddlewareHeaders(context.rewrite(target), res)
208+
209+
// coookies set in middleware need to be available during the lambda request
210+
const newRequest = new Request(target, request)
211+
const newRequestCookies = mergeMiddlewareCookies(edgeResponse, newRequest)
212+
if (newRequestCookies) {
213+
newRequest.headers.set('Cookie', newRequestCookies)
214+
}
215+
216+
return addMiddlewareHeaders(context.next(newRequest), edgeResponse)
203217
}
204218

205219
if (redirect) {
@@ -208,27 +222,35 @@ export const buildResponse = async ({
208222
logger.withFields({ redirect_url: redirect }).debug('Redirect url is same as original url')
209223
return
210224
}
211-
res.headers.set('location', redirect)
225+
edgeResponse.headers.set('location', redirect)
212226
}
213227

214228
// Data requests shouldn't automatically redirect in the browser (they might be HTML pages): they're handled by the router
215229
if (redirect && isDataReq) {
216-
res.headers.delete('location')
217-
res.headers.set('x-nextjs-redirect', relativizeURL(redirect, request.url))
230+
edgeResponse.headers.delete('location')
231+
edgeResponse.headers.set('x-nextjs-redirect', relativizeURL(redirect, request.url))
218232
}
219233

220-
nextRedirect = res.headers.get('x-nextjs-redirect')
234+
nextRedirect = edgeResponse.headers.get('x-nextjs-redirect')
221235

222236
if (nextRedirect && isDataReq) {
223-
res.headers.set('x-nextjs-redirect', normalizeDataUrl(nextRedirect))
237+
edgeResponse.headers.set('x-nextjs-redirect', normalizeDataUrl(nextRedirect))
224238
}
225239

226-
if (res.headers.get('x-middleware-next') === '1') {
227-
res.headers.delete('x-middleware-next')
228-
return addMiddlewareHeaders(context.next(), res)
240+
if (edgeResponse.headers.get('x-middleware-next') === '1') {
241+
edgeResponse.headers.delete('x-middleware-next')
242+
243+
// coookies set in middleware need to be available during the lambda request
244+
const newRequest = new Request(request)
245+
const newRequestCookies = mergeMiddlewareCookies(edgeResponse, newRequest)
246+
if (newRequestCookies) {
247+
newRequest.headers.set('Cookie', newRequestCookies)
248+
}
249+
250+
return addMiddlewareHeaders(context.next(newRequest), edgeResponse)
229251
}
230252

231-
return res
253+
return edgeResponse
232254
}
233255

234256
/**

0 commit comments

Comments
 (0)