feat: adds refresh hooks (#6965)
## Description Adds collection `refresh` hooks to override the default `refresh` operation behavior.
This commit is contained in:
@@ -26,6 +26,8 @@ Additionally, `auth`-enabled collections feature the following hooks:
|
||||
- [afterRefresh](#afterrefresh)
|
||||
- [afterMe](#afterme)
|
||||
- [afterForgotPassword](#afterforgotpassword)
|
||||
- [refresh](#refresh)
|
||||
- [me](#me)
|
||||
|
||||
## Config
|
||||
|
||||
@@ -59,6 +61,8 @@ export const ExampleHooks: CollectionConfig = {
|
||||
afterRefresh: [(args) => {...}],
|
||||
afterMe: [(args) => {...}],
|
||||
afterForgotPassword: [(args) => {...}],
|
||||
refresh: [(args) => {...}],
|
||||
me: [(args) => {...}],
|
||||
},
|
||||
}
|
||||
```
|
||||
@@ -299,6 +303,32 @@ const afterForgotPasswordHook: CollectionAfterForgotPasswordHook = async ({
|
||||
}) => {...}
|
||||
```
|
||||
|
||||
### refresh
|
||||
|
||||
For auth-enabled Collections, this hook allows you to optionally replace the default behavior of the `refresh` operation with your own. If you optionally return a value from your hook, the operation will not perform its own logic and continue.
|
||||
|
||||
```ts
|
||||
import type { CollectionRefreshHook } from 'payload'
|
||||
|
||||
const myRefreshHook: CollectionRefreshHook = async ({
|
||||
args, // arguments passed into the `refresh` operation
|
||||
user, // the user as queried from the database
|
||||
}) => {...}
|
||||
```
|
||||
|
||||
### me
|
||||
|
||||
For auth-enabled Collections, this hook allows you to optionally replace the default behavior of the `me` operation with your own. If you optionally return a value from your hook, the operation will not perform its own logic and continue.
|
||||
|
||||
```ts
|
||||
import type { CollectionMeHook } from 'payload'
|
||||
|
||||
const meHook: CollectionMeHook = async ({
|
||||
args, // arguments passed into the `me` operation
|
||||
user, // the user as queried from the database
|
||||
}) => {...}
|
||||
```
|
||||
|
||||
## TypeScript
|
||||
|
||||
Payload exports a type for each Collection hook which can be accessed as follows:
|
||||
@@ -319,5 +349,7 @@ import type {
|
||||
CollectionAfterRefreshHook,
|
||||
CollectionAfterMeHook,
|
||||
CollectionAfterForgotPasswordHook,
|
||||
CollectionRefreshHook,
|
||||
CollectionMeHook,
|
||||
} from 'payload'
|
||||
```
|
||||
|
||||
@@ -32,18 +32,20 @@ export const refresh: CollectionRouteHandler = async ({ collection, req }) => {
|
||||
token,
|
||||
})
|
||||
|
||||
const cookie = generatePayloadCookie({
|
||||
collectionConfig: collection.config,
|
||||
payload: req.payload,
|
||||
token: result.refreshedToken,
|
||||
})
|
||||
if (result.setCookie) {
|
||||
const cookie = generatePayloadCookie({
|
||||
collectionConfig: collection.config,
|
||||
payload: req.payload,
|
||||
token: result.refreshedToken,
|
||||
})
|
||||
|
||||
if (collection.config.auth.removeTokenFromResponses) {
|
||||
delete result.refreshedToken
|
||||
if (collection.config.auth.removeTokenFromResponses) {
|
||||
delete result.refreshedToken
|
||||
}
|
||||
|
||||
headers.set('Set-Cookie', cookie)
|
||||
}
|
||||
|
||||
headers.set('Set-Cookie', cookie)
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
message: t('authentication:tokenRefreshSuccessful'),
|
||||
|
||||
@@ -18,11 +18,9 @@ export type Arguments = {
|
||||
req: PayloadRequestWithData
|
||||
}
|
||||
|
||||
export const meOperation = async ({
|
||||
collection,
|
||||
currentToken,
|
||||
req,
|
||||
}: Arguments): Promise<MeOperationResult> => {
|
||||
export const meOperation = async (args: Arguments): Promise<MeOperationResult> => {
|
||||
const { collection, currentToken, req } = args
|
||||
|
||||
let result: MeOperationResult = {
|
||||
user: null,
|
||||
}
|
||||
@@ -48,16 +46,32 @@ export const meOperation = async ({
|
||||
|
||||
delete user.collection
|
||||
|
||||
result = {
|
||||
collection: req.user.collection,
|
||||
strategy: req.user._strategy,
|
||||
user,
|
||||
// /////////////////////////////////////
|
||||
// me hook - Collection
|
||||
// /////////////////////////////////////
|
||||
|
||||
for (const meHook of collection.config.hooks.me) {
|
||||
const hookResult = await meHook({ args, user })
|
||||
|
||||
if (hookResult) {
|
||||
result.user = hookResult.user
|
||||
result.exp = hookResult.exp
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (currentToken) {
|
||||
const decoded = jwt.decode(currentToken) as jwt.JwtPayload
|
||||
if (decoded) result.exp = decoded.exp
|
||||
result.token = currentToken
|
||||
result.collection = req.user.collection
|
||||
result.strategy = req.user._strategy
|
||||
|
||||
if (!result.user) {
|
||||
result.user = user
|
||||
|
||||
if (currentToken) {
|
||||
const decoded = jwt.decode(currentToken) as jwt.JwtPayload
|
||||
if (decoded) result.exp = decoded.exp
|
||||
if (!collection.config.auth.removeTokenFromResponses) result.token = currentToken
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getFieldsToSign } from '../getFieldsToSign.js'
|
||||
export type Result = {
|
||||
exp: number
|
||||
refreshedToken: string
|
||||
setCookie?: boolean
|
||||
strategy?: string
|
||||
user: Document
|
||||
}
|
||||
@@ -74,23 +75,41 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise<Result>
|
||||
req: args.req,
|
||||
})
|
||||
|
||||
const fieldsToSign = getFieldsToSign({
|
||||
collectionConfig,
|
||||
email: user?.email as string,
|
||||
user: args?.req?.user,
|
||||
})
|
||||
let result: Result
|
||||
|
||||
const refreshedToken = jwt.sign(fieldsToSign, secret, {
|
||||
expiresIn: collectionConfig.auth.tokenExpiration,
|
||||
})
|
||||
// /////////////////////////////////////
|
||||
// refresh hook - Collection
|
||||
// /////////////////////////////////////
|
||||
|
||||
const exp = (jwt.decode(refreshedToken) as Record<string, unknown>).exp as number
|
||||
for (const refreshHook of args.collection.config.hooks.refresh) {
|
||||
const hookResult = await refreshHook({ args, user })
|
||||
|
||||
let result: Result = {
|
||||
exp,
|
||||
refreshedToken,
|
||||
strategy: args.req.user._strategy,
|
||||
user,
|
||||
if (hookResult) {
|
||||
result = hookResult
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
const fieldsToSign = getFieldsToSign({
|
||||
collectionConfig,
|
||||
email: user?.email as string,
|
||||
user: args?.req?.user,
|
||||
})
|
||||
|
||||
const refreshedToken = jwt.sign(fieldsToSign, secret, {
|
||||
expiresIn: collectionConfig.auth.tokenExpiration,
|
||||
})
|
||||
|
||||
const exp = (jwt.decode(refreshedToken) as Record<string, unknown>).exp as number
|
||||
|
||||
result = {
|
||||
exp,
|
||||
refreshedToken,
|
||||
setCookie: true,
|
||||
strategy: args.req.user._strategy,
|
||||
user,
|
||||
}
|
||||
}
|
||||
|
||||
// /////////////////////////////////////
|
||||
@@ -104,9 +123,9 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise<Result>
|
||||
(await hook({
|
||||
collection: args.collection?.config,
|
||||
context: args.req.context,
|
||||
exp,
|
||||
exp: result.exp,
|
||||
req: args.req,
|
||||
token: refreshedToken,
|
||||
token: result.refreshedToken,
|
||||
})) || result
|
||||
}, Promise.resolve())
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ export const defaults = {
|
||||
beforeOperation: [],
|
||||
beforeRead: [],
|
||||
beforeValidate: [],
|
||||
me: [],
|
||||
refresh: [],
|
||||
},
|
||||
timestamps: true,
|
||||
upload: false,
|
||||
|
||||
@@ -141,6 +141,8 @@ const collectionSchema = joi.object().keys({
|
||||
beforeOperation: joi.array().items(joi.func()),
|
||||
beforeRead: joi.array().items(joi.func()),
|
||||
beforeValidate: joi.array().items(joi.func()),
|
||||
me: joi.array().items(joi.func()),
|
||||
refresh: joi.array().items(joi.func()),
|
||||
}),
|
||||
labels: joi.object({
|
||||
plural: joi
|
||||
|
||||
@@ -8,6 +8,11 @@ import type {
|
||||
CustomSaveDraftButton,
|
||||
CustomUpload,
|
||||
} from '../../admin/types.js'
|
||||
import type { Arguments as MeArguments } from '../../auth/operations/me.js'
|
||||
import type {
|
||||
Arguments as RefreshArguments,
|
||||
Result as RefreshResult,
|
||||
} from '../../auth/operations/refresh.js'
|
||||
import type { Auth, ClientUser, IncomingAuthType } from '../../auth/types.js'
|
||||
import type {
|
||||
Access,
|
||||
@@ -204,6 +209,16 @@ export type AfterMeHook<T extends TypeWithID = any> = (args: {
|
||||
response: unknown
|
||||
}) => any
|
||||
|
||||
export type RefreshHook<T extends TypeWithID = any> = (args: {
|
||||
args: RefreshArguments
|
||||
user: T
|
||||
}) => Promise<RefreshResult | void> | (RefreshResult | void)
|
||||
|
||||
export type MeHook<T extends TypeWithID = any> = (args: {
|
||||
args: MeArguments
|
||||
user: T
|
||||
}) => ({ exp: number; user: T } | void) | Promise<{ exp: number; user: T } | void>
|
||||
|
||||
export type AfterRefreshHook<T extends TypeWithID = any> = (args: {
|
||||
/** The collection which this hook is being run on */
|
||||
collection: SanitizedCollectionConfig
|
||||
@@ -398,6 +413,19 @@ export type CollectionConfig<TSlug extends CollectionSlug = any> = {
|
||||
beforeOperation?: BeforeOperationHook[]
|
||||
beforeRead?: BeforeReadHook[]
|
||||
beforeValidate?: BeforeValidateHook[]
|
||||
/**
|
||||
/**
|
||||
* Use the `me` hook to control the `me` operation.
|
||||
* Here, you can optionally instruct the me operation to return early,
|
||||
* and skip its default logic.
|
||||
*/
|
||||
me?: MeHook[]
|
||||
/**
|
||||
* Use the `refresh` hook to control the refresh operation.
|
||||
* Here, you can optionally instruct the refresh operation to return early,
|
||||
* and skip its default logic.
|
||||
*/
|
||||
refresh?: RefreshHook[]
|
||||
}
|
||||
/**
|
||||
* Label configuration
|
||||
|
||||
@@ -674,6 +674,8 @@ export type {
|
||||
Collection,
|
||||
CollectionConfig,
|
||||
DataFromCollectionSlug,
|
||||
MeHook as CollectionMeHook,
|
||||
RefreshHook as CollectionRefreshHook,
|
||||
RequiredDataFromCollection,
|
||||
RequiredDataFromCollectionSlug,
|
||||
SanitizedCollectionConfig,
|
||||
|
||||
@@ -4,6 +4,8 @@ import { AuthenticationError } from 'payload'
|
||||
|
||||
import { devUser, regularUser } from '../../../credentials.js'
|
||||
import { afterLoginHook } from './afterLoginHook.js'
|
||||
import { meHook } from './meHook.js'
|
||||
import { refreshHook } from './refreshHook.js'
|
||||
|
||||
const beforeLoginHook: BeforeLoginHook = ({ req, user }) => {
|
||||
const isAdmin = user.roles.includes('admin') ? user : undefined
|
||||
@@ -45,6 +47,8 @@ const Users: CollectionConfig = {
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
me: [meHook],
|
||||
refresh: [refreshHook],
|
||||
afterLogin: [afterLoginHook],
|
||||
beforeLogin: [beforeLoginHook],
|
||||
},
|
||||
|
||||
10
test/hooks/collections/Users/meHook.ts
Normal file
10
test/hooks/collections/Users/meHook.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { MeHook } from 'node_modules/payload/src/collections/config/types.js'
|
||||
|
||||
export const meHook: MeHook = ({ user }) => {
|
||||
if (user.email === 'dontrefresh@payloadcms.com') {
|
||||
return {
|
||||
exp: 10000,
|
||||
user,
|
||||
}
|
||||
}
|
||||
}
|
||||
12
test/hooks/collections/Users/refreshHook.ts
Normal file
12
test/hooks/collections/Users/refreshHook.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { RefreshHook } from 'node_modules/payload/src/collections/config/types.js'
|
||||
|
||||
export const refreshHook: RefreshHook = ({ user }) => {
|
||||
if (user.email === 'dontrefresh@payloadcms.com') {
|
||||
return {
|
||||
exp: 1,
|
||||
refreshedToken: 'fake',
|
||||
strategy: 'local-jwt',
|
||||
user,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -326,6 +326,32 @@ describe('Hooks', () => {
|
||||
})
|
||||
|
||||
describe('auth collection hooks', () => {
|
||||
let hookUser
|
||||
let hookUserToken
|
||||
|
||||
beforeAll(async () => {
|
||||
const email = 'dontrefresh@payloadcms.com'
|
||||
|
||||
hookUser = await payload.create({
|
||||
collection: hooksUsersSlug,
|
||||
data: {
|
||||
email,
|
||||
password: devUser.password,
|
||||
roles: ['admin'],
|
||||
},
|
||||
})
|
||||
|
||||
const { token } = await payload.login({
|
||||
collection: hooksUsersSlug,
|
||||
data: {
|
||||
email: hookUser.email,
|
||||
password: devUser.password,
|
||||
},
|
||||
})
|
||||
|
||||
hookUserToken = token
|
||||
})
|
||||
|
||||
it('should call afterLogin hook', async () => {
|
||||
const { user } = await payload.login({
|
||||
collection: hooksUsersSlug,
|
||||
@@ -353,6 +379,31 @@ describe('Hooks', () => {
|
||||
}),
|
||||
).rejects.toThrow(AuthenticationError)
|
||||
})
|
||||
|
||||
it('should respect refresh hooks', async () => {
|
||||
const response = await restClient.POST(`/${hooksUsersSlug}/refresh-token`, {
|
||||
headers: {
|
||||
Authorization: `JWT ${hookUserToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
expect(data.exp).toStrictEqual(1)
|
||||
expect(data.refreshedToken).toStrictEqual('fake')
|
||||
})
|
||||
|
||||
it('should respect me hooks', async () => {
|
||||
const response = await restClient.GET(`/${hooksUsersSlug}/me`, {
|
||||
headers: {
|
||||
Authorization: `JWT ${hookUserToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
expect(data.exp).toStrictEqual(10000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hook parameter data', () => {
|
||||
|
||||
Reference in New Issue
Block a user