feat: auth sessions (#12483)

Adds full session functionality into Payload's existing local
authentication strategy.

It's enabled by default, because this is a more secure pattern that we
should enforce. However, we have provided an opt-out pattern for those
that want to stick to stateless JWT authentication by passing
`collectionConfig.auth.useSessions: false`.

Todo:

- [x] @jessrynkar to update the Next.js server functions for refresh and
logout to support these new features
- [x] @jessrynkar resolve build errors

---------

Co-authored-by: Elliot DeNolf <denolfe@gmail.com>
Co-authored-by: Jessica Chowdhury <jessica@trbl.design>
Co-authored-by: Jarrod Flesch <30633324+JarrodMFlesch@users.noreply.github.com>
Co-authored-by: Sasha <64744993+r1tsuu@users.noreply.github.com>
This commit is contained in:
James Mikrut
2025-06-27 09:13:52 -04:00
committed by GitHub
parent c8b72141e4
commit 26d709dda6
29 changed files with 610 additions and 81 deletions

View File

@@ -180,19 +180,22 @@ As Payload sets HTTP-only cookies, logging out cannot be done by just removing a
**Example REST API logout**: **Example REST API logout**:
```ts ```ts
const res = await fetch('http://localhost:3000/api/[collection-slug]/logout', { const res = await fetch(
'http://localhost:3000/api/[collection-slug]/logout?allSessions=false',
{
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}) },
)
``` ```
**Example GraphQL Mutation**: **Example GraphQL Mutation**:
``` ```
mutation { mutation {
logout[collection-singular-label] logoutUser(allSessions: false)
} }
``` ```
@@ -203,6 +206,10 @@ mutation {
docs](../local-api/server-functions#reusable-payload-server-functions). docs](../local-api/server-functions#reusable-payload-server-functions).
</Banner> </Banner>
#### 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 ## 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. 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.

View File

@@ -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). | | **`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. | | **`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). | | **`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). | | **`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 ### Login With Username

View File

@@ -393,7 +393,7 @@ export default function LoginForm() {
### Logout ### 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 #### Importing the `logout` function
@@ -401,7 +401,7 @@ Logs out the current user by clearing the authentication cookie.
import { logout } from '@payloadcms/next/auth' 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 ```ts
'use server' 'use server'
@@ -411,7 +411,7 @@ import config from '@payload-config'
export async function logoutAction() { export async function logoutAction() {
try { try {
return await logout({ config }) return await logout({ allSessions: true, config })
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Logout failed: ${error instanceof Error ? error.message : 'Unknown error'}`, `Logout failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
@@ -434,7 +434,7 @@ export default function LogoutButton() {
### Refresh ### 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 #### Importing the `refresh` function
@@ -453,7 +453,6 @@ import config from '@payload-config'
export async function refreshAction() { export async function refreshAction() {
try { try {
return await refresh({ return await refresh({
collection: 'users', // pass your collection slug
config, config,
}) })
} catch (error) { } catch (error) {

View File

@@ -74,9 +74,7 @@ import * as Sentry from '@sentry/nextjs'
const config = buildConfig({ const config = buildConfig({
collections: [Pages, Media], collections: [Pages, Media],
plugins: [ plugins: [sentryPlugin({ Sentry })],
sentryPlugin({ Sentry })
],
}) })
export default config export default config
@@ -98,9 +96,7 @@ export default buildConfig({
pool: { connectionString: process.env.DATABASE_URL }, pool: { connectionString: process.env.DATABASE_URL },
pg, // Inject the patched pg driver for Sentry instrumentation pg, // Inject the patched pg driver for Sentry instrumentation
}), }),
plugins: [ plugins: [sentryPlugin({ Sentry })],
sentryPlugin({ Sentry })
],
}) })
``` ```

View File

@@ -7,6 +7,7 @@ import type { Context } from '../types.js'
export function logout(collection: Collection): any { export function logout(collection: Collection): any {
async function resolver(_, args, context: Context) { async function resolver(_, args, context: Context) {
const options = { const options = {
allSessions: args.allSessions,
collection, collection,
req: isolateObjectProperty(context.req, 'transactionID'), req: isolateObjectProperty(context.req, 'transactionID'),
} }

View File

@@ -487,6 +487,9 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
graphqlResult.Mutation.fields[`logout${singularName}`] = { graphqlResult.Mutation.fields[`logout${singularName}`] = {
type: GraphQLString, type: GraphQLString,
args: {
allSessions: { type: GraphQLBoolean },
},
resolve: logout(collection), resolve: logout(collection),
} }

View File

@@ -1,24 +1,46 @@
'use server' 'use server'
import type { SanitizedConfig } from 'payload'
import { cookies as getCookies, headers as nextHeaders } from 'next/headers.js' 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' 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> | SanitizedConfig
}) {
const payload = await getPayload({ config }) const payload = await getPayload({ config })
const headers = await nextHeaders() 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 } 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) { if (existingCookie) {
const cookies = await getCookies() const cookies = await getCookies()
cookies.delete(existingCookie.name) cookies.delete(existingCookie.name)
return { message: 'User logged out successfully', success: true }
} }
return { message: 'User logged out successfully', success: true }
} }

View File

@@ -3,33 +3,45 @@
import type { CollectionSlug } from 'payload' import type { CollectionSlug } from 'payload'
import { headers as nextHeaders } from 'next/headers.js' 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 { getExistingAuthToken } from '../utilities/getExistingAuthToken.js'
import { setPayloadAuthCookie } from '../utilities/setPayloadAuthCookie.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 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}`) 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) { const refreshResult = await refreshOperation({
throw new Error('User not authenticated') collection: collectionConfig,
req,
})
if (!refreshResult) {
return { message: 'Token refresh failed', success: false }
} }
const existingCookie = await getExistingAuthToken(payload.config.cookiePrefix) const existingCookie = await getExistingAuthToken(payload.config.cookiePrefix)
if (!existingCookie) { if (!existingCookie) {
return { message: 'No valid token found', success: false } return { message: 'No valid token found to refresh', success: false }
} }
await setPayloadAuthCookie({ await setPayloadAuthCookie({
authConfig, authConfig: collectionConfig.config.auth,
cookiePrefix: payload.config.cookiePrefix, cookiePrefix: payload.config.cookiePrefix,
token: existingCookie.value, token: existingCookie.value,
}) })

View File

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

View File

@@ -9,8 +9,10 @@ import { logoutOperation } from '../operations/logout.js'
export const logoutHandler: PayloadHandler = async (req) => { export const logoutHandler: PayloadHandler = async (req) => {
const collection = getRequestCollection(req) const collection = getRequestCollection(req)
const { t } = req const { searchParams, t } = req
const result = await logoutOperation({ const result = await logoutOperation({
allSessions: searchParams.get('allSessions') === 'true',
collection, collection,
req, req,
}) })

View File

@@ -5,6 +5,7 @@ import { accountLockFields } from './baseFields/accountLock.js'
import { apiKeyFields } from './baseFields/apiKey.js' import { apiKeyFields } from './baseFields/apiKey.js'
import { baseAuthFields } from './baseFields/auth.js' import { baseAuthFields } from './baseFields/auth.js'
import { emailFieldConfig } from './baseFields/email.js' import { emailFieldConfig } from './baseFields/email.js'
import { sessionsFieldConfig } from './baseFields/sessions.js'
import { usernameFieldConfig } from './baseFields/username.js' import { usernameFieldConfig } from './baseFields/username.js'
import { verificationFields } from './baseFields/verification.js' import { verificationFields } from './baseFields/verification.js'
@@ -52,6 +53,10 @@ export const getBaseAuthFields = (authConfig: IncomingAuthType): Field[] => {
if (authConfig?.maxLoginAttempts && authConfig.maxLoginAttempts > 0) { if (authConfig?.maxLoginAttempts && authConfig.maxLoginAttempts > 0) {
authFields.push(...accountLockFields) authFields.push(...accountLockFields)
} }
if (authConfig.useSessions) {
authFields.push(sessionsFieldConfig)
}
} }
return authFields return authFields

View File

@@ -114,9 +114,10 @@ const traverseFields = ({
export const getFieldsToSign = (args: { export const getFieldsToSign = (args: {
collectionConfig: CollectionConfig collectionConfig: CollectionConfig
email: string email: string
sid?: string
user: PayloadRequest['user'] user: PayloadRequest['user']
}): Record<string, unknown> => { }): Record<string, unknown> => {
const { collectionConfig, email, user } = args const { collectionConfig, email, sid, user } = args
const result: Record<string, unknown> = { const result: Record<string, unknown> = {
id: user?.id, id: user?.id,
@@ -124,6 +125,10 @@ export const getFieldsToSign = (args: {
email, email,
} }
if (sid) {
result.sid = sid
}
traverseFields({ traverseFields({
data: user!, data: user!,
fields: collectionConfig.fields, fields: collectionConfig.fields,

View File

@@ -1,3 +1,5 @@
import { v4 as uuid } from 'uuid'
import type { import type {
AuthOperationsFromCollectionSlug, AuthOperationsFromCollectionSlug,
Collection, Collection,
@@ -23,6 +25,7 @@ import { getFieldsToSign } from '../getFieldsToSign.js'
import { getLoginOptions } from '../getLoginOptions.js' import { getLoginOptions } from '../getLoginOptions.js'
import { isUserLocked } from '../isUserLocked.js' import { isUserLocked } from '../isUserLocked.js'
import { jwtSign } from '../jwt.js' import { jwtSign } from '../jwt.js'
import { removeExpiredSessions } from '../removeExpiredSessions.js'
import { authenticateLocalStrategy } from '../strategies/local/authenticate.js' import { authenticateLocalStrategy } from '../strategies/local/authenticate.js'
import { incrementLoginAttempts } from '../strategies/local/incrementLoginAttempts.js' import { incrementLoginAttempts } from '../strategies/local/incrementLoginAttempts.js'
import { resetLoginAttempts } from '../strategies/local/resetLoginAttempts.js' import { resetLoginAttempts } from '../strategies/local/resetLoginAttempts.js'
@@ -114,7 +117,6 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
// Login // Login
// ///////////////////////////////////// // /////////////////////////////////////
let user
const { email: unsanitizedEmail, password } = data const { email: unsanitizedEmail, password } = data
const loginWithUsername = collectionConfig.auth.loginWithUsername const loginWithUsername = collectionConfig.auth.loginWithUsername
@@ -204,7 +206,7 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
whereConstraint = usernameConstraint whereConstraint = usernameConstraint
} }
user = await payload.db.findOne<any>({ let user = await payload.db.findOne<any>({
collection: collectionConfig.slug, collection: collectionConfig.slug,
req, req,
where: whereConstraint, where: whereConstraint,
@@ -239,6 +241,41 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
throw new AuthenticationError(req.t) throw new AuthenticationError(req.t)
} }
const fieldsToSignArgs: Parameters<typeof getFieldsToSign>[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) { if (maxLoginAttemptsEnabled) {
await resetLoginAttempts({ await resetLoginAttempts({
collection: collectionConfig, collection: collectionConfig,
@@ -248,12 +285,6 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
}) })
} }
const fieldsToSign = getFieldsToSign({
collectionConfig,
email: sanitizedEmail!,
user,
})
// ///////////////////////////////////// // /////////////////////////////////////
// beforeLogin - Collection // beforeLogin - Collection
// ///////////////////////////////////// // /////////////////////////////////////

View File

@@ -6,6 +6,7 @@ import type { PayloadRequest } from '../../types/index.js'
import { APIError } from '../../errors/index.js' import { APIError } from '../../errors/index.js'
export type Arguments = { export type Arguments = {
allSessions?: boolean
collection: Collection collection: Collection
req: PayloadRequest req: PayloadRequest
} }
@@ -13,6 +14,7 @@ export type Arguments = {
export const logoutOperation = async (incomingArgs: Arguments): Promise<boolean> => { export const logoutOperation = async (incomingArgs: Arguments): Promise<boolean> => {
let args = incomingArgs let args = incomingArgs
const { const {
allSessions,
collection: { config: collectionConfig }, collection: { config: collectionConfig },
req: { user }, req: { user },
req, req,
@@ -36,5 +38,41 @@ export const logoutOperation = async (incomingArgs: Arguments): Promise<boolean>
} }
} }
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 return true
} }

View File

@@ -1,4 +1,5 @@
import url from 'url' import url from 'url'
import { v4 as uuid } from 'uuid'
import type { Collection } from '../../collections/config/types.js' import type { Collection } from '../../collections/config/types.js'
import type { Document, PayloadRequest } from '../../types/index.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 { killTransaction } from '../../utilities/killTransaction.js'
import { getFieldsToSign } from '../getFieldsToSign.js' import { getFieldsToSign } from '../getFieldsToSign.js'
import { jwtSign } from '../jwt.js' import { jwtSign } from '../jwt.js'
import { removeExpiredSessions } from '../removeExpiredSessions.js'
export type Result = { export type Result = {
exp: number exp: number
@@ -79,6 +81,31 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise<Result>
req: args.req, 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) { if (user) {
user.collection = args.req.user.collection user.collection = args.req.user.collection
user._strategy = args.req.user._strategy user._strategy = args.req.user._strategy
@@ -103,6 +130,7 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise<Result>
const fieldsToSign = getFieldsToSign({ const fieldsToSign = getFieldsToSign({
collectionConfig, collectionConfig,
email: user?.email as string, email: user?.email as string,
sid,
user: args?.req?.user, user: args?.req?.user,
}) })

View File

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

View File

@@ -8,6 +8,7 @@ import { extractJWT } from '../extractJWT.js'
type JWTToken = { type JWTToken = {
collection: string collection: string
id: string id: string
sid?: string
} }
async function autoLogin({ async function autoLogin({
@@ -100,6 +101,18 @@ export const JWTAuthentication: AuthStrategyFunction = async ({
})) as AuthStrategyResult['user'] })) as AuthStrategyResult['user']
if (user && (!collection!.config.auth.verify || user._verified)) { 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.collection = collection!.config.slug
user._strategy = strategyName user._strategy = strategyName
return { return {

View File

@@ -118,6 +118,7 @@ type BaseUser = {
collection: string collection: string
email?: string email?: string
id: number | string id: number | string
sessions?: Array<UserSession>
username?: string username?: string
} }
@@ -133,6 +134,7 @@ export type ClientUser = {
[key: string]: any [key: string]: any
} & BaseUser } & BaseUser
export type UserSession = { createdAt: Date | string; expiresAt: Date | string; id: string }
type GenerateVerifyEmailHTML<TUser = any> = (args: { type GenerateVerifyEmailHTML<TUser = any> = (args: {
req: PayloadRequest req: PayloadRequest
token: string token: string
@@ -277,6 +279,13 @@ export interface IncomingAuthType {
* @link https://payloadcms.com/docs/authentication/api-keys * @link https://payloadcms.com/docs/authentication/api-keys
*/ */
useAPIKey?: boolean 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. * 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 * @link https://payloadcms.com/docs/authentication/email#email-verification

View File

@@ -127,6 +127,7 @@ export const authDefaults: IncomingAuthType = {
loginWithUsername: false, loginWithUsername: false,
maxLoginAttempts: 5, maxLoginAttempts: 5,
tokenExpiration: 7200, tokenExpiration: 7200,
useSessions: true,
verify: false, verify: false,
} }
@@ -142,6 +143,7 @@ export const addDefaultsToAuthConfig = (auth: IncomingAuthType): IncomingAuthTyp
auth.loginWithUsername = auth.loginWithUsername ?? false auth.loginWithUsername = auth.loginWithUsername ?? false
auth.maxLoginAttempts = auth.maxLoginAttempts ?? 5 auth.maxLoginAttempts = auth.maxLoginAttempts ?? 5
auth.tokenExpiration = auth.tokenExpiration ?? 7200 auth.tokenExpiration = auth.tokenExpiration ?? 7200
auth.useSessions = auth.useSessions ?? true
auth.verify = auth.verify ?? false auth.verify = auth.verify ?? false
auth.strategies = auth.strategies ?? [] auth.strategies = auth.strategies ?? []

View File

@@ -465,7 +465,7 @@ export const promise = async ({
}) })
} }
}) })
} else { } else if (field.hidden !== true || showHiddenFields === true) {
siblingDoc[field.name] = [] siblingDoc[field.name] = []
} }
break break
@@ -570,7 +570,7 @@ export const promise = async ({
}) })
} }
}) })
} else { } else if (field.hidden !== true || showHiddenFields === true) {
siblingDoc[field.name] = [] siblingDoc[field.name] = []
} }

View File

@@ -138,6 +138,18 @@ import { getLogger } from './utilities/logger.js'
import { serverInit as serverInitTelemetry } from './utilities/telemetry/events/serverInit.js' import { serverInit as serverInitTelemetry } from './utilities/telemetry/events/serverInit.js'
import { traverseFields } from './utilities/traverseFields.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 { executeAccess } from './auth/executeAccess.js'
export { executeAuthStrategies } from './auth/executeAuthStrategies.js' export { executeAuthStrategies } from './auth/executeAuthStrategies.js'
export { extractAccessFromPermission } from './auth/extractAccessFromPermission.js' export { extractAccessFromPermission } from './auth/extractAccessFromPermission.js'

View File

@@ -58,27 +58,27 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
"@playwright/test": "1.50.0",
"@tailwindcss/typography": "^0.5.13", "@tailwindcss/typography": "^0.5.13",
"@testing-library/react": "16.3.0",
"@types/escape-html": "^1.0.2", "@types/escape-html": "^1.0.2",
"@types/node": "22.5.4", "@types/node": "22.5.4",
"@types/react": "19.1.0", "@types/react": "19.1.0",
"@types/react-dom": "19.1.2", "@types/react-dom": "19.1.2",
"@vitejs/plugin-react": "4.5.2",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"copyfiles": "^2.4.1", "copyfiles": "^2.4.1",
"eslint": "^9.16.0", "eslint": "^9.16.0",
"eslint-config-next": "15.3.0", "eslint-config-next": "15.3.0",
"jsdom": "26.1.0",
"playwright": "1.50.0",
"playwright-core": "1.50.0",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"tailwindcss": "^3.4.3", "tailwindcss": "^3.4.3",
"@playwright/test": "1.50.0", "typescript": "5.7.3",
"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",
"vite-tsconfig-paths": "5.1.4", "vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.3", "vitest": "3.2.3"
"typescript": "5.7.3"
}, },
"engines": { "engines": {
"node": "^18.20.2 || >=20.9.0", "node": "^18.20.2 || >=20.9.0",

View File

@@ -25,29 +25,47 @@ import { default as default_1a7510af427896d367a49dbf838d2de6 } from '@/component
import { default as default_8a7ab0eb7ab5c511aba12e68480bfe5e } from '@/components/BeforeLogin' import { default as default_8a7ab0eb7ab5c511aba12e68480bfe5e } from '@/components/BeforeLogin'
export const importMap = { export const importMap = {
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e, '@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell':
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e, RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e, '@payloadcms/richtext-lexical/rsc#RscEntryLexicalField':
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient": FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, '@payloadcms/richtext-lexical/rsc#LexicalDiffComponent':
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, '@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient':
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, '@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient':
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, '@payloadcms/richtext-lexical/client#HeadingFeatureClient':
"@payloadcms/plugin-seo/client#OverviewComponent": OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860, HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/plugin-seo/client#MetaTitleComponent": MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860, '@payloadcms/richtext-lexical/client#ParagraphFeatureClient':
"@payloadcms/plugin-seo/client#MetaImageComponent": MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860, ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/plugin-seo/client#MetaDescriptionComponent": MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860, '@payloadcms/richtext-lexical/client#UnderlineFeatureClient':
"@payloadcms/plugin-seo/client#PreviewComponent": PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860, UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@/fields/slug/SlugComponent#SlugComponent": SlugComponent_92cc057d0a2abb4f6cf0307edf59f986, '@payloadcms/richtext-lexical/client#BoldFeatureClient':
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BlocksFeatureClient": BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, '@payloadcms/richtext-lexical/client#ItalicFeatureClient':
"@payloadcms/plugin-search/client#LinkToDoc": LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634, ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/plugin-search/client#ReindexButton": ReindexButton_aead06e4cbf6b2620c5c51c9ab283634, '@payloadcms/richtext-lexical/client#LinkFeatureClient':
"@/Header/RowLabel#RowLabel": RowLabel_ec255a65fa6fa8d1faeb09cf35284224, LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@/Footer/RowLabel#RowLabel": RowLabel_1f6ff6ff633e3695d348f4f3c58f1466, '@payloadcms/plugin-seo/client#OverviewComponent':
"@/components/BeforeDashboard#default": default_1a7510af427896d367a49dbf838d2de6, OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
"@/components/BeforeLogin#default": default_8a7ab0eb7ab5c511aba12e68480bfe5e '@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,
} }

View File

@@ -95,6 +95,7 @@ export interface Config {
'auth-collection': AuthCollection; 'auth-collection': AuthCollection;
'payload-locked-documents': PayloadLockedDocument; 'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference; 'payload-preferences': PayloadPreference;
'payload-sessions': PayloadSession;
'payload-migrations': PayloadMigration; 'payload-migrations': PayloadMigration;
}; };
collectionsJoins: {}; collectionsJoins: {};
@@ -125,6 +126,7 @@ export interface Config {
'auth-collection': AuthCollectionSelect<false> | AuthCollectionSelect<true>; 'auth-collection': AuthCollectionSelect<false> | AuthCollectionSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>; 'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>; 'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-sessions': PayloadSessionsSelect<false> | PayloadSessionsSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>; 'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
}; };
db: { db: {
@@ -891,6 +893,26 @@ export interface PayloadPreference {
updatedAt: string; updatedAt: string;
createdAt: 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 * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations". * via the `definition` "payload-migrations".
@@ -1295,6 +1317,17 @@ export interface PayloadPreferencesSelect<T extends boolean = true> {
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-sessions_select".
*/
export interface PayloadSessionsSelect<T extends boolean = true> {
session?: T;
expiration?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select". * via the `definition` "payload-migrations_select".

View File

@@ -1,3 +1,4 @@
/* eslint-disable jest/no-conditional-in-test */
import type { import type {
BasePayload, BasePayload,
EmailFieldValidation, EmailFieldValidation,
@@ -737,7 +738,7 @@ describe('Auth', () => {
it('should retain fields when auth.disableLocalStrategy.enableFields is true', () => { it('should retain fields when auth.disableLocalStrategy.enableFields is true', () => {
const authFields = payload.collections[partialDisableLocalStrategiesSlug].config.fields const authFields = payload.collections[partialDisableLocalStrategiesSlug].config.fields
// eslint-disable-next-line jest/no-conditional-in-test
.filter((field) => 'name' in field && field.name) .filter((field) => 'name' in field && field.name)
.map((field) => (field as FieldAffectingData).name) .map((field) => (field as FieldAffectingData).name)
@@ -751,6 +752,7 @@ describe('Auth', () => {
'hash', 'hash',
'loginAttempts', 'loginAttempts',
'lockUntil', 'lockUntil',
'sessions',
]) ])
}) })
@@ -1051,4 +1053,215 @@ describe('Auth', () => {
expect(emailValidation('user,name@example.com', mockContext)).toBe('validation:emailAddress') 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<User>({
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<User>({
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<User>({
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<User>({
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<User>({
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<User>({
collection: slug,
where: {
email: {
equals: devUser.email,
},
},
})
expect(user2.docs[0]?.sessions).toHaveLength(1)
})
})
}) })

View File

@@ -248,6 +248,11 @@ export interface User {
hash?: string | null; hash?: string | null;
loginAttempts?: number | null; loginAttempts?: number | null;
lockUntil?: string | null; lockUntil?: string | null;
sessions: {
id: string;
createdAt?: string | null;
expiresAt: string;
}[];
password?: string | null; password?: string | null;
} }
/** /**
@@ -265,6 +270,11 @@ export interface PartialDisableLocalStrategy {
hash?: string | null; hash?: string | null;
loginAttempts?: number | null; loginAttempts?: number | null;
lockUntil?: string | null; lockUntil?: string | null;
sessions: {
id: string;
createdAt?: string | null;
expiresAt: string;
}[];
password?: string | null; password?: string | null;
} }
/** /**
@@ -306,6 +316,11 @@ export interface PublicUser {
_verificationToken?: string | null; _verificationToken?: string | null;
loginAttempts?: number | null; loginAttempts?: number | null;
lockUntil?: string | null; lockUntil?: string | null;
sessions: {
id: string;
createdAt?: string | null;
expiresAt: string;
}[];
password?: string | null; password?: string | null;
} }
/** /**
@@ -471,6 +486,13 @@ export interface UsersSelect<T extends boolean = true> {
hash?: T; hash?: T;
loginAttempts?: T; loginAttempts?: T;
lockUntil?: T; lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
} }
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
@@ -486,6 +508,13 @@ export interface PartialDisableLocalStrategiesSelect<T extends boolean = true> {
hash?: T; hash?: T;
loginAttempts?: T; loginAttempts?: T;
lockUntil?: T; lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
} }
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
@@ -523,6 +552,13 @@ export interface PublicUsersSelect<T extends boolean = true> {
_verificationToken?: T; _verificationToken?: T;
loginAttempts?: T; loginAttempts?: T;
lockUntil?: T; lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
} }
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema

View File

@@ -96,5 +96,8 @@
"ts-essentials": "10.0.3", "ts-essentials": "10.0.3",
"typescript": "5.7.3", "typescript": "5.7.3",
"uuid": "10.0.0" "uuid": "10.0.0"
},
"pnpm": {
"neverBuiltDependencies": []
} }
} }

View File

@@ -16,7 +16,6 @@ export async function loginFunction({ email, password }: LoginArgs) {
email, email,
password, password,
}) })
return result
} catch (error) { } catch (error) {
throw new Error(`Login failed: ${error instanceof Error ? error.message : 'Unknown error'}`) throw new Error(`Login failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
} }

View File

@@ -7,7 +7,6 @@ import config from '../config.js'
export async function refreshFunction() { export async function refreshFunction() {
try { try {
return await refresh({ return await refresh({
collection: 'users', // update this to your collection slug
config, config,
}) })
} catch (error) { } catch (error) {