feat(next)!: allows auth strategies to return headers that need to be… (#6964)

## Description

Some authentication strategies may need to set headers for responses,
such as updating cookies via a refresh token, and similar. This PR
extends Payload's auth strategy capabilities with a manner of
accomplishing this.

This is a breaking change if you have custom authentication strategies
in Payload's 3.0 beta. But it's a simple one to update.

Instead of your custom auth strategy returning the `user`, now you must
return an object with a `user` property.

This is because you can now also optionally return `responseHeaders`,
which will be returned by Payload API responses if you define them in
your auth strategies. This can be helpful for cases where you need to
set cookies and similar, directly within your auth strategies.

Before: 

```ts
return user
```

After:

```ts
return { user }
```
This commit is contained in:
James Mikrut
2024-06-27 17:33:25 -04:00
committed by GitHub
parent 07f3f273cd
commit 37e2da012b
14 changed files with 267 additions and 49 deletions

View File

@@ -33,10 +33,12 @@ The `authenticate` function is passed the following arguments:
### Example Strategy
At its core a strategy simply takes information from the incoming request and returns a user. This is exactly how Payloads built-in strategies function.
At its core a strategy simply takes information from the incoming request and returns a user. This is exactly how Payload's built-in strategies function.
Your `authenticate` method should return an object containing a Payload user document and any optional headers that you'd like Payload to set for you when we return a response.
```ts
import { CollectionConfig } from 'payload/types'
import { CollectionConfig } from 'payload'
export const Users: CollectionConfig = {
slug: 'users',
@@ -59,7 +61,18 @@ export const Users: CollectionConfig = {
},
})
return usersQuery.docs[0] || null
return {
// Send the user back to authenticate,
// or send null if no user should be authenticated
user: usersQuery.docs[0] || null,
// Optionally, you can return headers
// that you'd like Payload to set here when
// it returns the response
responseHeaders: new Headers({
'some-header': 'my header value'
})
}
}
}
]

View File

@@ -9,6 +9,7 @@ import { addDataAndFileToRequest } from '../../utilities/addDataAndFileToRequest
import { addLocalesToRequestFromData } from '../../utilities/addLocalesToRequest.js'
import { createPayloadRequest } from '../../utilities/createPayloadRequest.js'
import { headersWithCors } from '../../utilities/headersWithCors.js'
import { mergeHeaders } from '../../utilities/mergeHeaders.js'
const handleError = async (
payload: Payload,
@@ -122,7 +123,7 @@ export const POST =
return response
},
schema,
validationRules: (request, args, defaultRules) => defaultRules.concat(validationRules(args)),
validationRules: (_, args, defaultRules) => defaultRules.concat(validationRules(args)),
})(originalRequest)
const resHeaders = headersWithCors({
@@ -134,6 +135,10 @@ export const POST =
resHeaders.append(key, headers[key])
}
if (basePayloadRequest.responseHeaders) {
mergeHeaders(basePayloadRequest.responseHeaders, resHeaders)
}
return new Response(apiResponse.body, {
headers: resHeaders,
status: apiResponse.status,

View File

@@ -21,6 +21,7 @@ import { addDataAndFileToRequest } from '../../utilities/addDataAndFileToRequest
import { addLocalesToRequestFromData } from '../../utilities/addLocalesToRequest.js'
import { createPayloadRequest } from '../../utilities/createPayloadRequest.js'
import { headersWithCors } from '../../utilities/headersWithCors.js'
import { mergeHeaders } from '../../utilities/mergeHeaders.js'
import { access } from './auth/access.js'
import { forgotPassword } from './auth/forgotPassword.js'
import { init } from './auth/init.js'
@@ -122,7 +123,7 @@ const endpoints = {
},
}
const handleCustomEndpoints = ({
const handleCustomEndpoints = async ({
endpoints,
entitySlug,
payloadRequest,
@@ -130,7 +131,7 @@ const handleCustomEndpoints = ({
endpoints: Endpoint[] | GlobalConfig['endpoints']
entitySlug?: string
payloadRequest: PayloadRequest
}): Promise<Response> | Response => {
}): Promise<Response> => {
if (endpoints && endpoints.length > 0) {
let handlerParams = {}
const { pathname } = payloadRequest
@@ -170,7 +171,15 @@ const handleCustomEndpoints = ({
...payloadRequest.routeParams,
...handlerParams,
}
return customEndpoint.handler(payloadRequest)
const res = await customEndpoint.handler(payloadRequest)
if (res instanceof Response) {
if (payloadRequest.responseHeaders) {
mergeHeaders(payloadRequest.responseHeaders, res.headers)
}
return res
}
}
}
@@ -376,13 +385,20 @@ export const GET =
res = await endpoints.root.GET[slug1]({ req: payloadRequest })
}
if (res instanceof Response) return res
if (res instanceof Response) {
if (req.responseHeaders) {
mergeHeaders(req.responseHeaders, res.headers)
}
return res
}
// root routes
const customEndpointResponse = await handleCustomEndpoints({
endpoints: req.payload.config.endpoints,
payloadRequest: req,
})
if (customEndpointResponse) return customEndpointResponse
return RouteNotFoundResponse({
@@ -545,13 +561,20 @@ export const POST =
res = await endpoints.root.POST[slug1]({ req: payloadRequest })
}
if (res instanceof Response) return res
if (res instanceof Response) {
if (req.responseHeaders) {
mergeHeaders(req.responseHeaders, res.headers)
}
return res
}
// root routes
const customEndpointResponse = await handleCustomEndpoints({
endpoints: req.payload.config.endpoints,
payloadRequest: req,
})
if (customEndpointResponse) return customEndpointResponse
return RouteNotFoundResponse({
@@ -626,13 +649,20 @@ export const DELETE =
}
}
if (res instanceof Response) return res
if (res instanceof Response) {
if (req.responseHeaders) {
mergeHeaders(req.responseHeaders, res.headers)
}
return res
}
// root routes
const customEndpointResponse = await handleCustomEndpoints({
endpoints: req.payload.config.endpoints,
payloadRequest: req,
})
if (customEndpointResponse) return customEndpointResponse
return RouteNotFoundResponse({
@@ -708,13 +738,20 @@ export const PATCH =
}
}
if (res instanceof Response) return res
if (res instanceof Response) {
if (req.responseHeaders) {
mergeHeaders(req.responseHeaders, res.headers)
}
return res
}
// root routes
const customEndpointResponse = await handleCustomEndpoints({
endpoints: req.payload.config.endpoints,
payloadRequest: req,
})
if (customEndpointResponse) return customEndpointResponse
return RouteNotFoundResponse({

View File

@@ -97,11 +97,15 @@ export const createPayloadRequest = async ({
req.payloadDataLoader = getDataLoader(req)
req.user = await executeAuthStrategies({
const { responseHeaders, user } = await executeAuthStrategies({
headers: req.headers,
isGraphQL,
payload,
})
req.user = user
if (responseHeaders) req.responseHeaders = responseHeaders
return req
}

View File

@@ -0,0 +1,33 @@
const headersToJoin = ['set-cookie', 'warning', 'www-authenticate', 'proxy-authenticate', 'vary']
export function mergeHeaders(sourceHeaders: Headers, destinationHeaders: Headers): void {
// Create a map to store combined headers
const combinedHeaders = new Headers()
// Add existing destination headers to the combined map
destinationHeaders.forEach((value, key) => {
combinedHeaders.set(key, value)
})
// Add source headers to the combined map, joining specific headers
sourceHeaders.forEach((value, key) => {
const lowerKey = key.toLowerCase()
if (headersToJoin.includes(lowerKey)) {
if (combinedHeaders.has(key)) {
combinedHeaders.set(key, `${combinedHeaders.get(key)}, ${value}`)
} else {
combinedHeaders.set(key, value)
}
} else {
combinedHeaders.set(key, value)
}
})
// Clear the destination headers and set the combined headers
destinationHeaders.forEach((_, key) => {
destinationHeaders.delete(key)
})
combinedHeaders.forEach((value, key) => {
destinationHeaders.append(key, value)
})
}

View File

@@ -1,14 +1,16 @@
import type { TypedUser } from '../index.js'
import type { AuthStrategyFunctionArgs } from './index.js'
import type { AuthStrategyFunctionArgs, AuthStrategyResult } from './index.js'
export const executeAuthStrategies = async (
args: AuthStrategyFunctionArgs,
): Promise<TypedUser | null> => {
return args.payload.authStrategies.reduce(async (accumulatorPromise, strategy) => {
const authUser = await accumulatorPromise
if (!authUser) {
return strategy.authenticate(args)
}
return authUser
}, Promise.resolve(null))
): Promise<AuthStrategyResult> => {
return args.payload.authStrategies.reduce(
async (accumulatorPromise, strategy) => {
const result: AuthStrategyResult = await accumulatorPromise
if (!result.user) {
return strategy.authenticate(args)
}
return result
},
Promise.resolve({ user: null }),
)
}

View File

@@ -15,6 +15,7 @@ export type AuthArgs = {
export type AuthResult = {
permissions: Permissions
responseHeaders?: Headers
user: TypedUser | null
}
@@ -26,12 +27,13 @@ export const auth = async (args: Required<AuthArgs>): Promise<AuthResult> => {
try {
const shouldCommit = await initTransaction(req)
const user = await executeAuthStrategies({
const { responseHeaders, user } = await executeAuthStrategies({
headers,
payload,
})
req.user = user
req.responseHeaders = responseHeaders
const permissions = await getAccessResults({
req,
@@ -41,6 +43,7 @@ export const auth = async (args: Required<AuthArgs>): Promise<AuthResult> => {
return {
permissions,
responseHeaders,
user,
}
} catch (error: unknown) {

View File

@@ -48,12 +48,14 @@ export const APIKeyAuthentication =
user.collection = collectionConfig.slug
user._strategy = 'api-key'
return user as User
return {
user: user as User,
}
}
} catch (err) {
return null
return { user: null }
}
}
return null
return { user: null }
}

View File

@@ -29,11 +29,13 @@ export const JWTAuthentication: AuthStrategyFunction = async ({
if (user && (!collection.config.auth.verify || user._verified)) {
user.collection = collection.config.slug
user._strategy = 'local-jwt'
return user as User
return {
user: user as User,
}
} else {
return null
return { user: null }
}
} catch (error) {
return null
return { user: null }
}
}

View File

@@ -101,9 +101,15 @@ export type AuthStrategyFunctionArgs = {
isGraphQL?: boolean
payload: Payload
}
export type AuthStrategyResult = {
responseHeaders?: Headers
user: User | null
}
export type AuthStrategyFunction = (
args: AuthStrategyFunctionArgs,
) => Promise<User | null> | User | null
) => AuthStrategyResult | Promise<AuthStrategyResult>
export type AuthStrategy = {
authenticate: AuthStrategyFunction
name: string

View File

@@ -31,6 +31,8 @@ export type CustomPayloadRequestProperties = {
payloadUploadSizes?: Record<string, Buffer>
/** Query params on the request */
query: Record<string, unknown>
/** Any response headers that are required to be set when a response is sent */
responseHeaders?: Headers
/** The route parameters
* @example
* /:collection/:id -> /posts/123

132
pnpm-lock.yaml generated
View File

@@ -633,7 +633,7 @@ importers:
version: 6.11.0(webpack@5.91.0)
css-minimizer-webpack-plugin:
specifier: ^6.0.0
version: 6.0.0(esbuild@0.19.12)(webpack@5.91.0)
version: 6.0.0(webpack@5.91.0)
mini-css-extract-plugin:
specifier: 1.6.2
version: 1.6.2(webpack@5.91.0)
@@ -642,7 +642,7 @@ importers:
version: link:../payload
postcss-loader:
specifier: ^8.1.1
version: 8.1.1(postcss@8.4.38)(typescript@5.5.2)(webpack@5.91.0)
version: 8.1.1(postcss@8.4.38)(webpack@5.91.0)
postcss-preset-env:
specifier: ^9.5.14
version: 9.5.14(postcss@8.4.38)
@@ -657,10 +657,10 @@ importers:
version: 1.12.1
terser-webpack-plugin:
specifier: ^5.3.10
version: 5.3.10(@swc/core@1.6.5)(esbuild@0.19.12)(webpack@5.91.0)
version: 5.3.10(@swc/core@1.6.5)(webpack@5.91.0)
webpack:
specifier: ^5.78.0
version: 5.91.0(@swc/core@1.6.5)(esbuild@0.19.12)(webpack-cli@5.1.4)
version: 5.91.0(@swc/core@1.6.5)(webpack-cli@5.1.4)
webpack-cli:
specifier: ^5.1.4
version: 5.1.4(webpack@5.91.0)
@@ -7859,7 +7859,7 @@ packages:
webpack: 5.x.x
webpack-cli: 5.x.x
dependencies:
webpack: 5.91.0(@swc/core@1.6.5)(esbuild@0.19.12)(webpack-cli@5.1.4)
webpack: 5.91.0(@swc/core@1.6.5)(webpack-cli@5.1.4)
webpack-cli: 5.1.4(webpack@5.91.0)
dev: true
@@ -7870,7 +7870,7 @@ packages:
webpack: 5.x.x
webpack-cli: 5.x.x
dependencies:
webpack: 5.91.0(@swc/core@1.6.5)(esbuild@0.19.12)(webpack-cli@5.1.4)
webpack: 5.91.0(@swc/core@1.6.5)(webpack-cli@5.1.4)
webpack-cli: 5.1.4(webpack@5.91.0)
dev: true
@@ -7885,7 +7885,7 @@ packages:
webpack-dev-server:
optional: true
dependencies:
webpack: 5.91.0(@swc/core@1.6.5)(esbuild@0.19.12)(webpack-cli@5.1.4)
webpack: 5.91.0(@swc/core@1.6.5)(webpack-cli@5.1.4)
webpack-cli: 5.1.4(webpack@5.91.0)
dev: true
@@ -9140,6 +9140,21 @@ packages:
yaml: 1.10.2
dev: false
/cosmiconfig@9.0.0:
resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==}
engines: {node: '>=14'}
peerDependencies:
typescript: 5.5.2
peerDependenciesMeta:
typescript:
optional: true
dependencies:
env-paths: 2.2.1
import-fresh: 3.3.0
js-yaml: 4.1.0
parse-json: 5.2.0
dev: true
/cosmiconfig@9.0.0(typescript@5.5.2):
resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==}
engines: {node: '>=14'}
@@ -9261,10 +9276,10 @@ packages:
postcss-modules-values: 4.0.0(postcss@8.4.38)
postcss-value-parser: 4.2.0
semver: 7.6.0
webpack: 5.91.0(@swc/core@1.6.5)(esbuild@0.19.12)(webpack-cli@5.1.4)
webpack: 5.91.0(@swc/core@1.6.5)(webpack-cli@5.1.4)
dev: true
/css-minimizer-webpack-plugin@6.0.0(esbuild@0.19.12)(webpack@5.91.0):
/css-minimizer-webpack-plugin@6.0.0(webpack@5.91.0):
resolution: {integrity: sha512-BLpR9CCDkKvhO3i0oZQgad6v9pCxUuhSc5RT6iUEy9M8hBXi4TJb5vqF2GQ2deqYHmRi3O6IR9hgAZQWg0EBwA==}
engines: {node: '>= 18.12.0'}
peerDependencies:
@@ -9291,12 +9306,11 @@ packages:
dependencies:
'@jridgewell/trace-mapping': 0.3.25
cssnano: 6.1.2(postcss@8.4.38)
esbuild: 0.19.12
jest-worker: 29.7.0
postcss: 8.4.38
schema-utils: 4.2.0
serialize-javascript: 6.0.2
webpack: 5.91.0(@swc/core@1.6.5)(esbuild@0.19.12)(webpack-cli@5.1.4)
webpack: 5.91.0(@swc/core@1.6.5)(webpack-cli@5.1.4)
dev: true
/css-prefers-color-scheme@9.0.1(postcss@8.4.38):
@@ -13344,7 +13358,7 @@ packages:
dependencies:
loader-utils: 2.0.4
schema-utils: 3.3.0
webpack: 5.91.0(@swc/core@1.6.5)(esbuild@0.19.12)(webpack-cli@5.1.4)
webpack: 5.91.0(@swc/core@1.6.5)(webpack-cli@5.1.4)
webpack-sources: 1.4.3
dev: true
@@ -14682,6 +14696,28 @@ packages:
- typescript
dev: true
/postcss-loader@8.1.1(postcss@8.4.38)(webpack@5.91.0):
resolution: {integrity: sha512-0IeqyAsG6tYiDRCYKQJLAmgQr47DX6N7sFSWvQxt6AcupX8DIdmykuk/o/tx0Lze3ErGHJEp5OSRxrelC6+NdQ==}
engines: {node: '>= 18.12.0'}
peerDependencies:
'@rspack/core': 0.x || 1.x
postcss: ^7.0.0 || ^8.0.1
webpack: ^5.0.0
peerDependenciesMeta:
'@rspack/core':
optional: true
webpack:
optional: true
dependencies:
cosmiconfig: 9.0.0
jiti: 1.21.0
postcss: 8.4.38
semver: 7.6.0
webpack: 5.91.0(@swc/core@1.6.5)(webpack-cli@5.1.4)
transitivePeerDependencies:
- typescript
dev: true
/postcss-logical@7.0.1(postcss@8.4.38):
resolution: {integrity: sha512-8GwUQZE0ri0K0HJHkDv87XOLC8DE0msc+HoWLeKdtjDZEwpZ5xuK3QdV6FhmHSQW40LPkg43QzvATRAI3LsRkg==}
engines: {node: ^14 || ^16 || >=18}
@@ -16093,7 +16129,7 @@ packages:
dependencies:
neo-async: 2.6.2
sass: 1.77.4
webpack: 5.91.0(@swc/core@1.6.5)(esbuild@0.19.12)(webpack-cli@5.1.4)
webpack: 5.91.0(@swc/core@1.6.5)(webpack-cli@5.1.4)
dev: true
/sass@1.77.4:
@@ -16880,7 +16916,7 @@ packages:
dependencies:
'@swc/core': 1.6.5
'@swc/counter': 0.1.3
webpack: 5.91.0(@swc/core@1.6.5)(esbuild@0.19.12)(webpack-cli@5.1.4)
webpack: 5.91.0(@swc/core@1.6.5)(webpack-cli@5.1.4)
dev: true
/swc-plugin-transform-remove-imports@1.12.1:
@@ -17042,6 +17078,31 @@ packages:
webpack: 5.91.0(@swc/core@1.6.5)(esbuild@0.21.5)
dev: true
/terser-webpack-plugin@5.3.10(@swc/core@1.6.5)(webpack@5.91.0):
resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==}
engines: {node: '>= 10.13.0'}
peerDependencies:
'@swc/core': '*'
esbuild: '*'
uglify-js: '*'
webpack: ^5.1.0
peerDependenciesMeta:
'@swc/core':
optional: true
esbuild:
optional: true
uglify-js:
optional: true
dependencies:
'@jridgewell/trace-mapping': 0.3.25
'@swc/core': 1.6.5
jest-worker: 27.5.1
schema-utils: 3.3.0
serialize-javascript: 6.0.2
terser: 5.30.3
webpack: 5.91.0(@swc/core@1.6.5)(webpack-cli@5.1.4)
dev: true
/terser@5.30.3:
resolution: {integrity: sha512-STdUgOUx8rLbMGO9IOwHLpCqolkDITFFQSMYYwKE1N2lY6MVSaeoi10z/EhWxRc6ybqoVmKSkhKYH/XUpl7vSA==}
engines: {node: '>=10'}
@@ -17797,7 +17858,7 @@ packages:
import-local: 3.1.0
interpret: 3.1.1
rechoir: 0.8.0
webpack: 5.91.0(@swc/core@1.6.5)(esbuild@0.19.12)(webpack-cli@5.1.4)
webpack: 5.91.0(@swc/core@1.6.5)(webpack-cli@5.1.4)
webpack-merge: 5.10.0
dev: true
@@ -17903,6 +17964,47 @@ packages:
- uglify-js
dev: true
/webpack@5.91.0(@swc/core@1.6.5)(webpack-cli@5.1.4):
resolution: {integrity: sha512-rzVwlLeBWHJbmgTC/8TvAcu5vpJNII+MelQpylD4jNERPwpBJOE2lEcko1zJX3QJeLjTTAnQxn/OJ8bjDzVQaw==}
engines: {node: '>=10.13.0'}
hasBin: true
peerDependencies:
webpack-cli: '*'
peerDependenciesMeta:
webpack-cli:
optional: true
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.5
'@webassemblyjs/ast': 1.12.1
'@webassemblyjs/wasm-edit': 1.12.1
'@webassemblyjs/wasm-parser': 1.12.1
acorn: 8.11.3
acorn-import-assertions: 1.9.0(acorn@8.11.3)
browserslist: 4.23.0
chrome-trace-event: 1.0.3
enhanced-resolve: 5.16.0
es-module-lexer: 1.5.0
eslint-scope: 5.1.1
events: 3.3.0
glob-to-regexp: 0.4.1
graceful-fs: 4.2.11
json-parse-even-better-errors: 2.3.1
loader-runner: 4.3.0
mime-types: 2.1.35
neo-async: 2.6.2
schema-utils: 3.3.0
tapable: 2.2.1
terser-webpack-plugin: 5.3.10(@swc/core@1.6.5)(webpack@5.91.0)
watchpack: 2.4.1
webpack-cli: 5.1.4(webpack@5.91.0)
webpack-sources: 3.2.3
transitivePeerDependencies:
- '@swc/core'
- esbuild
- uglify-js
dev: true
/whatwg-encoding@2.0.0:
resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==}
engines: {node: '>=12'}

View File

@@ -25,12 +25,17 @@ const customAuthenticationStrategy: AuthStrategyFunction = async ({ headers, pay
})
const user = usersQuery.docs[0] || null
if (!user) return null
if (!user) return { user: null }
return {
...user,
_strategy: `${usersSlug}-${strategyName}`,
collection: usersSlug,
user: {
...user,
_strategy: `${usersSlug}-${strategyName}`,
collection: usersSlug,
},
responseHeaders: new Headers({
'Smile-For-Me': 'please',
}),
}
}

View File

@@ -48,6 +48,8 @@ describe('AuthStrategies', () => {
const data = await response.json()
// Expect that the auth strategy should be able to return headers
expect(response.headers.has('Smile-For-Me')).toBeTruthy()
expect(response.status).toBe(200)
expect(data.user.name).toBe(name)
})