Skip to content

Commit 26d709d

Browse files
jmikrutdenolfejessrynkarJarrodMFleschr1tsuu
authored
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>
1 parent c8b7214 commit 26d709d

File tree

29 files changed

+610
-81
lines changed

29 files changed

+610
-81
lines changed

docs/authentication/operations.mdx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -180,19 +180,22 @@ As Payload sets HTTP-only cookies, logging out cannot be done by just removing a
180180
**Example REST API logout**:
181181

182182
```ts
183-
const res = await fetch('http://localhost:3000/api/[collection-slug]/logout', {
184-
method: 'POST',
185-
headers: {
186-
'Content-Type': 'application/json',
183+
const res = await fetch(
184+
'http://localhost:3000/api/[collection-slug]/logout?allSessions=false',
185+
{
186+
method: 'POST',
187+
headers: {
188+
'Content-Type': 'application/json',
189+
},
187190
},
188-
})
191+
)
189192
```
190193

191194
**Example GraphQL Mutation**:
192195

193196
```
194197
mutation {
195-
logout[collection-singular-label]
198+
logoutUser(allSessions: false)
196199
}
197200
```
198201

@@ -203,6 +206,10 @@ mutation {
203206
docs](../local-api/server-functions#reusable-payload-server-functions).
204207
</Banner>
205208

209+
#### Logging out with sessions enabled
210+
211+
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.
212+
206213
## Refresh
207214

208215
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.

docs/authentication/overview.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ The following options are available:
9191
| **`strategies`** | Advanced - an array of custom authentication strategies to extend this collection's authentication with. [More details](./custom-strategies). |
9292
| **`tokenExpiration`** | How long (in seconds) to keep the user logged in. JWTs and HTTP-only cookies will both expire at the same time. |
9393
| **`useAPIKey`** | Payload Authentication provides for API keys to be set on each user within an Authentication-enabled Collection. [More details](./api-keys). |
94+
| **`useSessions`** | True by default. Set to `false` to use stateless JWTs for authentication instead of sessions. |
9495
| **`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). |
9596

9697
### Login With Username

docs/local-api/server-functions.mdx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -393,15 +393,15 @@ export default function LoginForm() {
393393

394394
### Logout
395395

396-
Logs out the current user by clearing the authentication cookie.
396+
Logs out the current user by clearing the authentication cookie and current sessions.
397397

398398
#### Importing the `logout` function
399399

400400
```ts
401401
import { logout } from '@payloadcms/next/auth'
402402
```
403403

404-
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.
404+
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`.
405405

406406
```ts
407407
'use server'
@@ -411,7 +411,7 @@ import config from '@payload-config'
411411

412412
export async function logoutAction() {
413413
try {
414-
return await logout({ config })
414+
return await logout({ allSessions: true, config })
415415
} catch (error) {
416416
throw new Error(
417417
`Logout failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
@@ -434,7 +434,7 @@ export default function LogoutButton() {
434434

435435
### Refresh
436436

437-
Refreshes the authentication token for the logged-in user.
437+
Refreshes the authentication token and current session for the logged-in user.
438438

439439
#### Importing the `refresh` function
440440

@@ -453,7 +453,6 @@ import config from '@payload-config'
453453
export async function refreshAction() {
454454
try {
455455
return await refresh({
456-
collection: 'users', // pass your collection slug
457456
config,
458457
})
459458
} catch (error) {

docs/plugins/sentry.mdx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,7 @@ import * as Sentry from '@sentry/nextjs'
7474

7575
const config = buildConfig({
7676
collections: [Pages, Media],
77-
plugins: [
78-
sentryPlugin({ Sentry })
79-
],
77+
plugins: [sentryPlugin({ Sentry })],
8078
})
8179

8280
export default config
@@ -98,9 +96,7 @@ export default buildConfig({
9896
pool: { connectionString: process.env.DATABASE_URL },
9997
pg, // Inject the patched pg driver for Sentry instrumentation
10098
}),
101-
plugins: [
102-
sentryPlugin({ Sentry })
103-
],
99+
plugins: [sentryPlugin({ Sentry })],
104100
})
105101
```
106102

packages/graphql/src/resolvers/auth/logout.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { Context } from '../types.js'
77
export function logout(collection: Collection): any {
88
async function resolver(_, args, context: Context) {
99
const options = {
10+
allSessions: args.allSessions,
1011
collection,
1112
req: isolateObjectProperty(context.req, 'transactionID'),
1213
}

packages/graphql/src/schema/initCollections.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,9 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
487487

488488
graphqlResult.Mutation.fields[`logout${singularName}`] = {
489489
type: GraphQLString,
490+
args: {
491+
allSessions: { type: GraphQLBoolean },
492+
},
490493
resolve: logout(collection),
491494
}
492495

packages/next/src/auth/logout.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,46 @@
11
'use server'
22

3+
import type { SanitizedConfig } from 'payload'
4+
35
import { cookies as getCookies, headers as nextHeaders } from 'next/headers.js'
4-
import { getPayload } from 'payload'
6+
import { createLocalReq, getPayload, logoutOperation } from 'payload'
57

68
import { getExistingAuthToken } from '../utilities/getExistingAuthToken.js'
79

8-
export async function logout({ config }: { config: any }) {
10+
export async function logout({
11+
allSessions = false,
12+
config,
13+
}: {
14+
allSessions?: boolean
15+
config: Promise<SanitizedConfig> | SanitizedConfig
16+
}) {
917
const payload = await getPayload({ config })
1018
const headers = await nextHeaders()
11-
const result = await payload.auth({ headers })
19+
const authResult = await payload.auth({ headers })
1220

13-
if (!result.user) {
21+
if (!authResult.user) {
1422
return { message: 'User already logged out', success: true }
1523
}
1624

17-
const existingCookie = await getExistingAuthToken(payload.config.cookiePrefix)
25+
const { user } = authResult
26+
const req = await createLocalReq({ user }, payload)
27+
const collection = payload.collections[user.collection]
1828

29+
const logoutResult = await logoutOperation({
30+
allSessions,
31+
collection,
32+
req,
33+
})
34+
35+
if (!logoutResult) {
36+
return { message: 'Logout failed', success: false }
37+
}
38+
39+
const existingCookie = await getExistingAuthToken(payload.config.cookiePrefix)
1940
if (existingCookie) {
2041
const cookies = await getCookies()
2142
cookies.delete(existingCookie.name)
22-
return { message: 'User logged out successfully', success: true }
2343
}
44+
45+
return { message: 'User logged out successfully', success: true }
2446
}

packages/next/src/auth/refresh.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,33 +3,45 @@
33
import type { CollectionSlug } from 'payload'
44

55
import { headers as nextHeaders } from 'next/headers.js'
6-
import { getPayload } from 'payload'
6+
import { createLocalReq, getPayload, refreshOperation } from 'payload'
77

88
import { getExistingAuthToken } from '../utilities/getExistingAuthToken.js'
99
import { setPayloadAuthCookie } from '../utilities/setPayloadAuthCookie.js'
1010

11-
export async function refresh({ collection, config }: { collection: CollectionSlug; config: any }) {
11+
export async function refresh({ config }: { config: any }) {
1212
const payload = await getPayload({ config })
13-
const authConfig = payload.collections[collection]?.config.auth
13+
const headers = await nextHeaders()
14+
const result = await payload.auth({ headers })
1415

15-
if (!authConfig) {
16+
if (!result.user) {
17+
throw new Error('Cannot refresh token: user not authenticated')
18+
}
19+
20+
const collection: CollectionSlug | undefined = result.user.collection
21+
const collectionConfig = payload.collections[collection]
22+
23+
if (!collectionConfig?.config.auth) {
1624
throw new Error(`No auth config found for collection: ${collection}`)
1725
}
1826

19-
const { user } = await payload.auth({ headers: await nextHeaders() })
27+
const req = await createLocalReq({ user: result.user }, payload)
2028

21-
if (!user) {
22-
throw new Error('User not authenticated')
29+
const refreshResult = await refreshOperation({
30+
collection: collectionConfig,
31+
req,
32+
})
33+
34+
if (!refreshResult) {
35+
return { message: 'Token refresh failed', success: false }
2336
}
2437

2538
const existingCookie = await getExistingAuthToken(payload.config.cookiePrefix)
26-
2739
if (!existingCookie) {
28-
return { message: 'No valid token found', success: false }
40+
return { message: 'No valid token found to refresh', success: false }
2941
}
3042

3143
await setPayloadAuthCookie({
32-
authConfig,
44+
authConfig: collectionConfig.config.auth,
3345
cookiePrefix: payload.config.cookiePrefix,
3446
token: existingCookie.value,
3547
})
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { ArrayField } from '../../fields/config/types.js'
2+
3+
export const sessionsFieldConfig: ArrayField = {
4+
name: 'sessions',
5+
type: 'array',
6+
access: {
7+
read: ({ doc, req: { user } }) => {
8+
return user?.id === doc?.id
9+
},
10+
update: () => false,
11+
},
12+
admin: {
13+
disabled: true,
14+
},
15+
fields: [
16+
{
17+
name: 'id',
18+
type: 'text',
19+
required: true,
20+
},
21+
{
22+
name: 'createdAt',
23+
type: 'date',
24+
defaultValue: () => new Date(),
25+
},
26+
{
27+
name: 'expiresAt',
28+
type: 'date',
29+
required: true,
30+
},
31+
],
32+
}

packages/payload/src/auth/endpoints/logout.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import { logoutOperation } from '../operations/logout.js'
99

1010
export const logoutHandler: PayloadHandler = async (req) => {
1111
const collection = getRequestCollection(req)
12-
const { t } = req
12+
const { searchParams, t } = req
13+
1314
const result = await logoutOperation({
15+
allSessions: searchParams.get('allSessions') === 'true',
1416
collection,
1517
req,
1618
})

0 commit comments

Comments
 (0)