diff --git a/docs/authentication/operations.mdx b/docs/authentication/operations.mdx index 0415f50549c..e59e253b9a7 100644 --- a/docs/authentication/operations.mdx +++ b/docs/authentication/operations.mdx @@ -180,19 +180,22 @@ As Payload sets HTTP-only cookies, logging out cannot be done by just removing a **Example REST API logout**: ```ts -const res = await fetch('http://localhost:3000/api/[collection-slug]/logout', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', +const res = await fetch( + 'http://localhost:3000/api/[collection-slug]/logout?allSessions=false', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, }, -}) +) ``` **Example GraphQL Mutation**: ``` mutation { - logout[collection-singular-label] + logoutUser(allSessions: false) } ``` @@ -203,6 +206,10 @@ mutation { docs](../local-api/server-functions#reusable-payload-server-functions). +#### Logging out with sessions enabled + +By default, logging out will only end the session pertaining to the JWT that was used to log out with. However, you can pass `allSessions: true` to the logout operation in order to end all sessions for the user logging out. + ## Refresh Allows for "refreshing" JWTs. If your user has a token that is about to expire, but the user is still active and using the app, you might want to use the `refresh` operation to receive a new token by executing this operation via the authenticated user. diff --git a/docs/authentication/overview.mdx b/docs/authentication/overview.mdx index 7dd30f7ee3e..7ff87de6482 100644 --- a/docs/authentication/overview.mdx +++ b/docs/authentication/overview.mdx @@ -91,6 +91,7 @@ The following options are available: | **`strategies`** | Advanced - an array of custom authentication strategies to extend this collection's authentication with. [More details](./custom-strategies). | | **`tokenExpiration`** | How long (in seconds) to keep the user logged in. JWTs and HTTP-only cookies will both expire at the same time. | | **`useAPIKey`** | Payload Authentication provides for API keys to be set on each user within an Authentication-enabled Collection. [More details](./api-keys). | +| **`useSessions`** | True by default. Set to `false` to use stateless JWTs for authentication instead of sessions. | | **`verify`** | Set to `true` or pass an object with verification options to require users to verify by email before they are allowed to log into your app. [More details](./email#email-verification). | ### Login With Username diff --git a/docs/local-api/server-functions.mdx b/docs/local-api/server-functions.mdx index 4966276b58e..c97d6b262f6 100644 --- a/docs/local-api/server-functions.mdx +++ b/docs/local-api/server-functions.mdx @@ -393,7 +393,7 @@ export default function LoginForm() { ### Logout -Logs out the current user by clearing the authentication cookie. +Logs out the current user by clearing the authentication cookie and current sessions. #### Importing the `logout` function @@ -401,7 +401,7 @@ Logs out the current user by clearing the authentication cookie. import { logout } from '@payloadcms/next/auth' ``` -Similar to the login function, you now need to pass your Payload config to this function and this cannot be done in a client component. Use a helper server function as shown below. +Similar to the login function, you now need to pass your Payload config to this function and this cannot be done in a client component. Use a helper server function as shown below. To ensure all sessions are cleared, set `allSessions: true` in the options, if you wish to logout but keep current sessions active, you can set this to `false` or leave it `undefined`. ```ts 'use server' @@ -411,7 +411,7 @@ import config from '@payload-config' export async function logoutAction() { try { - return await logout({ config }) + return await logout({ allSessions: true, config }) } catch (error) { throw new Error( `Logout failed: ${error instanceof Error ? error.message : 'Unknown error'}`, @@ -434,7 +434,7 @@ export default function LogoutButton() { ### Refresh -Refreshes the authentication token for the logged-in user. +Refreshes the authentication token and current session for the logged-in user. #### Importing the `refresh` function @@ -453,7 +453,6 @@ import config from '@payload-config' export async function refreshAction() { try { return await refresh({ - collection: 'users', // pass your collection slug config, }) } catch (error) { diff --git a/docs/plugins/sentry.mdx b/docs/plugins/sentry.mdx index afd2576c784..fc87f2e2dee 100644 --- a/docs/plugins/sentry.mdx +++ b/docs/plugins/sentry.mdx @@ -74,9 +74,7 @@ import * as Sentry from '@sentry/nextjs' const config = buildConfig({ collections: [Pages, Media], - plugins: [ - sentryPlugin({ Sentry }) - ], + plugins: [sentryPlugin({ Sentry })], }) export default config @@ -98,9 +96,7 @@ export default buildConfig({ pool: { connectionString: process.env.DATABASE_URL }, pg, // Inject the patched pg driver for Sentry instrumentation }), - plugins: [ - sentryPlugin({ Sentry }) - ], + plugins: [sentryPlugin({ Sentry })], }) ``` diff --git a/packages/graphql/src/resolvers/auth/logout.ts b/packages/graphql/src/resolvers/auth/logout.ts index 7277ea5a2f8..3d7de0820d5 100644 --- a/packages/graphql/src/resolvers/auth/logout.ts +++ b/packages/graphql/src/resolvers/auth/logout.ts @@ -7,6 +7,7 @@ import type { Context } from '../types.js' export function logout(collection: Collection): any { async function resolver(_, args, context: Context) { const options = { + allSessions: args.allSessions, collection, req: isolateObjectProperty(context.req, 'transactionID'), } diff --git a/packages/graphql/src/schema/initCollections.ts b/packages/graphql/src/schema/initCollections.ts index 7dda78057dd..6ae09a692d4 100644 --- a/packages/graphql/src/schema/initCollections.ts +++ b/packages/graphql/src/schema/initCollections.ts @@ -487,6 +487,9 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ graphqlResult.Mutation.fields[`logout${singularName}`] = { type: GraphQLString, + args: { + allSessions: { type: GraphQLBoolean }, + }, resolve: logout(collection), } diff --git a/packages/next/src/auth/logout.ts b/packages/next/src/auth/logout.ts index 25f86f9b355..192e293580b 100644 --- a/packages/next/src/auth/logout.ts +++ b/packages/next/src/auth/logout.ts @@ -1,24 +1,46 @@ 'use server' +import type { SanitizedConfig } from 'payload' + import { cookies as getCookies, headers as nextHeaders } from 'next/headers.js' -import { getPayload } from 'payload' +import { createLocalReq, getPayload, logoutOperation } from 'payload' import { getExistingAuthToken } from '../utilities/getExistingAuthToken.js' -export async function logout({ config }: { config: any }) { +export async function logout({ + allSessions = false, + config, +}: { + allSessions?: boolean + config: Promise | SanitizedConfig +}) { const payload = await getPayload({ config }) const headers = await nextHeaders() - const result = await payload.auth({ headers }) + const authResult = await payload.auth({ headers }) - if (!result.user) { + if (!authResult.user) { return { message: 'User already logged out', success: true } } - const existingCookie = await getExistingAuthToken(payload.config.cookiePrefix) + const { user } = authResult + const req = await createLocalReq({ user }, payload) + const collection = payload.collections[user.collection] + const logoutResult = await logoutOperation({ + allSessions, + collection, + req, + }) + + if (!logoutResult) { + return { message: 'Logout failed', success: false } + } + + const existingCookie = await getExistingAuthToken(payload.config.cookiePrefix) if (existingCookie) { const cookies = await getCookies() cookies.delete(existingCookie.name) - return { message: 'User logged out successfully', success: true } } + + return { message: 'User logged out successfully', success: true } } diff --git a/packages/next/src/auth/refresh.ts b/packages/next/src/auth/refresh.ts index 16d3d5f4661..9ece3e97c72 100644 --- a/packages/next/src/auth/refresh.ts +++ b/packages/next/src/auth/refresh.ts @@ -3,33 +3,45 @@ import type { CollectionSlug } from 'payload' import { headers as nextHeaders } from 'next/headers.js' -import { getPayload } from 'payload' +import { createLocalReq, getPayload, refreshOperation } from 'payload' import { getExistingAuthToken } from '../utilities/getExistingAuthToken.js' import { setPayloadAuthCookie } from '../utilities/setPayloadAuthCookie.js' -export async function refresh({ collection, config }: { collection: CollectionSlug; config: any }) { +export async function refresh({ config }: { config: any }) { const payload = await getPayload({ config }) - const authConfig = payload.collections[collection]?.config.auth + const headers = await nextHeaders() + const result = await payload.auth({ headers }) - if (!authConfig) { + if (!result.user) { + throw new Error('Cannot refresh token: user not authenticated') + } + + const collection: CollectionSlug | undefined = result.user.collection + const collectionConfig = payload.collections[collection] + + if (!collectionConfig?.config.auth) { throw new Error(`No auth config found for collection: ${collection}`) } - const { user } = await payload.auth({ headers: await nextHeaders() }) + const req = await createLocalReq({ user: result.user }, payload) - if (!user) { - throw new Error('User not authenticated') + const refreshResult = await refreshOperation({ + collection: collectionConfig, + req, + }) + + if (!refreshResult) { + return { message: 'Token refresh failed', success: false } } const existingCookie = await getExistingAuthToken(payload.config.cookiePrefix) - if (!existingCookie) { - return { message: 'No valid token found', success: false } + return { message: 'No valid token found to refresh', success: false } } await setPayloadAuthCookie({ - authConfig, + authConfig: collectionConfig.config.auth, cookiePrefix: payload.config.cookiePrefix, token: existingCookie.value, }) diff --git a/packages/payload/src/auth/baseFields/sessions.ts b/packages/payload/src/auth/baseFields/sessions.ts new file mode 100644 index 00000000000..46f1807e5a2 --- /dev/null +++ b/packages/payload/src/auth/baseFields/sessions.ts @@ -0,0 +1,32 @@ +import type { ArrayField } from '../../fields/config/types.js' + +export const sessionsFieldConfig: ArrayField = { + name: 'sessions', + type: 'array', + access: { + read: ({ doc, req: { user } }) => { + return user?.id === doc?.id + }, + update: () => false, + }, + admin: { + disabled: true, + }, + fields: [ + { + name: 'id', + type: 'text', + required: true, + }, + { + name: 'createdAt', + type: 'date', + defaultValue: () => new Date(), + }, + { + name: 'expiresAt', + type: 'date', + required: true, + }, + ], +} diff --git a/packages/payload/src/auth/endpoints/logout.ts b/packages/payload/src/auth/endpoints/logout.ts index f04a021e398..042356a02d6 100644 --- a/packages/payload/src/auth/endpoints/logout.ts +++ b/packages/payload/src/auth/endpoints/logout.ts @@ -9,8 +9,10 @@ import { logoutOperation } from '../operations/logout.js' export const logoutHandler: PayloadHandler = async (req) => { const collection = getRequestCollection(req) - const { t } = req + const { searchParams, t } = req + const result = await logoutOperation({ + allSessions: searchParams.get('allSessions') === 'true', collection, req, }) diff --git a/packages/payload/src/auth/getAuthFields.ts b/packages/payload/src/auth/getAuthFields.ts index 90e621dd42f..3b2db9f1476 100644 --- a/packages/payload/src/auth/getAuthFields.ts +++ b/packages/payload/src/auth/getAuthFields.ts @@ -5,6 +5,7 @@ import { accountLockFields } from './baseFields/accountLock.js' import { apiKeyFields } from './baseFields/apiKey.js' import { baseAuthFields } from './baseFields/auth.js' import { emailFieldConfig } from './baseFields/email.js' +import { sessionsFieldConfig } from './baseFields/sessions.js' import { usernameFieldConfig } from './baseFields/username.js' import { verificationFields } from './baseFields/verification.js' @@ -52,6 +53,10 @@ export const getBaseAuthFields = (authConfig: IncomingAuthType): Field[] => { if (authConfig?.maxLoginAttempts && authConfig.maxLoginAttempts > 0) { authFields.push(...accountLockFields) } + + if (authConfig.useSessions) { + authFields.push(sessionsFieldConfig) + } } return authFields diff --git a/packages/payload/src/auth/getFieldsToSign.ts b/packages/payload/src/auth/getFieldsToSign.ts index 053993e6dd8..dc8dd917800 100644 --- a/packages/payload/src/auth/getFieldsToSign.ts +++ b/packages/payload/src/auth/getFieldsToSign.ts @@ -114,9 +114,10 @@ const traverseFields = ({ export const getFieldsToSign = (args: { collectionConfig: CollectionConfig email: string + sid?: string user: PayloadRequest['user'] }): Record => { - const { collectionConfig, email, user } = args + const { collectionConfig, email, sid, user } = args const result: Record = { id: user?.id, @@ -124,6 +125,10 @@ export const getFieldsToSign = (args: { email, } + if (sid) { + result.sid = sid + } + traverseFields({ data: user!, fields: collectionConfig.fields, diff --git a/packages/payload/src/auth/operations/login.ts b/packages/payload/src/auth/operations/login.ts index 6317b0b0deb..a7aa4869c6a 100644 --- a/packages/payload/src/auth/operations/login.ts +++ b/packages/payload/src/auth/operations/login.ts @@ -1,3 +1,5 @@ +import { v4 as uuid } from 'uuid' + import type { AuthOperationsFromCollectionSlug, Collection, @@ -23,6 +25,7 @@ import { getFieldsToSign } from '../getFieldsToSign.js' import { getLoginOptions } from '../getLoginOptions.js' import { isUserLocked } from '../isUserLocked.js' import { jwtSign } from '../jwt.js' +import { removeExpiredSessions } from '../removeExpiredSessions.js' import { authenticateLocalStrategy } from '../strategies/local/authenticate.js' import { incrementLoginAttempts } from '../strategies/local/incrementLoginAttempts.js' import { resetLoginAttempts } from '../strategies/local/resetLoginAttempts.js' @@ -114,7 +117,6 @@ export const loginOperation = async ( // Login // ///////////////////////////////////// - let user const { email: unsanitizedEmail, password } = data const loginWithUsername = collectionConfig.auth.loginWithUsername @@ -204,7 +206,7 @@ export const loginOperation = async ( whereConstraint = usernameConstraint } - user = await payload.db.findOne({ + let user = await payload.db.findOne({ collection: collectionConfig.slug, req, where: whereConstraint, @@ -239,6 +241,41 @@ export const loginOperation = async ( throw new AuthenticationError(req.t) } + const fieldsToSignArgs: Parameters[0] = { + collectionConfig, + email: sanitizedEmail!, + user, + } + + if (collectionConfig.auth.useSessions) { + // Add session to user + const newSessionID = uuid() + const now = new Date() + const tokenExpInMs = collectionConfig.auth.tokenExpiration * 1000 + const expiresAt = new Date(now.getTime() + tokenExpInMs) + + const session = { id: newSessionID, createdAt: now, expiresAt } + + if (!user.sessions?.length) { + user.sessions = [session] + } else { + user.sessions = removeExpiredSessions(user.sessions) + user.sessions.push(session) + } + + await payload.db.updateOne({ + id: user.id, + collection: collectionConfig.slug, + data: user, + req, + returning: false, + }) + + fieldsToSignArgs.sid = newSessionID + } + + const fieldsToSign = getFieldsToSign(fieldsToSignArgs) + if (maxLoginAttemptsEnabled) { await resetLoginAttempts({ collection: collectionConfig, @@ -248,12 +285,6 @@ export const loginOperation = async ( }) } - const fieldsToSign = getFieldsToSign({ - collectionConfig, - email: sanitizedEmail!, - user, - }) - // ///////////////////////////////////// // beforeLogin - Collection // ///////////////////////////////////// diff --git a/packages/payload/src/auth/operations/logout.ts b/packages/payload/src/auth/operations/logout.ts index 57d5679b5b7..1977a7ffa4e 100644 --- a/packages/payload/src/auth/operations/logout.ts +++ b/packages/payload/src/auth/operations/logout.ts @@ -6,6 +6,7 @@ import type { PayloadRequest } from '../../types/index.js' import { APIError } from '../../errors/index.js' export type Arguments = { + allSessions?: boolean collection: Collection req: PayloadRequest } @@ -13,6 +14,7 @@ export type Arguments = { export const logoutOperation = async (incomingArgs: Arguments): Promise => { let args = incomingArgs const { + allSessions, collection: { config: collectionConfig }, req: { user }, req, @@ -36,5 +38,41 @@ export const logoutOperation = async (incomingArgs: Arguments): Promise } } + if (collectionConfig.auth.disableLocalStrategy !== true && collectionConfig.auth.useSessions) { + const userWithSessions = await req.payload.db.findOne<{ + id: number | string + sessions: { id: string }[] + }>({ + collection: collectionConfig.slug, + req, + where: { + id: { + equals: user.id, + }, + }, + }) + + if (!userWithSessions) { + throw new APIError('No User', httpStatus.BAD_REQUEST) + } + + if (allSessions) { + userWithSessions.sessions = [] + } else { + const sessionsAfterLogout = (userWithSessions?.sessions || []).filter( + (s) => s.id !== req?.user?._sid, + ) + + userWithSessions.sessions = sessionsAfterLogout + } + + await req.payload.db.updateOne({ + id: user.id, + collection: collectionConfig.slug, + data: userWithSessions, + returning: false, + }) + } + return true } diff --git a/packages/payload/src/auth/operations/refresh.ts b/packages/payload/src/auth/operations/refresh.ts index e0e610245a7..969fa57e15a 100644 --- a/packages/payload/src/auth/operations/refresh.ts +++ b/packages/payload/src/auth/operations/refresh.ts @@ -1,4 +1,5 @@ import url from 'url' +import { v4 as uuid } from 'uuid' import type { Collection } from '../../collections/config/types.js' import type { Document, PayloadRequest } from '../../types/index.js' @@ -10,6 +11,7 @@ import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' import { getFieldsToSign } from '../getFieldsToSign.js' import { jwtSign } from '../jwt.js' +import { removeExpiredSessions } from '../removeExpiredSessions.js' export type Result = { exp: number @@ -79,6 +81,31 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise req: args.req, }) + const sid = args.req.user._sid + + if (collectionConfig.auth.useSessions && !collectionConfig.auth.disableLocalStrategy) { + if (!Array.isArray(user.sessions) || !sid) { + throw new Forbidden(args.req.t) + } + + const existingSession = user.sessions.find(({ id }) => id === sid) + + const now = new Date() + const tokenExpInMs = collectionConfig.auth.tokenExpiration * 1000 + existingSession.expiresAt = new Date(now.getTime() + tokenExpInMs) + + await req.payload.db.updateOne({ + id: user.id, + collection: collectionConfig.slug, + data: { + ...user, + sessions: removeExpiredSessions(user.sessions), + }, + req, + returning: false, + }) + } + if (user) { user.collection = args.req.user.collection user._strategy = args.req.user._strategy @@ -103,6 +130,7 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise const fieldsToSign = getFieldsToSign({ collectionConfig, email: user?.email as string, + sid, user: args?.req?.user, }) diff --git a/packages/payload/src/auth/removeExpiredSessions.ts b/packages/payload/src/auth/removeExpiredSessions.ts new file mode 100644 index 00000000000..c0dd7476daa --- /dev/null +++ b/packages/payload/src/auth/removeExpiredSessions.ts @@ -0,0 +1,10 @@ +import type { UserSession } from './types.js' + +export const removeExpiredSessions = (sessions: UserSession[]) => { + const now = new Date() + + return sessions.filter(({ expiresAt }) => { + const expiry = expiresAt instanceof Date ? expiresAt : new Date(expiresAt) + return expiry > now + }) +} diff --git a/packages/payload/src/auth/strategies/jwt.ts b/packages/payload/src/auth/strategies/jwt.ts index 1817ba6dd13..797f5c25bc1 100644 --- a/packages/payload/src/auth/strategies/jwt.ts +++ b/packages/payload/src/auth/strategies/jwt.ts @@ -8,6 +8,7 @@ import { extractJWT } from '../extractJWT.js' type JWTToken = { collection: string id: string + sid?: string } async function autoLogin({ @@ -100,6 +101,18 @@ export const JWTAuthentication: AuthStrategyFunction = async ({ })) as AuthStrategyResult['user'] if (user && (!collection!.config.auth.verify || user._verified)) { + if (collection!.config.auth.useSessions) { + const existingSession = (user.sessions || []).find(({ id }) => id === decodedPayload.sid) + + if (!existingSession || !decodedPayload.sid) { + return { + user: null, + } + } + + user._sid = decodedPayload.sid + } + user.collection = collection!.config.slug user._strategy = strategyName return { diff --git a/packages/payload/src/auth/types.ts b/packages/payload/src/auth/types.ts index 54b1da6c004..0a2a4a5f078 100644 --- a/packages/payload/src/auth/types.ts +++ b/packages/payload/src/auth/types.ts @@ -118,6 +118,7 @@ type BaseUser = { collection: string email?: string id: number | string + sessions?: Array username?: string } @@ -133,6 +134,7 @@ export type ClientUser = { [key: string]: any } & BaseUser +export type UserSession = { createdAt: Date | string; expiresAt: Date | string; id: string } type GenerateVerifyEmailHTML = (args: { req: PayloadRequest token: string @@ -277,6 +279,13 @@ export interface IncomingAuthType { * @link https://payloadcms.com/docs/authentication/api-keys */ useAPIKey?: boolean + + /** + * Use sessions for authentication. Enabled by default. + * @default true + */ + useSessions?: boolean + /** * Set to true or pass an object with verification options to require users to verify by email before they are allowed to log into your app. * @link https://payloadcms.com/docs/authentication/email#email-verification diff --git a/packages/payload/src/collections/config/defaults.ts b/packages/payload/src/collections/config/defaults.ts index 605705fb587..9e329073234 100644 --- a/packages/payload/src/collections/config/defaults.ts +++ b/packages/payload/src/collections/config/defaults.ts @@ -127,6 +127,7 @@ export const authDefaults: IncomingAuthType = { loginWithUsername: false, maxLoginAttempts: 5, tokenExpiration: 7200, + useSessions: true, verify: false, } @@ -142,6 +143,7 @@ export const addDefaultsToAuthConfig = (auth: IncomingAuthType): IncomingAuthTyp auth.loginWithUsername = auth.loginWithUsername ?? false auth.maxLoginAttempts = auth.maxLoginAttempts ?? 5 auth.tokenExpiration = auth.tokenExpiration ?? 7200 + auth.useSessions = auth.useSessions ?? true auth.verify = auth.verify ?? false auth.strategies = auth.strategies ?? [] diff --git a/packages/payload/src/fields/hooks/afterRead/promise.ts b/packages/payload/src/fields/hooks/afterRead/promise.ts index c30a6bb8d0d..30603c3807e 100644 --- a/packages/payload/src/fields/hooks/afterRead/promise.ts +++ b/packages/payload/src/fields/hooks/afterRead/promise.ts @@ -465,7 +465,7 @@ export const promise = async ({ }) } }) - } else { + } else if (field.hidden !== true || showHiddenFields === true) { siblingDoc[field.name] = [] } break @@ -570,7 +570,7 @@ export const promise = async ({ }) } }) - } else { + } else if (field.hidden !== true || showHiddenFields === true) { siblingDoc[field.name] = [] } diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index fc2e7f763fa..bb2046422b7 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -138,6 +138,18 @@ import { getLogger } from './utilities/logger.js' import { serverInit as serverInitTelemetry } from './utilities/telemetry/events/serverInit.js' import { traverseFields } from './utilities/traverseFields.js' +/** + * Export of all base fields that could potentially be + * useful as users wish to extend built-in fields with custom logic + */ +export { accountLockFields as baseAccountLockFields } from './auth/baseFields/accountLock.js' +export { apiKeyFields as baseAPIKeyFields } from './auth/baseFields/apiKey.js' +export { baseAuthFields } from './auth/baseFields/auth.js' +export { emailFieldConfig as baseEmailField } from './auth/baseFields/email.js' +export { sessionsFieldConfig as baseSessionsField } from './auth/baseFields/sessions.js' +export { usernameFieldConfig as baseUsernameField } from './auth/baseFields/username.js' + +export { verificationFields as baseVerificationFields } from './auth/baseFields/verification.js' export { executeAccess } from './auth/executeAccess.js' export { executeAuthStrategies } from './auth/executeAuthStrategies.js' export { extractAccessFromPermission } from './auth/extractAccessFromPermission.js' diff --git a/templates/website/package.json b/templates/website/package.json index db42a675ddb..ce2b3631ce2 100644 --- a/templates/website/package.json +++ b/templates/website/package.json @@ -58,27 +58,27 @@ }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", + "@playwright/test": "1.50.0", "@tailwindcss/typography": "^0.5.13", + "@testing-library/react": "16.3.0", "@types/escape-html": "^1.0.2", "@types/node": "22.5.4", "@types/react": "19.1.0", "@types/react-dom": "19.1.2", + "@vitejs/plugin-react": "4.5.2", "autoprefixer": "^10.4.19", "copyfiles": "^2.4.1", "eslint": "^9.16.0", "eslint-config-next": "15.3.0", - "postcss": "^8.4.38", - "prettier": "^3.4.2", - "tailwindcss": "^3.4.3", - "@playwright/test": "1.50.0", "jsdom": "26.1.0", - "@testing-library/react": "16.3.0", - "@vitejs/plugin-react": "4.5.2", "playwright": "1.50.0", "playwright-core": "1.50.0", + "postcss": "^8.4.38", + "prettier": "^3.4.2", + "tailwindcss": "^3.4.3", + "typescript": "5.7.3", "vite-tsconfig-paths": "5.1.4", - "vitest": "3.2.3", - "typescript": "5.7.3" + "vitest": "3.2.3" }, "engines": { "node": "^18.20.2 || >=20.9.0", diff --git a/templates/website/src/app/(payload)/admin/importMap.js b/templates/website/src/app/(payload)/admin/importMap.js index 0031d9e0137..bd78e0fbd3d 100644 --- a/templates/website/src/app/(payload)/admin/importMap.js +++ b/templates/website/src/app/(payload)/admin/importMap.js @@ -25,29 +25,47 @@ import { default as default_1a7510af427896d367a49dbf838d2de6 } from '@/component import { default as default_8a7ab0eb7ab5c511aba12e68480bfe5e } from '@/components/BeforeLogin' export const importMap = { - "@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e, - "@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e, - "@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e, - "@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient": FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/plugin-seo/client#OverviewComponent": OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860, - "@payloadcms/plugin-seo/client#MetaTitleComponent": MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860, - "@payloadcms/plugin-seo/client#MetaImageComponent": MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860, - "@payloadcms/plugin-seo/client#MetaDescriptionComponent": MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860, - "@payloadcms/plugin-seo/client#PreviewComponent": PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860, - "@/fields/slug/SlugComponent#SlugComponent": SlugComponent_92cc057d0a2abb4f6cf0307edf59f986, - "@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#BlocksFeatureClient": BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/plugin-search/client#LinkToDoc": LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634, - "@payloadcms/plugin-search/client#ReindexButton": ReindexButton_aead06e4cbf6b2620c5c51c9ab283634, - "@/Header/RowLabel#RowLabel": RowLabel_ec255a65fa6fa8d1faeb09cf35284224, - "@/Footer/RowLabel#RowLabel": RowLabel_1f6ff6ff633e3695d348f4f3c58f1466, - "@/components/BeforeDashboard#default": default_1a7510af427896d367a49dbf838d2de6, - "@/components/BeforeLogin#default": default_8a7ab0eb7ab5c511aba12e68480bfe5e + '@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell': + RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e, + '@payloadcms/richtext-lexical/rsc#RscEntryLexicalField': + RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e, + '@payloadcms/richtext-lexical/rsc#LexicalDiffComponent': + LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e, + '@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient': + InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient': + FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#HeadingFeatureClient': + HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#ParagraphFeatureClient': + ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#UnderlineFeatureClient': + UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#BoldFeatureClient': + BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#ItalicFeatureClient': + ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#LinkFeatureClient': + LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/plugin-seo/client#OverviewComponent': + OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860, + '@payloadcms/plugin-seo/client#MetaTitleComponent': + MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860, + '@payloadcms/plugin-seo/client#MetaImageComponent': + MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860, + '@payloadcms/plugin-seo/client#MetaDescriptionComponent': + MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860, + '@payloadcms/plugin-seo/client#PreviewComponent': + PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860, + '@/fields/slug/SlugComponent#SlugComponent': SlugComponent_92cc057d0a2abb4f6cf0307edf59f986, + '@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient': + HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#BlocksFeatureClient': + BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/plugin-search/client#LinkToDoc': LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634, + '@payloadcms/plugin-search/client#ReindexButton': ReindexButton_aead06e4cbf6b2620c5c51c9ab283634, + '@/Header/RowLabel#RowLabel': RowLabel_ec255a65fa6fa8d1faeb09cf35284224, + '@/Footer/RowLabel#RowLabel': RowLabel_1f6ff6ff633e3695d348f4f3c58f1466, + '@/components/BeforeDashboard#default': default_1a7510af427896d367a49dbf838d2de6, + '@/components/BeforeLogin#default': default_8a7ab0eb7ab5c511aba12e68480bfe5e, } diff --git a/test/access-control/config.ts b/test/access-control/config.ts index 5040400a4b3..f82fce76bda 100644 --- a/test/access-control/config.ts +++ b/test/access-control/config.ts @@ -1,4 +1,4 @@ -/* eslint-disable no-restricted-exports */ + import { fileURLToPath } from 'node:url' import path from 'path' const filename = fileURLToPath(import.meta.url) diff --git a/test/access-control/payload-types.ts b/test/access-control/payload-types.ts index 0e87d735b34..da8c3cb5a89 100644 --- a/test/access-control/payload-types.ts +++ b/test/access-control/payload-types.ts @@ -95,6 +95,7 @@ export interface Config { 'auth-collection': AuthCollection; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; + 'payload-sessions': PayloadSession; 'payload-migrations': PayloadMigration; }; collectionsJoins: {}; @@ -125,6 +126,7 @@ export interface Config { 'auth-collection': AuthCollectionSelect | AuthCollectionSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; + 'payload-sessions': PayloadSessionsSelect | PayloadSessionsSelect; 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; }; db: { @@ -891,6 +893,26 @@ export interface PayloadPreference { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-sessions". + */ +export interface PayloadSession { + id: string; + session: string; + expiration: string; + user: + | { + relationTo: 'users'; + value: string | User; + } + | { + relationTo: 'public-users'; + value: string | PublicUser; + }; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-migrations". @@ -1295,6 +1317,17 @@ export interface PayloadPreferencesSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-sessions_select". + */ +export interface PayloadSessionsSelect { + session?: T; + expiration?: T; + user?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-migrations_select". diff --git a/test/admin-root/app/(payload)/importMap.js b/test/admin-root/app/(payload)/importMap.js index 7e732f32064..c0cfa3580dd 100644 --- a/test/admin-root/app/(payload)/importMap.js +++ b/test/admin-root/app/(payload)/importMap.js @@ -1,5 +1,5 @@ import { CustomView as CustomView_c4f0e2747eca2be436a06a63cea31567 } from '../../CustomView/index.js' export const importMap = { - "/CustomView/index.js#CustomView": CustomView_c4f0e2747eca2be436a06a63cea31567 + '/CustomView/index.js#CustomView': CustomView_c4f0e2747eca2be436a06a63cea31567, } diff --git a/test/auth/int.spec.ts b/test/auth/int.spec.ts index ea89fc74e9b..ecfa5f1ad98 100644 --- a/test/auth/int.spec.ts +++ b/test/auth/int.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable jest/no-conditional-in-test */ import type { BasePayload, EmailFieldValidation, @@ -737,7 +738,7 @@ describe('Auth', () => { it('should retain fields when auth.disableLocalStrategy.enableFields is true', () => { const authFields = payload.collections[partialDisableLocalStrategiesSlug].config.fields - // eslint-disable-next-line jest/no-conditional-in-test + .filter((field) => 'name' in field && field.name) .map((field) => (field as FieldAffectingData).name) @@ -751,6 +752,7 @@ describe('Auth', () => { 'hash', 'loginAttempts', 'lockUntil', + 'sessions', ]) }) @@ -1051,4 +1053,215 @@ describe('Auth', () => { expect(emailValidation('user,name@example.com', mockContext)).toBe('validation:emailAddress') }) }) + + describe('Sessions', () => { + it('should set a session on a user', async () => { + const authenticated = await payload.login({ + collection: slug, + data: { + email: devUser.email, + password: devUser.password, + }, + }) + + expect(authenticated.token).toBeTruthy() + + const user = await payload.db.find({ + collection: slug, + where: { + id: { + equals: authenticated.user.id, + }, + }, + }) + + expect(Array.isArray(user.docs[0]?.sessions)).toBeTruthy() + + const decoded = jwtDecode<{ sid: string }>(String(authenticated.token)) + + expect(decoded.sid).toBeDefined() + + const matchedSession = user.docs[0]?.sessions?.find(({ id }) => id === decoded.sid) + + expect(matchedSession).toBeDefined() + expect(matchedSession?.createdAt).toBeDefined() + expect(matchedSession?.expiresAt).toBeDefined() + }) + + it('should log out a user and delete only the session being logged out', async () => { + const authenticated = await payload.login({ + collection: slug, + data: { + email: devUser.email, + password: devUser.password, + }, + }) + + const authenticated2 = await payload.login({ + collection: slug, + data: { + email: devUser.email, + password: devUser.password, + }, + }) + + await restClient.POST(`/${slug}/logout`, { + headers: { + Authorization: `JWT ${authenticated.token}`, + }, + }) + + const user = await payload.db.find({ + collection: slug, + where: { + email: { + equals: devUser.email, + }, + }, + }) + + const decoded = jwtDecode<{ sid: string }>(String(authenticated.token)) + expect(decoded.sid).toBeDefined() + + const remainingSessions = user.docs[0]?.sessions ?? [] + + const loggedOutSession = remainingSessions.find(({ id }) => id === decoded.sid) + expect(loggedOutSession).toBeUndefined() + + const decoded2 = jwtDecode<{ sid: string }>(String(authenticated2.token)) + expect(decoded2.sid).toBeDefined() + + const existingSession = remainingSessions.find(({ id }) => id === decoded2.sid) + expect(existingSession?.id).toStrictEqual(decoded2.sid) + }) + + it('should refresh an existing session', async () => { + const authenticated = await payload.login({ + collection: slug, + data: { + email: devUser.email, + password: devUser.password, + }, + }) + + const decoded = jwtDecode<{ sid: string }>(String(authenticated.token)) + + const user = await payload.db.find({ + collection: slug, + where: { + email: { + equals: devUser.email, + }, + }, + }) + + const matchedSession = user.docs[0]?.sessions?.find(({ id }) => id === decoded.sid) + + const refreshed = await restClient + .POST(`/${slug}/refresh-token`, { + headers: { + Authorization: `JWT ${authenticated.token}`, + }, + }) + .then((res) => res.json()) + + const refreshedUser = await payload.db.find({ + collection: slug, + where: { + email: { + equals: devUser.email, + }, + }, + }) + + const decodedRefreshed = jwtDecode<{ sid: string }>(String(refreshed.refreshedToken)) + + const matchedRefreshedSession = refreshedUser.docs[0]?.sessions?.find( + ({ id }) => id === decodedRefreshed.sid, + ) + + expect(decodedRefreshed.sid).toStrictEqual(decoded.sid) + + expect(new Date(matchedSession?.expiresAt as unknown as string).getTime()).toBeLessThan( + new Date(matchedRefreshedSession?.expiresAt as unknown as string).getTime(), + ) + }) + + it('should not authenticate a user who has a JWT but its session has been terminated', async () => { + const authenticated = await payload.login({ + collection: slug, + data: { + email: devUser.email, + password: devUser.password, + }, + }) + + await restClient.POST(`/${slug}/logout?allSessions=true`, { + headers: { + Authorization: `JWT ${authenticated.token}`, + }, + }) + + const user = await payload.db.find({ + collection: slug, + where: { + email: { + equals: devUser.email, + }, + }, + }) + + const remainingSessions = user.docs[0]?.sessions + expect(remainingSessions).toHaveLength(0) + + const meQuery = await restClient + .GET(`/${slug}/me`, { + headers: { + Authorization: `JWT ${authenticated.token}`, + }, + }) + .then((res) => res.json()) + + expect(meQuery.user).toBeNull() + }) + + it('should clean up expired sessions when logging in', async () => { + const userWithExpiredSession = await payload.create({ + collection: slug, + data: { + email: `${devUser.email}.au`, + password: devUser.password, + roles: ['admin'], + sessions: [ + { + id: uuid(), + createdAt: new Date().toDateString(), + expiresAt: new Date(new Date().getTime() - 5000).toDateString(), // Set an expired session + }, + ], + }, + }) + + expect(userWithExpiredSession.sessions).toHaveLength(1) + + await payload.login({ + collection: slug, + data: { + email: devUser.email, + password: devUser.password, + }, + }) + + const user2 = await payload.db.find({ + collection: slug, + where: { + email: { + equals: devUser.email, + }, + }, + }) + + expect(user2.docs[0]?.sessions).toHaveLength(1) + }) + }) }) diff --git a/test/auth/payload-types.ts b/test/auth/payload-types.ts index 9ce056d1dd3..176fae2020e 100644 --- a/test/auth/payload-types.ts +++ b/test/auth/payload-types.ts @@ -248,6 +248,11 @@ export interface User { hash?: string | null; loginAttempts?: number | null; lockUntil?: string | null; + sessions: { + id: string; + createdAt?: string | null; + expiresAt: string; + }[]; password?: string | null; } /** @@ -265,6 +270,11 @@ export interface PartialDisableLocalStrategy { hash?: string | null; loginAttempts?: number | null; lockUntil?: string | null; + sessions: { + id: string; + createdAt?: string | null; + expiresAt: string; + }[]; password?: string | null; } /** @@ -306,6 +316,11 @@ export interface PublicUser { _verificationToken?: string | null; loginAttempts?: number | null; lockUntil?: string | null; + sessions: { + id: string; + createdAt?: string | null; + expiresAt: string; + }[]; password?: string | null; } /** @@ -471,6 +486,13 @@ export interface UsersSelect { hash?: T; loginAttempts?: T; lockUntil?: T; + sessions?: + | T + | { + id?: T; + createdAt?: T; + expiresAt?: T; + }; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -486,6 +508,13 @@ export interface PartialDisableLocalStrategiesSelect { hash?: T; loginAttempts?: T; lockUntil?: T; + sessions?: + | T + | { + id?: T; + createdAt?: T; + expiresAt?: T; + }; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -523,6 +552,13 @@ export interface PublicUsersSelect { _verificationToken?: T; loginAttempts?: T; lockUntil?: T; + sessions?: + | T + | { + id?: T; + createdAt?: T; + expiresAt?: T; + }; } /** * This interface was referenced by `Config`'s JSON-Schema diff --git a/test/package.json b/test/package.json index 07fa27d38a4..4ddc19f2be9 100644 --- a/test/package.json +++ b/test/package.json @@ -96,5 +96,8 @@ "ts-essentials": "10.0.3", "typescript": "5.7.3", "uuid": "10.0.0" + }, + "pnpm": { + "neverBuiltDependencies": [] } } diff --git a/test/server-functions/components/loginFunction.tsx b/test/server-functions/components/loginFunction.tsx index 6c439c1c9e9..bfbf244e9c2 100644 --- a/test/server-functions/components/loginFunction.tsx +++ b/test/server-functions/components/loginFunction.tsx @@ -16,7 +16,6 @@ export async function loginFunction({ email, password }: LoginArgs) { email, password, }) - return result } catch (error) { throw new Error(`Login failed: ${error instanceof Error ? error.message : 'Unknown error'}`) } diff --git a/test/server-functions/components/refreshFunction.tsx b/test/server-functions/components/refreshFunction.tsx index ed830bc5c1f..60155638fa8 100644 --- a/test/server-functions/components/refreshFunction.tsx +++ b/test/server-functions/components/refreshFunction.tsx @@ -7,7 +7,6 @@ import config from '../config.js' export async function refreshFunction() { try { return await refresh({ - collection: 'users', // update this to your collection slug config, }) } catch (error) {