From 0cf4700b235ea1be964bd6466e52a04d9363b8ba Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Thu, 15 May 2025 13:27:00 -0400 Subject: [PATCH 01/14] feat: auth sessions --- .../payload/src/auth/baseFields/sessions.ts | 25 ++++ packages/payload/src/auth/getAuthFields.ts | 5 + packages/payload/src/auth/getFieldsToSign.ts | 7 +- packages/payload/src/auth/operations/login.ts | 50 +++++++- .../payload/src/auth/operations/logout.ts | 31 +++++ packages/payload/src/auth/operations/me.ts | 8 ++ .../payload/src/auth/operations/refresh.ts | 30 +++++ packages/payload/src/auth/types.ts | 7 ++ .../src/collections/config/defaults.ts | 2 + pnpm-lock.yaml | 39 ++++--- test/access-control/payload-types.ts | 33 ++++++ test/auth/payload-types.ts | 36 ++++++ tsconfig.base.json | 107 ++++++++++++++---- 13 files changed, 333 insertions(+), 47 deletions(-) create mode 100644 packages/payload/src/auth/baseFields/sessions.ts diff --git a/packages/payload/src/auth/baseFields/sessions.ts b/packages/payload/src/auth/baseFields/sessions.ts new file mode 100644 index 00000000000..33b579e1e1d --- /dev/null +++ b/packages/payload/src/auth/baseFields/sessions.ts @@ -0,0 +1,25 @@ +import type { ArrayField } from '../../fields/config/types.js' + +export const sessionsFieldConfig: ArrayField = { + name: 'sessions', + type: 'array', + defaultValue: [], + fields: [ + { + name: 'id', + type: 'text', + required: true, + }, + { + name: 'createdAt', + type: 'date', + defaultValue: () => new Date(), + }, + { + name: 'expiresAt', + type: 'date', + required: true, + }, + ], + required: true, +} 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 c66bf40ca3f..1c427c71d1b 100644 --- a/packages/payload/src/auth/getFieldsToSign.ts +++ b/packages/payload/src/auth/getFieldsToSign.ts @@ -115,9 +115,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, @@ -125,6 +126,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 5bd02efd2fa..84c3756fe41 100644 --- a/packages/payload/src/auth/operations/login.ts +++ b/packages/payload/src/auth/operations/login.ts @@ -1,4 +1,7 @@ // @ts-strict-ignore + +import { v4 as uuid } from 'uuid' + import type { AuthOperationsFromCollectionSlug, Collection, @@ -115,7 +118,9 @@ export const loginOperation = async ( // Login // ///////////////////////////////////// - let user + let user: + | ({ sessions?: Array<{ createdAt: Date; expiresAt: Date; id: string }> } & User) + | null = null const { email: unsanitizedEmail, password } = data const loginWithUsername = collectionConfig.auth.loginWithUsername @@ -249,9 +254,37 @@ export const loginOperation = async ( }) } + // Add session to user + const newSessionID = uuid() + const now = new Date() + const tokenExpInMs = collectionConfig.auth.tokenExpiration * 1000 + const expiresAt = new Date(now.getTime() + tokenExpInMs) + + req.payload.logger.info({ + createdAt: now, + expiresAt, + msg: 'DEBUG: loginOperation - Adding session to user', + newSessionID, + tokenExpiration: collectionConfig.auth.tokenExpiration, + }) + + const session = { id: newSessionID, createdAt: now, expiresAt } + if (!user.sessions?.length) { + user.sessions = [session] + } else { + user.sessions.push(session) + } + const fieldsToSign = getFieldsToSign({ collectionConfig, email: sanitizedEmail, + sid: newSessionID, + user, + }) + + console.log({ + msg: `User ${user.id} logged in`, + newSessionID, user, }) @@ -347,6 +380,21 @@ export const loginOperation = async ( result, }) + // TODO: only do this if sessions are enabled + if (user.sessions?.length) { + req.payload.logger.info({ + msg: 'DEBUG: loginOperation - Updating user sessions', + user, + }) + await payload.db.updateOne({ + id: user.id, + collection: collectionConfig.slug, + data: user, + req, + returning: false, + }) + } + // ///////////////////////////////////// // Return results // ///////////////////////////////////// diff --git a/packages/payload/src/auth/operations/logout.ts b/packages/payload/src/auth/operations/logout.ts index 57d5679b5b7..e63759c4f99 100644 --- a/packages/payload/src/auth/operations/logout.ts +++ b/packages/payload/src/auth/operations/logout.ts @@ -1,9 +1,11 @@ import { status as httpStatus } from 'http-status' +import { decodeJwt } from 'jose' import type { Collection } from '../../collections/config/types.js' import type { PayloadRequest } from '../../types/index.js' import { APIError } from '../../errors/index.js' +import { extractJWT } from '../extractJWT.js' export type Arguments = { collection: Collection @@ -36,5 +38,34 @@ export const logoutOperation = async (incomingArgs: Arguments): Promise } } + const token = extractJWT(req) + const decodedToken = token ? decodeJwt(token) : undefined + if (!decodedToken) { + throw new APIError('Invalid token', httpStatus.INTERNAL_SERVER_ERROR) + } + + const sessionsAfterLogout = ((user.sessions || []) as Array<{ id: string }>).filter( + (s) => s.id !== decodedToken.sid, + ) + req.payload.logger.info({ + allSessions: user.sessions, + msg: 'Logging out and updating user', + sessionIDFromToken: decodedToken.sid, + sessionsAfterLogout, + }) + + const updatedUser = await req.payload.update({ + id: user.id, + collection: collectionConfig.slug, + data: { + sessions: sessionsAfterLogout, + }, + }) + + req.payload.logger.info({ + msg: `Removed session ${decodedToken.sid as string} from user ${user.id}`, + user: updatedUser, + }) + return true } diff --git a/packages/payload/src/auth/operations/me.ts b/packages/payload/src/auth/operations/me.ts index 3befc68188a..30d895a2a93 100644 --- a/packages/payload/src/auth/operations/me.ts +++ b/packages/payload/src/auth/operations/me.ts @@ -87,6 +87,14 @@ export const meOperation = async (args: Arguments): Promise = const decoded = decodeJwt(currentToken) if (decoded) { result.exp = decoded.exp + + const isSessionValid = user.sessions?.some((s) => s.id === decoded.sid) + + req.payload.logger.info({ + isSessionValid, + msg: 'DEBUG: me operation - checking session validity', + }) + // Q: Do something with the session? Update a timestamp? } if (!collection.config.auth.removeTokenFromResponses) { result.token = currentToken diff --git a/packages/payload/src/auth/operations/refresh.ts b/packages/payload/src/auth/operations/refresh.ts index 6e1da852266..a9942417e70 100644 --- a/packages/payload/src/auth/operations/refresh.ts +++ b/packages/payload/src/auth/operations/refresh.ts @@ -1,5 +1,6 @@ // @ts-strict-ignore 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' @@ -101,9 +102,21 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise } if (!result) { + // Q: Should we update the existing session's expiresAt or create a new one? + + const newSessionID = uuid() + + // Add session to user + if (!user.sessions?.length) { + user.sessions = [{ id: newSessionID, createdAt: new Date() }] + } else { + user.sessions.push({ id: newSessionID, createdAt: new Date() }) + } + const fieldsToSign = getFieldsToSign({ collectionConfig, email: user?.email as string, + sid: newSessionID, user: args?.req?.user, }) @@ -160,6 +173,23 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise // Return results // ///////////////////////////////////// + // TODO: only do this if sessions are enabled + if (user.sessions?.length) { + req.payload.logger.info({ + msg: 'DEBUG: refreshOperation - Updating user sessions', + user, + }) + + // Q: Should we prune the sessions array to remove old sessions here? + await req.payload.db.updateOne({ + id: user.id, + collection: collectionConfig.slug, + data: user, + req, + returning: false, + }) + } + if (shouldCommit) { await commitTransaction(req) } diff --git a/packages/payload/src/auth/types.ts b/packages/payload/src/auth/types.ts index aadc7d502c5..ee8a20327b0 100644 --- a/packages/payload/src/auth/types.ts +++ b/packages/payload/src/auth/types.ts @@ -273,6 +273,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 eb338df75ee..f9b9613ceb6 100644 --- a/packages/payload/src/collections/config/defaults.ts +++ b/packages/payload/src/collections/config/defaults.ts @@ -126,6 +126,7 @@ export const authDefaults: IncomingAuthType = { loginWithUsername: false, maxLoginAttempts: 5, tokenExpiration: 7200, + useSessions: true, verify: false, } @@ -141,6 +142,7 @@ export const addDefaultsToAuthConfig = (auth: IncomingAuthType): IncomingAuthTyp auth.loginWithUsername = auth.loginWithUsername ?? false auth.maxLoginAttempts = auth.maxLoginAttempts ?? 5 auth.tokenExpiration = auth.tokenExpiration ?? 7200 + auth.useSessions = true auth.verify = auth.verify ?? false auth.strategies = auth.strategies ?? [] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c111b1db6c..c97b0ab9aad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,7 +45,7 @@ importers: version: 1.50.0 '@sentry/nextjs': specifier: ^8.33.1 - version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.3.0(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-e993439-20250405)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4))(react@19.1.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))(esbuild@0.19.12)) + version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.3.0(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-e993439-20250405)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4))(react@19.1.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))) '@sentry/node': specifier: ^8.33.1 version: 8.37.1 @@ -135,7 +135,7 @@ importers: version: 10.1.3(@aws-sdk/credential-providers@3.687.0(@aws-sdk/client-sso-oidc@3.687.0(@aws-sdk/client-sts@3.687.0)))(socks@2.8.3) next: specifier: 15.3.0 - version: 15.3.0(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-e993439-20250405)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4) + version: 15.3.0(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-e993439-20250405)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4) open: specifier: ^10.1.0 version: 10.1.0 @@ -1144,7 +1144,7 @@ importers: dependencies: '@sentry/nextjs': specifier: ^8.33.1 - version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.3.0(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-e993439-20250405)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4))(react@19.1.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))(esbuild@0.19.12)) + version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.3.0(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-e993439-20250405)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4))(react@19.1.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))) '@sentry/types': specifier: ^8.33.1 version: 8.37.1 @@ -1503,7 +1503,7 @@ importers: version: link:../plugin-cloud-storage uploadthing: specifier: 7.3.0 - version: 7.3.0(next@15.3.0(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4)) + version: 7.3.0(next@15.3.0(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4)) devDependencies: payload: specifier: workspace:* @@ -1789,7 +1789,7 @@ importers: version: link:../packages/ui '@sentry/nextjs': specifier: ^8.33.1 - version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.3.0(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-e993439-20250405)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4))(react@19.1.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))(esbuild@0.19.12)) + version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.3.0(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-e993439-20250405)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4))(react@19.1.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))) '@sentry/react': specifier: ^7.77.0 version: 7.119.2(react@19.1.0) @@ -1846,7 +1846,7 @@ importers: version: 8.9.5(@aws-sdk/credential-providers@3.687.0(@aws-sdk/client-sso-oidc@3.687.0(@aws-sdk/client-sts@3.687.0)))(socks@2.8.3) next: specifier: 15.3.0 - version: 15.3.0(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-e993439-20250405)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4) + version: 15.3.0(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-e993439-20250405)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4) nodemailer: specifier: 6.9.16 version: 6.9.16 @@ -8130,7 +8130,6 @@ packages: libsql@0.4.7: resolution: {integrity: sha512-T9eIRCs6b0J1SHKYIvD8+KCJMcWZ900iZyxdnSCdqxN12Z1ijzT+jY5nrk72Jw4B0HGzms2NgpryArlJqvc3Lw==} - cpu: [x64, arm64, wasm32] os: [darwin, linux, win32] license-checker@25.0.1: @@ -8594,6 +8593,7 @@ packages: node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead node-fetch-native@1.6.4: resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==} @@ -14039,7 +14039,7 @@ snapshots: '@sentry/utils': 7.119.2 localforage: 1.10.0 - '@sentry/nextjs@8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.3.0(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-e993439-20250405)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4))(react@19.1.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))(esbuild@0.19.12))': + '@sentry/nextjs@8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.3.0(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-e993439-20250405)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4))(react@19.1.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/instrumentation-http': 0.53.0(@opentelemetry/api@1.9.0) @@ -14053,9 +14053,9 @@ snapshots: '@sentry/types': 8.37.1 '@sentry/utils': 8.37.1 '@sentry/vercel-edge': 8.37.1 - '@sentry/webpack-plugin': 2.22.6(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))(esbuild@0.19.12)) + '@sentry/webpack-plugin': 2.22.6(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))) chalk: 3.0.0 - next: 15.3.0(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-e993439-20250405)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4) + next: 15.3.0(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-e993439-20250405)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4) resolve: 1.22.8 rollup: 3.29.5 stacktrace-parser: 0.1.10 @@ -14163,12 +14163,12 @@ snapshots: '@sentry/types': 8.37.1 '@sentry/utils': 8.37.1 - '@sentry/webpack-plugin@2.22.6(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))(esbuild@0.19.12))': + '@sentry/webpack-plugin@2.22.6(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))': dependencies: '@sentry/bundler-plugin-core': 2.22.6 unplugin: 1.0.1 uuid: 9.0.0 - webpack: 5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))(esbuild@0.19.12) + webpack: 5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)) transitivePeerDependencies: - encoding - supports-color @@ -18750,7 +18750,7 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@15.3.0(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-e993439-20250405)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4): + next@15.3.0(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-e993439-20250405)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4): dependencies: '@next/env': 15.3.0 '@swc/counter': 0.1.3 @@ -20262,17 +20262,16 @@ snapshots: ansi-escapes: 4.3.2 supports-hyperlinks: 2.3.0 - terser-webpack-plugin@5.3.10(@swc/core@1.10.12(@swc/helpers@0.5.15))(esbuild@0.19.12)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))(esbuild@0.19.12)): + terser-webpack-plugin@5.3.10(@swc/core@1.10.12(@swc/helpers@0.5.15))(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))): dependencies: '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 3.3.0 serialize-javascript: 6.0.2 terser: 5.36.0 - webpack: 5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))(esbuild@0.19.12) + webpack: 5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)) optionalDependencies: '@swc/core': 1.10.12(@swc/helpers@0.5.15) - esbuild: 0.19.12 terser@5.36.0: dependencies: @@ -20551,14 +20550,14 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - uploadthing@7.3.0(next@15.3.0(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4)): + uploadthing@7.3.0(next@15.3.0(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4)): dependencies: '@effect/platform': 0.69.8(effect@3.10.3) '@uploadthing/mime-types': 0.3.2 '@uploadthing/shared': 7.1.1 effect: 3.10.3 optionalDependencies: - next: 15.3.0(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-e993439-20250405)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4) + next: 15.3.0(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-e993439-20250405)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.77.4) uri-js@4.4.1: dependencies: @@ -20663,7 +20662,7 @@ snapshots: webpack-virtual-modules@0.5.0: {} - webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))(esbuild@0.19.12): + webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.6 @@ -20685,7 +20684,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.10(@swc/core@1.10.12(@swc/helpers@0.5.15))(esbuild@0.19.12)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))(esbuild@0.19.12)) + terser-webpack-plugin: 5.3.10(@swc/core@1.10.12(@swc/helpers@0.5.15))(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))) watchpack: 2.4.2 webpack-sources: 3.2.3 transitivePeerDependencies: diff --git a/test/access-control/payload-types.ts b/test/access-control/payload-types.ts index 899f999ab3e..0e556de559c 100644 --- a/test/access-control/payload-types.ts +++ b/test/access-control/payload-types.ts @@ -93,6 +93,7 @@ export interface Config { hooks: Hook; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; + 'payload-sessions': PayloadSession; 'payload-migrations': PayloadMigration; }; collectionsJoins: {}; @@ -122,6 +123,7 @@ export interface Config { hooks: HooksSelect | HooksSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; + 'payload-sessions': PayloadSessionsSelect | PayloadSessionsSelect; 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; }; db: { @@ -835,6 +837,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". @@ -1220,6 +1242,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/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/tsconfig.base.json b/tsconfig.base.json index 12877b1be6f..679d8653da8 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -16,13 +16,21 @@ "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "jsx": "preserve", - "lib": ["DOM", "DOM.Iterable", "ES2022"], + "lib": [ + "DOM", + "DOM.Iterable", + "ES2022" + ], "outDir": "${configDir}/dist", "resolveJsonModule": true, "skipLibCheck": true, "emitDeclarationOnly": true, "sourceMap": true, - "types": ["jest", "node", "@types/jest"], + "types": [ + "jest", + "node", + "@types/jest" + ], "incremental": true, "isolatedModules": true, "plugins": [ @@ -31,35 +39,69 @@ } ], "paths": { - "@payload-config": ["./test/_community/config.ts"], - "@payloadcms/admin-bar": ["./packages/admin-bar/src"], - "@payloadcms/live-preview": ["./packages/live-preview/src"], - "@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"], - "@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"], - "@payloadcms/ui": ["./packages/ui/src/exports/client/index.ts"], - "@payloadcms/ui/shared": ["./packages/ui/src/exports/shared/index.ts"], - "@payloadcms/ui/scss": ["./packages/ui/src/scss.scss"], - "@payloadcms/ui/scss/app.scss": ["./packages/ui/src/scss/app.scss"], - "@payloadcms/next/*": ["./packages/next/src/exports/*.ts"], + "@payload-config": [ + "./test/_community/config.ts" + ], + "@payloadcms/admin-bar": [ + "./packages/admin-bar/src" + ], + "@payloadcms/live-preview": [ + "./packages/live-preview/src" + ], + "@payloadcms/live-preview-react": [ + "./packages/live-preview-react/src/index.ts" + ], + "@payloadcms/live-preview-vue": [ + "./packages/live-preview-vue/src/index.ts" + ], + "@payloadcms/ui": [ + "./packages/ui/src/exports/client/index.ts" + ], + "@payloadcms/ui/shared": [ + "./packages/ui/src/exports/shared/index.ts" + ], + "@payloadcms/ui/scss": [ + "./packages/ui/src/scss.scss" + ], + "@payloadcms/ui/scss/app.scss": [ + "./packages/ui/src/scss/app.scss" + ], + "@payloadcms/next/*": [ + "./packages/next/src/exports/*.ts" + ], "@payloadcms/richtext-lexical/client": [ "./packages/richtext-lexical/src/exports/client/index.ts" ], - "@payloadcms/richtext-lexical/rsc": ["./packages/richtext-lexical/src/exports/server/rsc.ts"], - "@payloadcms/richtext-slate/rsc": ["./packages/richtext-slate/src/exports/server/rsc.ts"], + "@payloadcms/richtext-lexical/rsc": [ + "./packages/richtext-lexical/src/exports/server/rsc.ts" + ], + "@payloadcms/richtext-slate/rsc": [ + "./packages/richtext-slate/src/exports/server/rsc.ts" + ], "@payloadcms/richtext-slate/client": [ "./packages/richtext-slate/src/exports/client/index.ts" ], - "@payloadcms/plugin-seo/client": ["./packages/plugin-seo/src/exports/client.ts"], - "@payloadcms/plugin-sentry/client": ["./packages/plugin-sentry/src/exports/client.ts"], - "@payloadcms/plugin-stripe/client": ["./packages/plugin-stripe/src/exports/client.ts"], - "@payloadcms/plugin-search/client": ["./packages/plugin-search/src/exports/client.ts"], + "@payloadcms/plugin-seo/client": [ + "./packages/plugin-seo/src/exports/client.ts" + ], + "@payloadcms/plugin-sentry/client": [ + "./packages/plugin-sentry/src/exports/client.ts" + ], + "@payloadcms/plugin-stripe/client": [ + "./packages/plugin-stripe/src/exports/client.ts" + ], + "@payloadcms/plugin-search/client": [ + "./packages/plugin-search/src/exports/client.ts" + ], "@payloadcms/plugin-form-builder/client": [ "./packages/plugin-form-builder/src/exports/client.ts" ], "@payloadcms/plugin-import-export/rsc": [ "./packages/plugin-import-export/src/exports/rsc.ts" ], - "@payloadcms/plugin-multi-tenant/rsc": ["./packages/plugin-multi-tenant/src/exports/rsc.ts"], + "@payloadcms/plugin-multi-tenant/rsc": [ + "./packages/plugin-multi-tenant/src/exports/rsc.ts" + ], "@payloadcms/plugin-multi-tenant/utilities": [ "./packages/plugin-multi-tenant/src/exports/utilities.ts" ], @@ -69,24 +111,39 @@ "@payloadcms/plugin-multi-tenant/client": [ "./packages/plugin-multi-tenant/src/exports/client.ts" ], - "@payloadcms/plugin-multi-tenant": ["./packages/plugin-multi-tenant/src/index.ts"], + "@payloadcms/plugin-multi-tenant": [ + "./packages/plugin-multi-tenant/src/index.ts" + ], "@payloadcms/plugin-multi-tenant/translations/languages/all": [ "./packages/plugin-multi-tenant/src/translations/index.ts" ], "@payloadcms/plugin-multi-tenant/translations/languages/*": [ "./packages/plugin-multi-tenant/src/translations/languages/*.ts" ], - "@payloadcms/next": ["./packages/next/src/exports/*"], - "@payloadcms/storage-s3/client": ["./packages/storage-s3/src/exports/client.ts"], + "@payloadcms/next": [ + "./packages/next/src/exports/*" + ], + "@payloadcms/storage-s3/client": [ + "./packages/storage-s3/src/exports/client.ts" + ], "@payloadcms/storage-vercel-blob/client": [ "./packages/storage-vercel-blob/src/exports/client.ts" ], - "@payloadcms/storage-gcs/client": ["./packages/storage-gcs/src/exports/client.ts"], + "@payloadcms/storage-gcs/client": [ + "./packages/storage-gcs/src/exports/client.ts" + ], "@payloadcms/storage-uploadthing/client": [ "./packages/storage-uploadthing/src/exports/client.ts" ] } }, - "include": ["${configDir}/src"], - "exclude": ["${configDir}/dist", "${configDir}/build", "${configDir}/temp", "**/*.spec.ts"] + "include": [ + "${configDir}/src" + ], + "exclude": [ + "${configDir}/dist", + "${configDir}/build", + "${configDir}/temp", + "**/*.spec.ts" + ] } From 24d23b40f3cc74ec463e3fd75ea12a8ad62e15b7 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Mon, 19 May 2025 11:59:06 -0400 Subject: [PATCH 02/14] chore: check session validity in me operation --- packages/payload/src/auth/operations/login.ts | 4 +- packages/payload/src/auth/operations/me.ts | 26 ++++- packages/payload/src/auth/types.ts | 2 + tsconfig.base.json | 107 ++++-------------- 4 files changed, 51 insertions(+), 88 deletions(-) diff --git a/packages/payload/src/auth/operations/login.ts b/packages/payload/src/auth/operations/login.ts index 84c3756fe41..4031f6013eb 100644 --- a/packages/payload/src/auth/operations/login.ts +++ b/packages/payload/src/auth/operations/login.ts @@ -118,9 +118,7 @@ export const loginOperation = async ( // Login // ///////////////////////////////////// - let user: - | ({ sessions?: Array<{ createdAt: Date; expiresAt: Date; id: string }> } & User) - | null = null + let user: null | User = null const { email: unsanitizedEmail, password } = data const loginWithUsername = collectionConfig.auth.loginWithUsername diff --git a/packages/payload/src/auth/operations/me.ts b/packages/payload/src/auth/operations/me.ts index 30d895a2a93..fdc9aeb2135 100644 --- a/packages/payload/src/auth/operations/me.ts +++ b/packages/payload/src/auth/operations/me.ts @@ -88,13 +88,33 @@ export const meOperation = async (args: Arguments): Promise = if (decoded) { result.exp = decoded.exp - const isSessionValid = user.sessions?.some((s) => s.id === decoded.sid) + const validSession = user.sessions?.find((s) => { + const expired = new Date() > s.expiresAt + const matchingSession = s.id === decoded.sid + req.payload.logger.info({ + expired, + expiresAt: s.expiresAt, + msg: `DEBUG: Checking session validity of ${s.id}`, + now: new Date(), + }) + + return matchingSession && !expired + }) req.payload.logger.info({ - isSessionValid, msg: 'DEBUG: me operation - checking session validity', + validSession, }) - // Q: Do something with the session? Update a timestamp? + + if (validSession) { + // Q: Should we update the session expiry date here? + } else { + req.payload.logger.info({ + msg: 'DEBUG: me operation - session expired!!', + }) + result.user = null + // Q: Throw an error if the session is expired or is null user sufficient? + } } if (!collection.config.auth.removeTokenFromResponses) { result.token = currentToken diff --git a/packages/payload/src/auth/types.ts b/packages/payload/src/auth/types.ts index ee8a20327b0..d741f0453ec 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; expiresAt: Date; id: string } type GenerateVerifyEmailHTML = (args: { req: PayloadRequest token: string diff --git a/tsconfig.base.json b/tsconfig.base.json index 679d8653da8..12877b1be6f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -16,21 +16,13 @@ "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "jsx": "preserve", - "lib": [ - "DOM", - "DOM.Iterable", - "ES2022" - ], + "lib": ["DOM", "DOM.Iterable", "ES2022"], "outDir": "${configDir}/dist", "resolveJsonModule": true, "skipLibCheck": true, "emitDeclarationOnly": true, "sourceMap": true, - "types": [ - "jest", - "node", - "@types/jest" - ], + "types": ["jest", "node", "@types/jest"], "incremental": true, "isolatedModules": true, "plugins": [ @@ -39,69 +31,35 @@ } ], "paths": { - "@payload-config": [ - "./test/_community/config.ts" - ], - "@payloadcms/admin-bar": [ - "./packages/admin-bar/src" - ], - "@payloadcms/live-preview": [ - "./packages/live-preview/src" - ], - "@payloadcms/live-preview-react": [ - "./packages/live-preview-react/src/index.ts" - ], - "@payloadcms/live-preview-vue": [ - "./packages/live-preview-vue/src/index.ts" - ], - "@payloadcms/ui": [ - "./packages/ui/src/exports/client/index.ts" - ], - "@payloadcms/ui/shared": [ - "./packages/ui/src/exports/shared/index.ts" - ], - "@payloadcms/ui/scss": [ - "./packages/ui/src/scss.scss" - ], - "@payloadcms/ui/scss/app.scss": [ - "./packages/ui/src/scss/app.scss" - ], - "@payloadcms/next/*": [ - "./packages/next/src/exports/*.ts" - ], + "@payload-config": ["./test/_community/config.ts"], + "@payloadcms/admin-bar": ["./packages/admin-bar/src"], + "@payloadcms/live-preview": ["./packages/live-preview/src"], + "@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"], + "@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"], + "@payloadcms/ui": ["./packages/ui/src/exports/client/index.ts"], + "@payloadcms/ui/shared": ["./packages/ui/src/exports/shared/index.ts"], + "@payloadcms/ui/scss": ["./packages/ui/src/scss.scss"], + "@payloadcms/ui/scss/app.scss": ["./packages/ui/src/scss/app.scss"], + "@payloadcms/next/*": ["./packages/next/src/exports/*.ts"], "@payloadcms/richtext-lexical/client": [ "./packages/richtext-lexical/src/exports/client/index.ts" ], - "@payloadcms/richtext-lexical/rsc": [ - "./packages/richtext-lexical/src/exports/server/rsc.ts" - ], - "@payloadcms/richtext-slate/rsc": [ - "./packages/richtext-slate/src/exports/server/rsc.ts" - ], + "@payloadcms/richtext-lexical/rsc": ["./packages/richtext-lexical/src/exports/server/rsc.ts"], + "@payloadcms/richtext-slate/rsc": ["./packages/richtext-slate/src/exports/server/rsc.ts"], "@payloadcms/richtext-slate/client": [ "./packages/richtext-slate/src/exports/client/index.ts" ], - "@payloadcms/plugin-seo/client": [ - "./packages/plugin-seo/src/exports/client.ts" - ], - "@payloadcms/plugin-sentry/client": [ - "./packages/plugin-sentry/src/exports/client.ts" - ], - "@payloadcms/plugin-stripe/client": [ - "./packages/plugin-stripe/src/exports/client.ts" - ], - "@payloadcms/plugin-search/client": [ - "./packages/plugin-search/src/exports/client.ts" - ], + "@payloadcms/plugin-seo/client": ["./packages/plugin-seo/src/exports/client.ts"], + "@payloadcms/plugin-sentry/client": ["./packages/plugin-sentry/src/exports/client.ts"], + "@payloadcms/plugin-stripe/client": ["./packages/plugin-stripe/src/exports/client.ts"], + "@payloadcms/plugin-search/client": ["./packages/plugin-search/src/exports/client.ts"], "@payloadcms/plugin-form-builder/client": [ "./packages/plugin-form-builder/src/exports/client.ts" ], "@payloadcms/plugin-import-export/rsc": [ "./packages/plugin-import-export/src/exports/rsc.ts" ], - "@payloadcms/plugin-multi-tenant/rsc": [ - "./packages/plugin-multi-tenant/src/exports/rsc.ts" - ], + "@payloadcms/plugin-multi-tenant/rsc": ["./packages/plugin-multi-tenant/src/exports/rsc.ts"], "@payloadcms/plugin-multi-tenant/utilities": [ "./packages/plugin-multi-tenant/src/exports/utilities.ts" ], @@ -111,39 +69,24 @@ "@payloadcms/plugin-multi-tenant/client": [ "./packages/plugin-multi-tenant/src/exports/client.ts" ], - "@payloadcms/plugin-multi-tenant": [ - "./packages/plugin-multi-tenant/src/index.ts" - ], + "@payloadcms/plugin-multi-tenant": ["./packages/plugin-multi-tenant/src/index.ts"], "@payloadcms/plugin-multi-tenant/translations/languages/all": [ "./packages/plugin-multi-tenant/src/translations/index.ts" ], "@payloadcms/plugin-multi-tenant/translations/languages/*": [ "./packages/plugin-multi-tenant/src/translations/languages/*.ts" ], - "@payloadcms/next": [ - "./packages/next/src/exports/*" - ], - "@payloadcms/storage-s3/client": [ - "./packages/storage-s3/src/exports/client.ts" - ], + "@payloadcms/next": ["./packages/next/src/exports/*"], + "@payloadcms/storage-s3/client": ["./packages/storage-s3/src/exports/client.ts"], "@payloadcms/storage-vercel-blob/client": [ "./packages/storage-vercel-blob/src/exports/client.ts" ], - "@payloadcms/storage-gcs/client": [ - "./packages/storage-gcs/src/exports/client.ts" - ], + "@payloadcms/storage-gcs/client": ["./packages/storage-gcs/src/exports/client.ts"], "@payloadcms/storage-uploadthing/client": [ "./packages/storage-uploadthing/src/exports/client.ts" ] } }, - "include": [ - "${configDir}/src" - ], - "exclude": [ - "${configDir}/dist", - "${configDir}/build", - "${configDir}/temp", - "**/*.spec.ts" - ] + "include": ["${configDir}/src"], + "exclude": ["${configDir}/dist", "${configDir}/build", "${configDir}/temp", "**/*.spec.ts"] } From 62f707671ce17fe175352003331ff869bd6e1630 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Tue, 20 May 2025 11:16:50 -0400 Subject: [PATCH 03/14] chore: useSessions conditional organization --- .../payload/src/auth/baseFields/sessions.ts | 6 ++ packages/payload/src/auth/operations/login.ts | 83 +++++++++---------- .../payload/src/auth/operations/refresh.ts | 31 +++---- 3 files changed, 63 insertions(+), 57 deletions(-) diff --git a/packages/payload/src/auth/baseFields/sessions.ts b/packages/payload/src/auth/baseFields/sessions.ts index 33b579e1e1d..adc3da53a74 100644 --- a/packages/payload/src/auth/baseFields/sessions.ts +++ b/packages/payload/src/auth/baseFields/sessions.ts @@ -13,11 +13,17 @@ export const sessionsFieldConfig: ArrayField = { { name: 'createdAt', type: 'date', + access: { + update: () => false, + }, defaultValue: () => new Date(), }, { name: 'expiresAt', type: 'date', + access: { + update: () => false, + }, required: true, }, ], diff --git a/packages/payload/src/auth/operations/login.ts b/packages/payload/src/auth/operations/login.ts index 4031f6013eb..ceedfac7705 100644 --- a/packages/payload/src/auth/operations/login.ts +++ b/packages/payload/src/auth/operations/login.ts @@ -252,39 +252,37 @@ export const loginOperation = async ( }) } - // Add session to user - const newSessionID = uuid() - const now = new Date() - const tokenExpInMs = collectionConfig.auth.tokenExpiration * 1000 - const expiresAt = new Date(now.getTime() + tokenExpInMs) - - req.payload.logger.info({ - createdAt: now, - expiresAt, - msg: 'DEBUG: loginOperation - Adding session to user', - newSessionID, - tokenExpiration: collectionConfig.auth.tokenExpiration, - }) - - const session = { id: newSessionID, createdAt: now, expiresAt } - if (!user.sessions?.length) { - user.sessions = [session] - } else { - user.sessions.push(session) - } - - const fieldsToSign = getFieldsToSign({ + const fieldsToSignArgs: Parameters[0] = { collectionConfig, email: sanitizedEmail, - sid: newSessionID, user, - }) + } - console.log({ - msg: `User ${user.id} logged in`, - newSessionID, - 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) + + req.payload.logger.info({ + createdAt: now, + expiresAt, + msg: 'DEBUG: loginOperation - Adding session to user', + newSessionID, + tokenExpiration: collectionConfig.auth.tokenExpiration, + }) + const session = { id: newSessionID, createdAt: now, expiresAt } + if (!user.sessions?.length) { + user.sessions = [session] + } else { + user.sessions.push(session) + } + + fieldsToSignArgs.sid = newSessionID + } + + const fieldsToSign = getFieldsToSign(fieldsToSignArgs) // ///////////////////////////////////// // beforeLogin - Collection @@ -378,19 +376,20 @@ export const loginOperation = async ( result, }) - // TODO: only do this if sessions are enabled - if (user.sessions?.length) { - req.payload.logger.info({ - msg: 'DEBUG: loginOperation - Updating user sessions', - user, - }) - await payload.db.updateOne({ - id: user.id, - collection: collectionConfig.slug, - data: user, - req, - returning: false, - }) + if (args.collection.config.auth.useSessions) { + if (user.sessions?.length) { + req.payload.logger.info({ + msg: 'DEBUG: loginOperation - Updating user sessions', + user, + }) + await payload.db.updateOne({ + id: user.id, + collection: collectionConfig.slug, + data: user, + req, + returning: false, + }) + } } // ///////////////////////////////////// diff --git a/packages/payload/src/auth/operations/refresh.ts b/packages/payload/src/auth/operations/refresh.ts index a9942417e70..015817f7f03 100644 --- a/packages/payload/src/auth/operations/refresh.ts +++ b/packages/payload/src/auth/operations/refresh.ts @@ -173,21 +173,22 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise // Return results // ///////////////////////////////////// - // TODO: only do this if sessions are enabled - if (user.sessions?.length) { - req.payload.logger.info({ - msg: 'DEBUG: refreshOperation - Updating user sessions', - user, - }) - - // Q: Should we prune the sessions array to remove old sessions here? - await req.payload.db.updateOne({ - id: user.id, - collection: collectionConfig.slug, - data: user, - req, - returning: false, - }) + if (args.collection.config.auth.useSessions) { + if (user.sessions?.length) { + req.payload.logger.info({ + msg: 'DEBUG: refreshOperation - Updating user sessions', + user, + }) + + // Q: Should we prune the sessions array to remove old sessions here? + await req.payload.db.updateOne({ + id: user.id, + collection: collectionConfig.slug, + data: user, + req, + returning: false, + }) + } } if (shouldCommit) { From 9ceec1c216df572d225a8f7858349af2d1ad9e87 Mon Sep 17 00:00:00 2001 From: James Mikrut Date: Wed, 21 May 2025 13:56:03 -0400 Subject: [PATCH 04/14] feat: functional sessions --- docs/authentication/overview.mdx | 1 + packages/graphql/src/resolvers/auth/logout.ts | 1 + .../graphql/src/schema/initCollections.ts | 3 + .../payload/src/auth/baseFields/sessions.ts | 17 +- packages/payload/src/auth/endpoints/logout.ts | 4 +- packages/payload/src/auth/operations/login.ts | 34 +-- .../payload/src/auth/operations/logout.ts | 50 ++-- packages/payload/src/auth/operations/me.ts | 28 --- .../payload/src/auth/operations/refresh.ts | 56 +++-- .../payload/src/auth/removeExpiredSessions.ts | 10 + packages/payload/src/auth/strategies/jwt.ts | 13 ++ packages/payload/src/auth/types.ts | 2 +- .../src/fields/hooks/afterRead/promise.ts | 4 +- packages/payload/src/index.ts | 12 + test/auth/int.spec.ts | 214 +++++++++++++++++- 15 files changed, 335 insertions(+), 114 deletions(-) create mode 100644 packages/payload/src/auth/removeExpiredSessions.ts diff --git a/docs/authentication/overview.mdx b/docs/authentication/overview.mdx index 688c71b853c..6e1e8ad2eb6 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/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/payload/src/auth/baseFields/sessions.ts b/packages/payload/src/auth/baseFields/sessions.ts index adc3da53a74..38c47b0f923 100644 --- a/packages/payload/src/auth/baseFields/sessions.ts +++ b/packages/payload/src/auth/baseFields/sessions.ts @@ -3,7 +3,15 @@ import type { ArrayField } from '../../fields/config/types.js' export const sessionsFieldConfig: ArrayField = { name: 'sessions', type: 'array', - defaultValue: [], + access: { + read: ({ doc, req: { user } }) => { + return user?.id === doc.id + }, + update: () => false, + }, + admin: { + disabled: true, + }, fields: [ { name: 'id', @@ -13,19 +21,12 @@ export const sessionsFieldConfig: ArrayField = { { name: 'createdAt', type: 'date', - access: { - update: () => false, - }, defaultValue: () => new Date(), }, { name: 'expiresAt', type: 'date', - access: { - update: () => false, - }, required: true, }, ], - 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/operations/login.ts b/packages/payload/src/auth/operations/login.ts index ceedfac7705..38823146182 100644 --- a/packages/payload/src/auth/operations/login.ts +++ b/packages/payload/src/auth/operations/login.ts @@ -27,6 +27,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' @@ -265,20 +266,23 @@ export const loginOperation = async ( const tokenExpInMs = collectionConfig.auth.tokenExpiration * 1000 const expiresAt = new Date(now.getTime() + tokenExpInMs) - req.payload.logger.info({ - createdAt: now, - expiresAt, - msg: 'DEBUG: loginOperation - Adding session to user', - newSessionID, - tokenExpiration: collectionConfig.auth.tokenExpiration, - }) 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 } @@ -376,22 +380,6 @@ export const loginOperation = async ( result, }) - if (args.collection.config.auth.useSessions) { - if (user.sessions?.length) { - req.payload.logger.info({ - msg: 'DEBUG: loginOperation - Updating user sessions', - user, - }) - await payload.db.updateOne({ - id: user.id, - collection: collectionConfig.slug, - data: user, - req, - returning: false, - }) - } - } - // ///////////////////////////////////// // Return results // ///////////////////////////////////// diff --git a/packages/payload/src/auth/operations/logout.ts b/packages/payload/src/auth/operations/logout.ts index e63759c4f99..309cbc5b3b5 100644 --- a/packages/payload/src/auth/operations/logout.ts +++ b/packages/payload/src/auth/operations/logout.ts @@ -8,6 +8,7 @@ import { APIError } from '../../errors/index.js' import { extractJWT } from '../extractJWT.js' export type Arguments = { + allSessions?: boolean collection: Collection req: PayloadRequest } @@ -15,6 +16,7 @@ export type Arguments = { export const logoutOperation = async (incomingArgs: Arguments): Promise => { let args = incomingArgs const { + allSessions, collection: { config: collectionConfig }, req: { user }, req, @@ -44,28 +46,36 @@ export const logoutOperation = async (incomingArgs: Arguments): Promise throw new APIError('Invalid token', httpStatus.INTERNAL_SERVER_ERROR) } - const sessionsAfterLogout = ((user.sessions || []) as Array<{ id: string }>).filter( - (s) => s.id !== decodedToken.sid, - ) - req.payload.logger.info({ - allSessions: user.sessions, - msg: 'Logging out and updating user', - sessionIDFromToken: decodedToken.sid, - sessionsAfterLogout, - }) + 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, + }, + }, + }) - const updatedUser = await req.payload.update({ - id: user.id, - collection: collectionConfig.slug, - data: { - sessions: sessionsAfterLogout, - }, - }) + if (allSessions) { + userWithSessions.sessions = [] + } else { + const sessionsAfterLogout = (userWithSessions?.sessions || []).filter( + (s) => s.id !== decodedToken.sid, + ) - req.payload.logger.info({ - msg: `Removed session ${decodedToken.sid as string} from user ${user.id}`, - user: updatedUser, - }) + userWithSessions.sessions = sessionsAfterLogout + } + + await req.payload.db.updateOne({ + id: user.id, + collection: collectionConfig.slug, + data: userWithSessions, + }) + } return true } diff --git a/packages/payload/src/auth/operations/me.ts b/packages/payload/src/auth/operations/me.ts index fdc9aeb2135..3befc68188a 100644 --- a/packages/payload/src/auth/operations/me.ts +++ b/packages/payload/src/auth/operations/me.ts @@ -87,34 +87,6 @@ export const meOperation = async (args: Arguments): Promise = const decoded = decodeJwt(currentToken) if (decoded) { result.exp = decoded.exp - - const validSession = user.sessions?.find((s) => { - const expired = new Date() > s.expiresAt - const matchingSession = s.id === decoded.sid - req.payload.logger.info({ - expired, - expiresAt: s.expiresAt, - msg: `DEBUG: Checking session validity of ${s.id}`, - now: new Date(), - }) - - return matchingSession && !expired - }) - - req.payload.logger.info({ - msg: 'DEBUG: me operation - checking session validity', - validSession, - }) - - if (validSession) { - // Q: Should we update the session expiry date here? - } else { - req.payload.logger.info({ - msg: 'DEBUG: me operation - session expired!!', - }) - result.user = null - // Q: Throw an error if the session is expired or is null user sufficient? - } } if (!collection.config.auth.removeTokenFromResponses) { result.token = currentToken diff --git a/packages/payload/src/auth/operations/refresh.ts b/packages/payload/src/auth/operations/refresh.ts index 015817f7f03..073c0659486 100644 --- a/packages/payload/src/auth/operations/refresh.ts +++ b/packages/payload/src/auth/operations/refresh.ts @@ -12,6 +12,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 @@ -81,6 +82,30 @@ 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: { + sessions: removeExpiredSessions(user.sessions), + }, + req, + returning: false, + }) + } + if (user) { user.collection = args.req.user.collection user._strategy = args.req.user._strategy @@ -102,21 +127,10 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise } if (!result) { - // Q: Should we update the existing session's expiresAt or create a new one? - - const newSessionID = uuid() - - // Add session to user - if (!user.sessions?.length) { - user.sessions = [{ id: newSessionID, createdAt: new Date() }] - } else { - user.sessions.push({ id: newSessionID, createdAt: new Date() }) - } - const fieldsToSign = getFieldsToSign({ collectionConfig, email: user?.email as string, - sid: newSessionID, + sid, user: args?.req?.user, }) @@ -173,24 +187,6 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise // Return results // ///////////////////////////////////// - if (args.collection.config.auth.useSessions) { - if (user.sessions?.length) { - req.payload.logger.info({ - msg: 'DEBUG: refreshOperation - Updating user sessions', - user, - }) - - // Q: Should we prune the sessions array to remove old sessions here? - await req.payload.db.updateOne({ - id: user.id, - collection: collectionConfig.slug, - data: user, - req, - returning: false, - }) - } - } - if (shouldCommit) { await commitTransaction(req) } 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 e2ea224b0b7..12b42f1fade 100644 --- a/packages/payload/src/auth/strategies/jwt.ts +++ b/packages/payload/src/auth/strategies/jwt.ts @@ -9,6 +9,7 @@ import { extractJWT } from '../extractJWT.js' type JWTToken = { collection: string id: string + sid?: string } async function autoLogin({ @@ -101,6 +102,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 d741f0453ec..7e33a748046 100644 --- a/packages/payload/src/auth/types.ts +++ b/packages/payload/src/auth/types.ts @@ -134,7 +134,7 @@ export type ClientUser = { [key: string]: any } & BaseUser -export type UserSession = { createdAt: Date; expiresAt: Date; id: string } +export type UserSession = { createdAt: Date | string; expiresAt: Date | string; id: string } type GenerateVerifyEmailHTML = (args: { req: PayloadRequest token: string diff --git a/packages/payload/src/fields/hooks/afterRead/promise.ts b/packages/payload/src/fields/hooks/afterRead/promise.ts index a06c7ab7062..f23aa685a9c 100644 --- a/packages/payload/src/fields/hooks/afterRead/promise.ts +++ b/packages/payload/src/fields/hooks/afterRead/promise.ts @@ -462,7 +462,7 @@ export const promise = async ({ }) } }) - } else { + } else if (field.hidden !== true || showHiddenFields === true) { siblingDoc[field.name] = [] } break @@ -565,7 +565,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 291d50e6323..f77903527c3 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -87,6 +87,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 { default as executeAccess } from './auth/executeAccess.js' export { executeAuthStrategies } from './auth/executeAuthStrategies.js' export { extractAccessFromPermission } from './auth/extractAccessFromPermission.js' diff --git a/test/auth/int.spec.ts b/test/auth/int.spec.ts index ec6e6c0e537..56973694a5b 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, @@ -739,7 +740,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) @@ -1053,4 +1054,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: String(new Date()), + expiresAt: String(new Date(new Date().getTime() - 5000)), + }, + ], + }, + }) + + 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) + }) + }) }) From ff8a874f62bbe84bbd525dc60eeb41202c462e20 Mon Sep 17 00:00:00 2001 From: James Mikrut Date: Wed, 21 May 2025 13:59:45 -0400 Subject: [PATCH 05/14] chore: cleanup logout --- packages/payload/src/auth/operations/logout.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/payload/src/auth/operations/logout.ts b/packages/payload/src/auth/operations/logout.ts index 309cbc5b3b5..ccda8a796a5 100644 --- a/packages/payload/src/auth/operations/logout.ts +++ b/packages/payload/src/auth/operations/logout.ts @@ -1,11 +1,9 @@ import { status as httpStatus } from 'http-status' -import { decodeJwt } from 'jose' import type { Collection } from '../../collections/config/types.js' import type { PayloadRequest } from '../../types/index.js' import { APIError } from '../../errors/index.js' -import { extractJWT } from '../extractJWT.js' export type Arguments = { allSessions?: boolean @@ -40,12 +38,6 @@ export const logoutOperation = async (incomingArgs: Arguments): Promise } } - const token = extractJWT(req) - const decodedToken = token ? decodeJwt(token) : undefined - if (!decodedToken) { - throw new APIError('Invalid token', httpStatus.INTERNAL_SERVER_ERROR) - } - if (collectionConfig.auth.disableLocalStrategy !== true && collectionConfig.auth.useSessions) { const userWithSessions = await req.payload.db.findOne<{ id: number | string @@ -64,7 +56,7 @@ export const logoutOperation = async (incomingArgs: Arguments): Promise userWithSessions.sessions = [] } else { const sessionsAfterLogout = (userWithSessions?.sessions || []).filter( - (s) => s.id !== decodedToken.sid, + (s) => s.id !== req.user._sid, ) userWithSessions.sessions = sessionsAfterLogout From 5658fa7aaa80b55a48ac2cdd77a3eded1f246c87 Mon Sep 17 00:00:00 2001 From: James Mikrut Date: Wed, 21 May 2025 14:09:47 -0400 Subject: [PATCH 06/14] chore: ts --- docs/authentication/operations.mdx | 19 +++++++++++++------ .../payload/src/auth/operations/logout.ts | 4 ++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/authentication/operations.mdx b/docs/authentication/operations.mdx index 88c81baaf92..ddf06a82a29 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/packages/payload/src/auth/operations/logout.ts b/packages/payload/src/auth/operations/logout.ts index ccda8a796a5..7741f1fd193 100644 --- a/packages/payload/src/auth/operations/logout.ts +++ b/packages/payload/src/auth/operations/logout.ts @@ -52,6 +52,10 @@ export const logoutOperation = async (incomingArgs: Arguments): Promise }, }) + if (!userWithSessions) { + throw new APIError('No User', httpStatus.BAD_REQUEST) + } + if (allSessions) { userWithSessions.sessions = [] } else { From 37786c6402faf1fbe726ddf9aaaba7ea8428fc82 Mon Sep 17 00:00:00 2001 From: Jessica Chowdhury Date: Wed, 28 May 2025 11:02:24 +0100 Subject: [PATCH 07/14] chore: update nextjs auth server functions and docs to incorporate sessions --- docs/local-api/server-functions.mdx | 9 +++-- packages/next/src/auth/logout.ts | 34 +++++++++++++++---- packages/next/src/auth/refresh.ts | 33 ++++++++++++------ .../payload/src/auth/baseFields/sessions.ts | 2 +- .../components/loginFunction.tsx | 1 - .../components/refreshFunction.tsx | 1 - 6 files changed, 56 insertions(+), 24 deletions(-) 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/packages/next/src/auth/logout.ts b/packages/next/src/auth/logout.ts index fc220fb88ed..29e23245637 100644 --- a/packages/next/src/auth/logout.ts +++ b/packages/next/src/auth/logout.ts @@ -1,27 +1,49 @@ '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 +}) { try { 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 } } catch (e) { console.error('Logout error:', e) throw new Error(`${e}`) diff --git a/packages/next/src/auth/refresh.ts b/packages/next/src/auth/refresh.ts index 920a4c558d8..884e0cce1ab 100644 --- a/packages/next/src/auth/refresh.ts +++ b/packages/next/src/auth/refresh.ts @@ -3,33 +3,46 @@ 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 }) { try { 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() }) - if (!user) { - throw new Error('User not authenticated') + const req = await createLocalReq({ user: result.user }, payload) + + 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 index 38c47b0f923..46f1807e5a2 100644 --- a/packages/payload/src/auth/baseFields/sessions.ts +++ b/packages/payload/src/auth/baseFields/sessions.ts @@ -5,7 +5,7 @@ export const sessionsFieldConfig: ArrayField = { type: 'array', access: { read: ({ doc, req: { user } }) => { - return user?.id === doc.id + return user?.id === doc?.id }, update: () => false, }, 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) { From e7ffb9999536b1d346cb4f130d5d0ccc19bbe6f2 Mon Sep 17 00:00:00 2001 From: Jessica Chowdhury Date: Wed, 28 May 2025 11:25:20 +0100 Subject: [PATCH 08/14] chore: fix misc build errors --- packages/next/src/auth/login.ts | 3 +-- packages/payload/src/auth/operations/logout.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/next/src/auth/login.ts b/packages/next/src/auth/login.ts index c85cd31dd41..e97fb45d94b 100644 --- a/packages/next/src/auth/login.ts +++ b/packages/next/src/auth/login.ts @@ -2,8 +2,7 @@ import type { CollectionSlug } from 'payload' -import { cookies as getCookies } from 'next/headers.js' -import { generatePayloadCookie, getPayload } from 'payload' +import { getPayload } from 'payload' import { setPayloadAuthCookie } from '../utilities/setPayloadAuthCookie.js' diff --git a/packages/payload/src/auth/operations/logout.ts b/packages/payload/src/auth/operations/logout.ts index 7741f1fd193..4bf8da98e20 100644 --- a/packages/payload/src/auth/operations/logout.ts +++ b/packages/payload/src/auth/operations/logout.ts @@ -60,7 +60,7 @@ export const logoutOperation = async (incomingArgs: Arguments): Promise userWithSessions.sessions = [] } else { const sessionsAfterLogout = (userWithSessions?.sessions || []).filter( - (s) => s.id !== req.user._sid, + (s) => s.id !== req?.user?._sid, ) userWithSessions.sessions = sessionsAfterLogout From 6dfb7f143c1cc111c1ac10dbccf50a26c5a3ebf0 Mon Sep 17 00:00:00 2001 From: Jessica Chowdhury Date: Fri, 30 May 2025 12:20:17 +0100 Subject: [PATCH 09/14] chore: fix auth int test --- test/auth/int.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/auth/int.spec.ts b/test/auth/int.spec.ts index 56973694a5b..4f4baa0a3c0 100644 --- a/test/auth/int.spec.ts +++ b/test/auth/int.spec.ts @@ -754,6 +754,7 @@ describe('Auth', () => { 'hash', 'loginAttempts', 'lockUntil', + 'sessions', ]) }) From 96c57cb4640e41f852d4c8b4093a21db80a40aa4 Mon Sep 17 00:00:00 2001 From: Jessica Chowdhury Date: Fri, 30 May 2025 12:28:04 +0100 Subject: [PATCH 10/14] chore: add returning:false to logout update operation --- packages/payload/src/auth/operations/logout.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/payload/src/auth/operations/logout.ts b/packages/payload/src/auth/operations/logout.ts index 4bf8da98e20..1977a7ffa4e 100644 --- a/packages/payload/src/auth/operations/logout.ts +++ b/packages/payload/src/auth/operations/logout.ts @@ -70,6 +70,7 @@ export const logoutOperation = async (incomingArgs: Arguments): Promise id: user.id, collection: collectionConfig.slug, data: userWithSessions, + returning: false, }) } From b484468b1a48a3128e02bf27b287c18c6eeedd8e Mon Sep 17 00:00:00 2001 From: Jessica Chowdhury Date: Fri, 30 May 2025 14:32:48 +0100 Subject: [PATCH 11/14] chore: move resetLoginAttempts to after session handling in login op --- packages/payload/src/auth/operations/login.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/payload/src/auth/operations/login.ts b/packages/payload/src/auth/operations/login.ts index 38823146182..d02673bd936 100644 --- a/packages/payload/src/auth/operations/login.ts +++ b/packages/payload/src/auth/operations/login.ts @@ -244,15 +244,6 @@ export const loginOperation = async ( throw new AuthenticationError(req.t) } - if (maxLoginAttemptsEnabled) { - await resetLoginAttempts({ - collection: collectionConfig, - doc: user, - payload: req.payload, - req, - }) - } - const fieldsToSignArgs: Parameters[0] = { collectionConfig, email: sanitizedEmail, @@ -288,6 +279,15 @@ export const loginOperation = async ( const fieldsToSign = getFieldsToSign(fieldsToSignArgs) + if (maxLoginAttemptsEnabled) { + await resetLoginAttempts({ + collection: collectionConfig, + doc: user, + payload: req.payload, + req, + }) + } + // ///////////////////////////////////// // beforeLogin - Collection // ///////////////////////////////////// From 1c212c9df51cb9f693263671a9186ba58a742182 Mon Sep 17 00:00:00 2001 From: Jessica Chowdhury Date: Fri, 30 May 2025 16:37:20 +0100 Subject: [PATCH 12/14] chore: fix failing auth int tests --- packages/payload/src/auth/operations/refresh.ts | 1 + test/auth/int.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/payload/src/auth/operations/refresh.ts b/packages/payload/src/auth/operations/refresh.ts index 073c0659486..4f6111cbb8d 100644 --- a/packages/payload/src/auth/operations/refresh.ts +++ b/packages/payload/src/auth/operations/refresh.ts @@ -99,6 +99,7 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise id: user.id, collection: collectionConfig.slug, data: { + ...user, sessions: removeExpiredSessions(user.sessions), }, req, diff --git a/test/auth/int.spec.ts b/test/auth/int.spec.ts index 4f4baa0a3c0..0612aaf57c2 100644 --- a/test/auth/int.spec.ts +++ b/test/auth/int.spec.ts @@ -1237,8 +1237,8 @@ describe('Auth', () => { sessions: [ { id: uuid(), - createdAt: String(new Date()), - expiresAt: String(new Date(new Date().getTime() - 5000)), + createdAt: new Date().toDateString(), + expiresAt: new Date(new Date().getTime() - 5000).toDateString(), // Set an expired session }, ], }, From 8827ce80c6695316220c6d2d5ac55b866e9f0b98 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Mon, 2 Jun 2025 15:40:36 -0400 Subject: [PATCH 13/14] chore: respect incoming useSessions value --- packages/payload/src/collections/config/defaults.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/payload/src/collections/config/defaults.ts b/packages/payload/src/collections/config/defaults.ts index f9b9613ceb6..565ca222830 100644 --- a/packages/payload/src/collections/config/defaults.ts +++ b/packages/payload/src/collections/config/defaults.ts @@ -142,7 +142,7 @@ export const addDefaultsToAuthConfig = (auth: IncomingAuthType): IncomingAuthTyp auth.loginWithUsername = auth.loginWithUsername ?? false auth.maxLoginAttempts = auth.maxLoginAttempts ?? 5 auth.tokenExpiration = auth.tokenExpiration ?? 7200 - auth.useSessions = true + auth.useSessions = auth.useSessions ?? true auth.verify = auth.verify ?? false auth.strategies = auth.strategies ?? [] From 1ef5c247193c6bf81ab2ec79ae34967bdb6075ab Mon Sep 17 00:00:00 2001 From: Jarrod Flesch <30633324+JarrodMFlesch@users.noreply.github.com> Date: Mon, 23 Jun 2025 12:26:59 -0400 Subject: [PATCH 14/14] ci: adjust neverBuiltDependencies in test/package.json (#12896) Fixes an issue introduced with https://github.com/payloadcms/payload/commit/4831f66f63c4a9cc0f8d0c92792366341e32cb7a that prevents CI from running the built code --------- Co-authored-by: Sasha <64744993+r1tsuu@users.noreply.github.com> --- test/package.json | 3 +++ test/versions/seed.ts | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) 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/versions/seed.ts b/test/versions/seed.ts index ed7ef8aa630..9a7d921f7e7 100644 --- a/test/versions/seed.ts +++ b/test/versions/seed.ts @@ -300,11 +300,17 @@ export async function seed(_payload: Payload, parallel: boolean = false) { depth: 0, }) + const pointGeoJSON: any = { + type: 'Point', + coordinates: [1, 3], + } + await _payload.db.updateOne({ collection: diffCollectionSlug, id: diffDoc.id, data: { ...diffDoc, + point: pointGeoJSON, createdAt: new Date(new Date(diffDoc.createdAt).getTime() - 2 * 60 * 10000).toISOString(), updatedAt: new Date(new Date(diffDoc.updatedAt).getTime() - 2 * 60 * 10000).toISOString(), }, @@ -390,7 +396,7 @@ export async function seed(_payload: Payload, parallel: boolean = false) { }, ], }, - point: [1, 3], + point: pointGeoJSON, radio: 'option2', relationship: draft2.id, relationshipHasMany: [manyDraftsID, draft2.id],