From c82d2caa29422083e97affc99a033296d78892d6 Mon Sep 17 00:00:00 2001 From: James Mikrut Date: Fri, 28 Jun 2024 09:06:27 -0400 Subject: [PATCH] feat: adds me and refresh hooks (#6968) ## Description Duplicate of https://github.com/payloadcms/payload/pull/6965 for 2.x --- docs/hooks/collections.mdx | 32 ++++++++ packages/payload/src/auth/operations/me.ts | 35 ++++++--- .../payload/src/auth/operations/refresh.ts | 75 ++++++++++++------- .../src/collections/config/defaults.ts | 2 + .../payload/src/collections/config/schema.ts | 2 + .../payload/src/collections/config/types.ts | 26 +++++++ packages/payload/src/exports/types.ts | 2 + test/hooks/collections/Users/index.ts | 4 + test/hooks/collections/Users/meHook.ts | 10 +++ test/hooks/collections/Users/refreshHook.ts | 12 +++ test/hooks/config.ts | 2 +- test/hooks/int.spec.ts | 52 +++++++++++++ 12 files changed, 215 insertions(+), 39 deletions(-) create mode 100644 test/hooks/collections/Users/meHook.ts create mode 100644 test/hooks/collections/Users/refreshHook.ts diff --git a/docs/hooks/collections.mdx b/docs/hooks/collections.mdx index f895a76efc..b8ac1ff837 100644 --- a/docs/hooks/collections.mdx +++ b/docs/hooks/collections.mdx @@ -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/types' + +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/types' + +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/types' ``` diff --git a/packages/payload/src/auth/operations/me.ts b/packages/payload/src/auth/operations/me.ts index ae45d745f0..90202c1663 100644 --- a/packages/payload/src/auth/operations/me.ts +++ b/packages/payload/src/auth/operations/me.ts @@ -20,7 +20,8 @@ export type Arguments = { req: PayloadRequest } -async function me({ collection, req }: Arguments): Promise { +async function me(args: Arguments): Promise { + const { collection, req } = args const extractJWT = getExtractJWT(req.payload.config) let response: Result = { user: null, @@ -47,18 +48,34 @@ async function me({ collection, req }: Arguments): Promise { delete user.collection - response = { - 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) { + response.user = hookResult.user + response.exp = hookResult.exp + + break + } } + response.collection = req.user.collection + response.strategy = req.user._strategy + const token = extractJWT(req) - if (token) { - const decoded = jwt.decode(token) as jwt.JwtPayload - if (decoded) response.exp = decoded.exp - if (!collection.config.auth.removeTokenFromResponses) response.token = token + if (!response.user) { + response.user = user + + if (token) { + const decoded = jwt.decode(token) as jwt.JwtPayload + if (decoded) response.exp = decoded.exp + if (!collection.config.auth.removeTokenFromResponses) response.token = token + } } } diff --git a/packages/payload/src/auth/operations/refresh.ts b/packages/payload/src/auth/operations/refresh.ts index e745c63cad..c8feb51a44 100644 --- a/packages/payload/src/auth/operations/refresh.ts +++ b/packages/payload/src/auth/operations/refresh.ts @@ -78,39 +78,56 @@ async function refresh(incomingArgs: Arguments): Promise { 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).exp as number + for (const refreshHook of args.collection.config.hooks.refresh) { + const hookResult = await refreshHook({ args, user }) - if (args.res) { - const cookieOptions = { - domain: undefined, - expires: getCookieExpiration(collectionConfig.auth.tokenExpiration), - httpOnly: true, - path: '/', - sameSite: collectionConfig.auth.cookies.sameSite, - secure: collectionConfig.auth.cookies.secure, + if (hookResult) { + result = hookResult + break } - - if (collectionConfig.auth.cookies.domain) - cookieOptions.domain = collectionConfig.auth.cookies.domain - - args.res.cookie(`${config.cookiePrefix}-token`, refreshedToken, cookieOptions) } - let result: Result = { - exp, - refreshedToken, - strategy: args.req.user._strategy, - user, + 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).exp as number + + if (args.res) { + const cookieOptions = { + domain: undefined, + expires: getCookieExpiration(collectionConfig.auth.tokenExpiration), + httpOnly: true, + path: '/', + sameSite: collectionConfig.auth.cookies.sameSite, + secure: collectionConfig.auth.cookies.secure, + } + + if (collectionConfig.auth.cookies.domain) + cookieOptions.domain = collectionConfig.auth.cookies.domain + + args.res.cookie(`${config.cookiePrefix}-token`, refreshedToken, cookieOptions) + } + + result = { + exp, + refreshedToken, + strategy: args.req.user._strategy, + user, + } } // ///////////////////////////////////// @@ -124,10 +141,10 @@ async function refresh(incomingArgs: Arguments): Promise { (await hook({ collection: args.collection?.config, context: args.req.context, - exp, + exp: result.exp, req: args.req, res: args.res, - token: refreshedToken, + token: result.refreshedToken, })) || result }, Promise.resolve()) diff --git a/packages/payload/src/collections/config/defaults.ts b/packages/payload/src/collections/config/defaults.ts index 166186041d..4867fa1534 100644 --- a/packages/payload/src/collections/config/defaults.ts +++ b/packages/payload/src/collections/config/defaults.ts @@ -38,6 +38,8 @@ export const defaults = { beforeOperation: [], beforeRead: [], beforeValidate: [], + me: [], + refresh: [], }, timestamps: true, upload: false, diff --git a/packages/payload/src/collections/config/schema.ts b/packages/payload/src/collections/config/schema.ts index a5fb5cc930..9e322ab21b 100644 --- a/packages/payload/src/collections/config/schema.ts +++ b/packages/payload/src/collections/config/schema.ts @@ -145,6 +145,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()), }), indexes: joi.array().items( joi.object().keys({ diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index 423340b668..f134c1c8f2 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -11,6 +11,11 @@ import type { CustomSaveDraftButtonProps, } from '../../admin/components/elements/types' import type { Props as ListProps } from '../../admin/components/views/collections/List/types' +import type { Arguments as MeArguments } from '../../auth/operations/me' +import type { + Arguments as RefreshArguments, + Result as RefreshResult, +} from '../../auth/operations/refresh' import type { Auth, IncomingAuthType, User } from '../../auth/types' import type { Access, @@ -190,6 +195,15 @@ export type AfterMeHook = (args: { response: unknown }) => any +export type RefreshHook = (args: { + args: RefreshArguments + user: T +}) => Promise | (RefreshResult | void) + +export type MeHook = (args: { + args: MeArguments + user: T +}) => ({ exp: number; user: T } | void) | Promise<{ exp: number; user: T } | void> export type AfterRefreshHook = (args: { /** The collection which this hook is being run on */ collection: SanitizedCollectionConfig @@ -411,6 +425,18 @@ export type CollectionConfig = { 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 diff --git a/packages/payload/src/exports/types.ts b/packages/payload/src/exports/types.ts index 53c5cdfee4..5dbab48ddd 100644 --- a/packages/payload/src/exports/types.ts +++ b/packages/payload/src/exports/types.ts @@ -42,6 +42,8 @@ export type { BeforeValidateHook as CollectionBeforeValidateHook, Collection, CollectionConfig, + MeHook as CollectionMeHook, + RefreshHook as CollectionRefreshHook, SanitizedCollectionConfig, TypeWithID, } from './../collections/config/types' diff --git a/test/hooks/collections/Users/index.ts b/test/hooks/collections/Users/index.ts index 6b9af85a96..f6887d9a1a 100644 --- a/test/hooks/collections/Users/index.ts +++ b/test/hooks/collections/Users/index.ts @@ -7,6 +7,8 @@ import type { Payload } from '../../../../packages/payload/src/payload' import { AuthenticationError } from '../../../../packages/payload/src/errors' import { devUser, regularUser } from '../../../credentials' import { afterLoginHook } from './afterLoginHook' +import { meHook } from './meHook' +import { refreshHook } from './refreshHook' const beforeLoginHook: BeforeLoginHook = ({ req, user }) => { const isAdmin = user.roles.includes('admin') ? user : undefined @@ -48,6 +50,8 @@ const Users: CollectionConfig = { }, ], hooks: { + me: [meHook], + refresh: [refreshHook], afterLogin: [afterLoginHook], beforeLogin: [beforeLoginHook], }, diff --git a/test/hooks/collections/Users/meHook.ts b/test/hooks/collections/Users/meHook.ts new file mode 100644 index 0000000000..e509f4c9b4 --- /dev/null +++ b/test/hooks/collections/Users/meHook.ts @@ -0,0 +1,10 @@ +import type { MeHook } from '../../../../packages/payload/src/collections/config/types' + +export const meHook: MeHook = ({ user }) => { + if (user.email === 'dontrefresh@payloadcms.com') { + return { + exp: 10000, + user, + } + } +} diff --git a/test/hooks/collections/Users/refreshHook.ts b/test/hooks/collections/Users/refreshHook.ts new file mode 100644 index 0000000000..6b380ef213 --- /dev/null +++ b/test/hooks/collections/Users/refreshHook.ts @@ -0,0 +1,12 @@ +import type { RefreshHook } from '../../../../packages/payload/src/collections/config/types' + +export const refreshHook: RefreshHook = ({ user }) => { + if (user.email === 'dontrefresh@payloadcms.com') { + return { + exp: 1, + refreshedToken: 'fake', + strategy: 'local-jwt', + user, + } + } +} diff --git a/test/hooks/config.ts b/test/hooks/config.ts index c135d68bc6..3a43f9b4f5 100644 --- a/test/hooks/config.ts +++ b/test/hooks/config.ts @@ -1,6 +1,6 @@ import type { SanitizedConfig } from '../../packages/payload/src/config/types' -import { APIError } from '../../packages/payload/errors' +import { APIError } from '../../packages/payload/src/errors' import { buildConfigWithDefaults } from '../buildConfigWithDefaults' import AfterOperation from './collections/AfterOperation' import ChainingHooks from './collections/ChainingHooks' diff --git a/test/hooks/int.spec.ts b/test/hooks/int.spec.ts index 4864c38f67..b3a1bed66e 100644 --- a/test/hooks/int.spec.ts +++ b/test/hooks/int.spec.ts @@ -327,6 +327,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, @@ -354,6 +380,32 @@ describe('Hooks', () => { }), ).rejects.toThrow(AuthenticationError) }) + + it('should respect refresh hooks', async () => { + const response = await fetch(`${apiUrl}/${hooksUsersSlug}/refresh-token`, { + method: 'POST', + 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 fetch(`${apiUrl}/${hooksUsersSlug}/me`, { + headers: { + Authorization: `JWT ${hookUserToken}`, + }, + }) + + const data = await response.json() + + expect(data.exp).toStrictEqual(10000) + }) }) describe('hook parameter data', () => {