feat: adds refresh hooks (#6965)

## Description

Adds collection `refresh` hooks to override the default `refresh`
operation behavior.
This commit is contained in:
James Mikrut
2024-06-27 17:22:01 -04:00
committed by GitHub
parent 0017c67f74
commit 07f3f273cd
12 changed files with 216 additions and 38 deletions

View File

@@ -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'
```

View File

@@ -32,6 +32,7 @@ export const refresh: CollectionRouteHandler = async ({ collection, req }) => {
token,
})
if (result.setCookie) {
const cookie = generatePayloadCookie({
collectionConfig: collection.config,
payload: req.payload,
@@ -43,6 +44,7 @@ export const refresh: CollectionRouteHandler = async ({ collection, req }) => {
}
headers.set('Set-Cookie', cookie)
}
return Response.json(
{

View File

@@ -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
}
}
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
result.token = currentToken
if (!collection.config.auth.removeTokenFromResponses) result.token = currentToken
}
}
}

View File

@@ -14,6 +14,7 @@ import { getFieldsToSign } from '../getFieldsToSign.js'
export type Result = {
exp: number
refreshedToken: string
setCookie?: boolean
strategy?: string
user: Document
}
@@ -74,6 +75,22 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise<Result>
req: args.req,
})
let result: Result
// /////////////////////////////////////
// refresh hook - Collection
// /////////////////////////////////////
for (const refreshHook of args.collection.config.hooks.refresh) {
const hookResult = await refreshHook({ args, user })
if (hookResult) {
result = hookResult
break
}
}
if (!result) {
const fieldsToSign = getFieldsToSign({
collectionConfig,
email: user?.email as string,
@@ -86,12 +103,14 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise<Result>
const exp = (jwt.decode(refreshedToken) as Record<string, unknown>).exp as number
let result: Result = {
result = {
exp,
refreshedToken,
setCookie: true,
strategy: args.req.user._strategy,
user,
}
}
// /////////////////////////////////////
// After Refresh - Collection
@@ -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())

View File

@@ -39,6 +39,8 @@ export const defaults = {
beforeOperation: [],
beforeRead: [],
beforeValidate: [],
me: [],
refresh: [],
},
timestamps: true,
upload: false,

View File

@@ -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

View File

@@ -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

View File

@@ -674,6 +674,8 @@ export type {
Collection,
CollectionConfig,
DataFromCollectionSlug,
MeHook as CollectionMeHook,
RefreshHook as CollectionRefreshHook,
RequiredDataFromCollection,
RequiredDataFromCollectionSlug,
SanitizedCollectionConfig,

View File

@@ -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],
},

View 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,
}
}
}

View 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,
}
}
}

View File

@@ -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', () => {