Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 190 additions & 0 deletions packages/payload/src/auth/extractJWT.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { describe, expect, it } from 'vitest'

import type { BasePayload } from '../index.js'

import { extractJWT } from './extractJWT.js'

function createHeaders(entries: Record<string, string>): Headers {
const headers = new Headers()
for (const [key, value] of Object.entries(entries)) {
headers.set(key, value)
}
return headers
}

function createPayload(overrides: { csrf?: string[] } = {}): BasePayload {
return {
config: {
auth: {
jwtOrder: ['cookie'],
},
cookiePrefix: 'payload',
csrf: overrides.csrf ?? [],
},
} as unknown as BasePayload
}

describe('extractJWT', () => {
const token = 'test-jwt-token'
const cookieHeader = `payload-token=${token}`
const allowedOrigin = 'http://localhost:3000'
const maliciousOrigin = 'http://evil.com'
const payloadWithCsrf = createPayload({ csrf: [allowedOrigin] })

describe('cookie extraction', () => {
it('should return null without cookie', () => {
const result = extractJWT({
headers: createHeaders({}),
payload: createPayload(),
})

expect(result).toBeNull()
})

it('should return cookie without csrf configured', () => {
const result = extractJWT({
headers: createHeaders({ Cookie: cookieHeader }),
payload: createPayload({ csrf: [] }),
})

expect(result).toBe(token)
})

it('should return cookie when Origin matches csrf allowlist', () => {
const result = extractJWT({
headers: createHeaders({
Cookie: cookieHeader,
Origin: allowedOrigin,
}),
payload: payloadWithCsrf,
})

expect(result).toBe(token)
})

it('should reject cookie when Origin not in csrf allowlist', () => {
const result = extractJWT({
headers: createHeaders({
Cookie: cookieHeader,
Origin: maliciousOrigin,
}),
payload: payloadWithCsrf,
})

expect(result).toBeNull()
})

it('should allow same-origin requests with csrf', () => {
const result = extractJWT({
headers: createHeaders({
Cookie: cookieHeader,
'Sec-Fetch-Site': 'same-origin',
}),
payload: payloadWithCsrf,
})

expect(result).toBe(token)
})

it('should allow same-site requests with csrf', () => {
const result = extractJWT({
headers: createHeaders({
Cookie: cookieHeader,
'Sec-Fetch-Site': 'same-site',
}),
payload: payloadWithCsrf,
})

expect(result).toBe(token)
})

it('should allow direct navigations (Sec-Fetch-Site: none) with csrf', () => {
const result = extractJWT({
headers: createHeaders({
Cookie: cookieHeader,
'Sec-Fetch-Site': 'none',
}),
payload: payloadWithCsrf,
})

expect(result).toBe(token)
})

it('should allow cross-site navigations (e.g. email links) with csrf', () => {
const result = extractJWT({
headers: createHeaders({
Cookie: cookieHeader,
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'cross-site',
}),
payload: payloadWithCsrf,
})

expect(result).toBe(token)
})

it('should allow navigate mode without Sec-Fetch-Site header', () => {
const result = extractJWT({
headers: createHeaders({
Cookie: cookieHeader,
'Sec-Fetch-Mode': 'navigate',
}),
payload: payloadWithCsrf,
})

expect(result).toBe(token)
})

it('should reject cross-site non-navigation requests with csrf', () => {
const result = extractJWT({
headers: createHeaders({
Cookie: cookieHeader,
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'cross-site',
}),
payload: payloadWithCsrf,
})

expect(result).toBeNull()
})

it('should reject requests without Sec-Fetch headers with csrf', () => {
const result = extractJWT({
headers: createHeaders({
Cookie: cookieHeader,
}),
payload: payloadWithCsrf,
})

expect(result).toBeNull()
})
})

describe('Bearer extraction', () => {
it('should extract Bearer token', () => {
const payload = createPayload()
payload.config.auth.jwtOrder = ['Bearer']

const result = extractJWT({
headers: createHeaders({ Authorization: `Bearer ${token}` }),
payload,
})

expect(result).toBe(token)
})
})

describe('JWT extraction', () => {
it('should extract JWT token', () => {
const payload = createPayload()
payload.config.auth.jwtOrder = ['JWT']

const result = extractJWT({
headers: createHeaders({ Authorization: `JWT ${token}` }),
payload,
})

expect(result).toBe(token)
})
})
})
17 changes: 12 additions & 5 deletions packages/payload/src/auth/extractJWT.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,22 @@ const extractionMethods: Record<string, ExtractionMethod> = {
return cookieToken
}

// No Origin with csrf configured — fall back to Sec-Fetch-Site
// No Origin with csrf configured — fall back to Sec-Fetch-* headers
const secFetchSite = headers.get('Sec-Fetch-Site')

// Allow same-origin, same-site, and direct navigations (none)
if (secFetchSite === 'same-origin' || secFetchSite === 'same-site' || secFetchSite === 'none') {
const secFetchMode = headers.get('Sec-Fetch-Mode')

// Allow same-origin, same-site, direct navigations (none),
// and cross-site top-level navigations (e.g. clicking a link from an email)
if (
secFetchSite === 'same-origin' ||
secFetchSite === 'same-site' ||
secFetchSite === 'none' ||
secFetchMode === 'navigate'
) {
return cookieToken
}

// Reject cross-site requests and missing header (non-browser clients)
// Reject cross-site non-navigation requests and missing header (non-browser clients)
return null
},
JWT: ({ headers }) => {
Expand Down
Loading