@dotpress/auth 1.1.2 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,9 +13,12 @@ You can use Prisma or any other ORM and easily connect it using the plugin DataP
13
13
  ### 1. Install
14
14
 
15
15
  ```bash
16
- npm install @dotpress/auth
16
+ npm install @dotpress/auth cookie-parser
17
+ npm install --save-dev @types/cookie-parser
17
18
  ```
18
19
 
20
+ **Note:** `cookie-parser` is required as a peer dependency if you want to use cookie-based authentication.
21
+
19
22
  ### 2. Register your plugin
20
23
 
21
24
  ```ts
@@ -49,14 +52,48 @@ The plugin will check and parse bearer token from Authorization header and will
49
52
 
50
53
  ## Configuration object properties
51
54
 
52
- - keys.publicKey (string, required): public RSA key to validate JWT tokens
53
- - keys.secretKey (string, required): private RSA key to generate JWT tokens
54
- - dataProvider (DataProvider, required): set of functions to store and retrieve data (see below)
55
- - accessTokenTTL (optional, default: 3600): Time-to-live, in seconds, for access tokens
56
- - refreshTokenTTLInDays (optional, default: 14): Time-to-live, in days, for refresh tokens
57
- - issuer (string, optional but recommended): Domain of your app or auth service
58
- - onLoginFailed (optional): Event handler triggered on failed logins
59
- - onLoginSuccess (optional): Event handler triggered on success logins
55
+ - **keys.publicKey** (string, required): public RSA key to validate JWT tokens
56
+ - **keys.secretKey** (string, required): private RSA key to generate JWT tokens
57
+ - **dataProvider** (DataProvider, required): set of functions to store and retrieve data (see below)
58
+ - **accessTokenTTL** (optional, default: 3600): Time-to-live, in seconds, for access tokens
59
+ - **refreshTokenTTLInDays** (optional, default: 14): Time-to-live, in days, for refresh tokens
60
+ - **issuer** (string, optional but recommended): Domain of your app or auth service
61
+ - **cookies** (CookieSettings, optional): Cookie configuration for web authentication
62
+ - **onLoginFailed** (optional): Event handler triggered on failed logins
63
+ - **onLoginSuccess** (optional): Event handler triggered on success logins
64
+
65
+ ### Cookie Settings
66
+
67
+ The `cookies` object supports the following properties:
68
+
69
+ ```ts
70
+ type CookieSettings = {
71
+ enabled?: boolean // Enable cookie mode (default: false)
72
+ httpOnly?: boolean // HttpOnly flag (default: true)
73
+ secure?: boolean // Secure flag (default: true in production)
74
+ sameSite?: 'strict' | 'lax' | 'none' // SameSite policy (default: 'lax')
75
+ domain?: string // Cookie domain for cross-subdomain auth
76
+ path?: string // Cookie path (default: '/')
77
+ accessTokenName?: string // Access token cookie name (default: 'dotpress_access_token')
78
+ refreshTokenName?: string // Refresh token cookie name (default: 'dotpress_refresh_token')
79
+ accessTokenInCookie?: boolean // Store access token in cookie (default: false)
80
+ }
81
+ ```
82
+
83
+ **Example configuration for cross-subdomain authentication:**
84
+
85
+ ```ts
86
+ const authPlugin = configurePlugin({
87
+ keys: { /* ... */ },
88
+ dataProvider: { /* ... */ },
89
+ cookies: {
90
+ enabled: true,
91
+ domain: '.myapp.com', // Works for app.myapp.com, www.myapp.com, etc.
92
+ secure: true,
93
+ sameSite: 'lax',
94
+ }
95
+ })
96
+ ```
60
97
 
61
98
  ### DataProvider type
62
99
 
@@ -92,55 +129,194 @@ type RefreshTokenData = {
92
129
  }
93
130
  ```
94
131
 
132
+ ## Authentication Modes
133
+
134
+ This plugin supports two authentication modes that can coexist:
135
+
136
+ ### Body Mode (Default)
137
+ Tokens are sent in request body. Suitable for mobile apps (Expo/React Native) and desktop apps (Electron).
138
+
139
+ ### Cookie Mode
140
+ Tokens are stored in httpOnly cookies. Suitable for web applications (SPAs, SSR) for enhanced security against XSS attacks.
141
+
142
+ The plugin automatically detects which mode to use based on the request.
143
+
144
+ ---
145
+
95
146
  ## Endpoints
96
147
 
97
148
  ### `POST /auth/login`
98
149
 
99
150
  Authenticate a user and generate tokens.
100
151
 
101
- Request Body:
102
- - username: string
103
- - password: string
104
- - client: optional user agent string
152
+ **Request Body:**
153
+ - `username` (string, required): Username
154
+ - `password` (string, required): Password
155
+ - `client` (string, optional): User agent string for tracking
156
+ - `aud` (string, optional): Audience for role-based access control
157
+ - `mode` (enum: 'body' | 'cookie', optional): Authentication mode
158
+
159
+ **Response:**
160
+ ```json
161
+ {
162
+ "userId": "usr-123",
163
+ "accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
164
+ "refreshToken": "3f7b2a8c1e...",
165
+ "expiresIn": 3600,
166
+ "expiresAt": "2025-11-02T15:30:00.000Z"
167
+ }
168
+ ```
105
169
 
106
- Response:
107
- - userId: string
108
- - accessToken: string
109
- - refreshToken: string
170
+ **Behavior:**
171
+ - If `mode: 'cookie'` is provided, tokens are also set as httpOnly cookies
172
+ - `accessToken` and `refreshToken` are always returned in the response body for flexibility
173
+ - Returns 401 on failed login
110
174
 
111
- Returns 401 on failed login.
175
+ **Cookie Mode Example:**
176
+
177
+ ```ts
178
+ // Client login request with cookie mode
179
+ const response = await fetch('/auth/login', {
180
+ method: 'POST',
181
+ headers: { 'Content-Type': 'application/json' },
182
+ credentials: 'include', // Important for cookies!
183
+ body: JSON.stringify({
184
+ username: 'john@example.com',
185
+ password: 'secret123',
186
+ mode: 'cookie'
187
+ })
188
+ })
189
+ ```
112
190
 
113
191
  ### `POST /auth/refresh`
114
192
 
115
- Refresh tokens using previous token pair.
193
+ Refresh tokens using previous token pair. The plugin automatically detects the authentication mode.
116
194
 
117
- Request Body:
118
- - accessToken: Previous access token, can be expired or still valid
119
- - refreshToken: Refresh token, must be valid, not used, not revoked
195
+ **Request Body (Body Mode):**
196
+ ```json
197
+ {
198
+ "accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
199
+ "refreshToken": "3f7b2a8c1e..."
200
+ }
201
+ ```
120
202
 
121
- Response:
122
- - userId: string
123
- - accessToken: string
124
- - refreshToken: string
203
+ **Request Body (Cookie Mode):**
204
+ ```json
205
+ {}
206
+ ```
207
+ *Tokens are automatically extracted from cookies.*
208
+
209
+ **Response:**
210
+ ```json
211
+ {
212
+ "userId": "usr-123",
213
+ "accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
214
+ "refreshToken": "9a1b3c5d7f...",
215
+ "expiresIn": 3600,
216
+ "expiresAt": "2025-11-02T16:30:00.000Z"
217
+ }
218
+ ```
125
219
 
126
- Returns 401 if tokens are not valid.
220
+ **Behavior:**
221
+ - Automatically detects mode: checks cookies first, then falls back to body
222
+ - If cookies are present, new tokens are automatically set as cookies
223
+ - Old refresh token is marked as used (one-time use with rotation)
224
+ - Returns 401 if tokens are not valid
225
+
226
+ **Error Codes:**
227
+ - `NO_TOKENS_PROVIDED`: No tokens found in cookies or body
228
+ - `INVALID_REFRESH_TOKEN`: Refresh token is invalid or already used
229
+ - `EXPIRED_REFRESH_TOKEN`: Refresh token has expired
230
+
231
+ ### `GET /auth/me`
232
+
233
+ Get current authenticated user.
234
+
235
+ **Request:**
236
+ No body required. Token is extracted from:
237
+ 1. `Authorization: Bearer <token>` header (priority)
238
+ 2. Cookie (if cookie mode is enabled and `accessTokenInCookie: true`)
239
+
240
+ **Response:**
241
+ ```json
242
+ {
243
+ "id": "usr-123",
244
+ "username": "john@example.com",
245
+ "email": "john@example.com",
246
+ // ... other user fields
247
+ }
248
+ ```
249
+
250
+ Returns 401 if user is not authenticated.
127
251
 
128
252
  ### `POST /auth/logout`
129
253
 
130
254
  Revoke tokens for current authenticated user.
131
255
 
132
- Response:
133
- - 200 OK on success
134
- - 401 if user is not authenticated
256
+ **Response:**
257
+ ```json
258
+ {
259
+ "success": true
260
+ }
261
+ ```
135
262
 
136
- ### `GET /auth/me`
263
+ **Behavior:**
264
+ - Revokes all refresh tokens for the user in the database
265
+ - Clears authentication cookies if cookie mode was used
266
+ - Returns 401 if user is not authenticated
267
+
268
+
269
+ ## Multi-Platform Support
270
+
271
+ The plugin is designed to support multiple client types simultaneously:
137
272
 
138
- Get current authenticated user
273
+ | Client Type | Recommended Mode | Configuration |
274
+ |-------------|-----------------|---------------|
275
+ | React SPA | Cookie | `mode: 'cookie'` in login |
276
+ | Next.js SSR | Cookie | `mode: 'cookie'` + `domain: '.yourapp.com'` |
277
+ | React Native / Expo | Body | No `mode` parameter (default) |
278
+ | Electron | Body | No `mode` parameter (default) |
139
279
 
140
- Response:
141
- - User object if user is authenticated
142
- - 401 if user is not authenticated
280
+ **Cross-subdomain authentication example:**
143
281
 
282
+ ```ts
283
+ // API configuration (api.myapp.com)
284
+ const authPlugin = configurePlugin({
285
+ cookies: {
286
+ enabled: true,
287
+ domain: '.myapp.com', // Share cookies across subdomains
288
+ secure: true,
289
+ sameSite: 'lax',
290
+ },
291
+ // ... other settings
292
+ })
293
+
294
+ // CORS configuration
295
+ const app = await createApp({
296
+ cors: {
297
+ origin: [
298
+ 'https://app.myapp.com',
299
+ 'https://www.myapp.com',
300
+ ],
301
+ credentials: true, // Required for cookies!
302
+ },
303
+ plugins: [authPlugin]
304
+ })
305
+ ```
306
+
307
+ **Client-side fetch configuration:**
308
+
309
+ ```ts
310
+ // Always include credentials for cookie mode
311
+ fetch('https://api.myapp.com/auth/login', {
312
+ method: 'POST',
313
+ credentials: 'include', // Essential for cookies
314
+ headers: { 'Content-Type': 'application/json' },
315
+ body: JSON.stringify({ username, password, mode: 'cookie' })
316
+ })
317
+ ```
318
+
319
+ ---
144
320
 
145
321
  ## Prisma schema examples
146
322
 
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,386 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import request from 'supertest';
3
+ import bcrypt from 'bcryptjs';
4
+ import { $clearRoutes, createApp } from 'dotpress';
5
+ import * as tokenFns from '../../lib/tokens.js';
6
+ import { configurePlugin } from '../../index.js';
7
+ describe('Cookie Mode Authentication', () => {
8
+ beforeEach(() => {
9
+ vi.resetAllMocks();
10
+ $clearRoutes();
11
+ });
12
+ const dataProvider = {
13
+ storeRefreshToken: vi.fn(),
14
+ findRefreshToken: vi.fn(),
15
+ markRefreshTokenAsUsed: vi.fn(),
16
+ revokeTokens: vi.fn(),
17
+ findUserById: vi.fn(),
18
+ findUserIdentifiers: vi.fn(),
19
+ };
20
+ const authPlugin = configurePlugin({
21
+ keys: {
22
+ secretKey: '',
23
+ publicKey: '',
24
+ },
25
+ dataProvider,
26
+ cookies: {
27
+ enabled: true,
28
+ secure: false, // For testing
29
+ httpOnly: true,
30
+ sameSite: 'lax',
31
+ },
32
+ });
33
+ describe('POST /auth/login with cookie mode', () => {
34
+ it('should set cookies when mode is "cookie"', async () => {
35
+ const pwdHash = await bcrypt.hash('123456', 10);
36
+ vi.mocked(dataProvider.findUserIdentifiers).mockResolvedValue({
37
+ userId: '101',
38
+ passwordHash: pwdHash,
39
+ });
40
+ vi.spyOn(tokenFns, 'generateTokensForUser').mockResolvedValue({
41
+ accessToken: 'acc-111222333',
42
+ refreshToken: 'ref-999888777',
43
+ });
44
+ const app = await createApp({
45
+ plugins: [authPlugin],
46
+ });
47
+ const res = await request(app).post('/auth/login').send({
48
+ username: 'JaneDoe',
49
+ password: '123456',
50
+ mode: 'cookie',
51
+ });
52
+ expect(res.status).toBe(200);
53
+ // Check response body
54
+ expect(res.body).toMatchObject({
55
+ userId: '101',
56
+ accessToken: 'acc-111222333',
57
+ refreshToken: 'ref-999888777',
58
+ expiresIn: expect.any(Number),
59
+ expiresAt: expect.any(String),
60
+ });
61
+ // Check cookies are set
62
+ expect(res.headers['set-cookie']).toBeDefined();
63
+ const cookies = Array.isArray(res.headers['set-cookie'])
64
+ ? res.headers['set-cookie']
65
+ : [res.headers['set-cookie']];
66
+ // Check refresh token cookie
67
+ const refreshCookie = cookies.find((c) => c.startsWith('dotpress_refresh_token='));
68
+ expect(refreshCookie).toBeDefined();
69
+ expect(refreshCookie).toContain('HttpOnly');
70
+ expect(refreshCookie).toContain('Path=/');
71
+ expect(refreshCookie).toContain('SameSite=Lax');
72
+ // Access token should NOT be in cookie by default
73
+ const accessCookie = cookies.find((c) => c.startsWith('dotpress_access_token='));
74
+ expect(accessCookie).toBeUndefined();
75
+ });
76
+ it('should set access token cookie when accessTokenInCookie is true', async () => {
77
+ const pwdHash = await bcrypt.hash('123456', 10);
78
+ vi.mocked(dataProvider.findUserIdentifiers).mockResolvedValue({
79
+ userId: '101',
80
+ passwordHash: pwdHash,
81
+ });
82
+ vi.spyOn(tokenFns, 'generateTokensForUser').mockResolvedValue({
83
+ accessToken: 'acc-111222333',
84
+ refreshToken: 'ref-999888777',
85
+ });
86
+ const authPluginWithAccessCookie = configurePlugin({
87
+ keys: {
88
+ secretKey: '',
89
+ publicKey: '',
90
+ },
91
+ dataProvider,
92
+ cookies: {
93
+ enabled: true,
94
+ secure: false,
95
+ accessTokenInCookie: true, // Enable access token in cookie
96
+ },
97
+ });
98
+ const app = await createApp({
99
+ plugins: [authPluginWithAccessCookie],
100
+ });
101
+ const res = await request(app).post('/auth/login').send({
102
+ username: 'JaneDoe',
103
+ password: '123456',
104
+ mode: 'cookie',
105
+ });
106
+ expect(res.status).toBe(200);
107
+ const cookies = Array.isArray(res.headers['set-cookie'])
108
+ ? res.headers['set-cookie']
109
+ : [res.headers['set-cookie']];
110
+ // Both tokens should be in cookies
111
+ const refreshCookie = cookies.find((c) => c.startsWith('dotpress_refresh_token='));
112
+ const accessCookie = cookies.find((c) => c.startsWith('dotpress_access_token='));
113
+ expect(refreshCookie).toBeDefined();
114
+ expect(accessCookie).toBeDefined();
115
+ });
116
+ it('should NOT set cookies when mode is "body"', async () => {
117
+ const pwdHash = await bcrypt.hash('123456', 10);
118
+ vi.mocked(dataProvider.findUserIdentifiers).mockResolvedValue({
119
+ userId: '101',
120
+ passwordHash: pwdHash,
121
+ });
122
+ vi.spyOn(tokenFns, 'generateTokensForUser').mockResolvedValue({
123
+ accessToken: 'acc-111222333',
124
+ refreshToken: 'ref-999888777',
125
+ });
126
+ const app = await createApp({
127
+ plugins: [authPlugin],
128
+ });
129
+ const res = await request(app).post('/auth/login').send({
130
+ username: 'JaneDoe',
131
+ password: '123456',
132
+ mode: 'body',
133
+ });
134
+ expect(res.status).toBe(200);
135
+ expect(res.body.accessToken).toBe('acc-111222333');
136
+ // No cookies should be set
137
+ expect(res.headers['set-cookie']).toBeUndefined();
138
+ });
139
+ it('should NOT set cookies when mode is not specified (default)', async () => {
140
+ const pwdHash = await bcrypt.hash('123456', 10);
141
+ vi.mocked(dataProvider.findUserIdentifiers).mockResolvedValue({
142
+ userId: '101',
143
+ passwordHash: pwdHash,
144
+ });
145
+ vi.spyOn(tokenFns, 'generateTokensForUser').mockResolvedValue({
146
+ accessToken: 'acc-111222333',
147
+ refreshToken: 'ref-999888777',
148
+ });
149
+ const app = await createApp({
150
+ plugins: [authPlugin],
151
+ });
152
+ const res = await request(app).post('/auth/login').send({
153
+ username: 'JaneDoe',
154
+ password: '123456',
155
+ });
156
+ expect(res.status).toBe(200);
157
+ // No cookies should be set (backward compatibility)
158
+ expect(res.headers['set-cookie']).toBeUndefined();
159
+ });
160
+ });
161
+ describe('POST /auth/refresh with cookies', () => {
162
+ it('should refresh tokens using cookies', async () => {
163
+ vi.spyOn(tokenFns, 'generateTokensFromRefreshToken').mockResolvedValue({
164
+ userId: '102',
165
+ accessToken: 'new-acc-token',
166
+ refreshToken: 'new-ref-token',
167
+ });
168
+ const app = await createApp({
169
+ plugins: [authPlugin],
170
+ });
171
+ // Simulate request with cookies
172
+ const res = await request(app)
173
+ .post('/auth/refresh')
174
+ .set('Cookie', [
175
+ 'dotpress_refresh_token=ref-999888777',
176
+ 'dotpress_access_token=acc-111222333',
177
+ ])
178
+ .send({});
179
+ expect(res.status).toBe(200);
180
+ expect(res.body).toMatchObject({
181
+ userId: '102',
182
+ accessToken: 'new-acc-token',
183
+ refreshToken: 'new-ref-token',
184
+ expiresIn: expect.any(Number),
185
+ expiresAt: expect.any(String),
186
+ });
187
+ // New cookies should be set
188
+ expect(res.headers['set-cookie']).toBeDefined();
189
+ const cookies = Array.isArray(res.headers['set-cookie'])
190
+ ? res.headers['set-cookie']
191
+ : [res.headers['set-cookie']];
192
+ expect(cookies.some((c) => c.includes('new-ref-token'))).toBe(true);
193
+ });
194
+ it('should fallback to body mode when no cookies present', async () => {
195
+ vi.spyOn(tokenFns, 'generateTokensFromRefreshToken').mockResolvedValue({
196
+ userId: '102',
197
+ accessToken: 'new-acc-token',
198
+ refreshToken: 'new-ref-token',
199
+ });
200
+ const app = await createApp({
201
+ plugins: [authPlugin],
202
+ });
203
+ const res = await request(app).post('/auth/refresh').send({
204
+ accessToken: 'acc-111222333',
205
+ refreshToken: 'ref-999888777',
206
+ });
207
+ expect(res.status).toBe(200);
208
+ expect(res.body.accessToken).toBe('new-acc-token');
209
+ // No cookies should be set in body mode
210
+ expect(res.headers['set-cookie']).toBeUndefined();
211
+ });
212
+ it('should return 401 when no tokens provided', async () => {
213
+ const app = await createApp({
214
+ plugins: [authPlugin],
215
+ });
216
+ const res = await request(app).post('/auth/refresh').send({});
217
+ expect(res.status).toBe(401);
218
+ expect(res.body.error).toBe('UNAUTHORIZED');
219
+ expect(res.body.message).toBe('NO_TOKENS_PROVIDED');
220
+ });
221
+ });
222
+ describe('POST /auth/logout with cookies', () => {
223
+ it('should clear cookies on logout', async () => {
224
+ vi.mocked(dataProvider.findUserById).mockResolvedValue({
225
+ id: 'usr-123',
226
+ username: 'JohnDoe',
227
+ email: 'john@example.com',
228
+ });
229
+ vi.spyOn(tokenFns, 'validateAndDecodeAccessToken').mockReturnValue({
230
+ userId: 'usr-123',
231
+ });
232
+ const app = await createApp({
233
+ plugins: [authPlugin],
234
+ });
235
+ const res = await request(app)
236
+ .post('/auth/logout')
237
+ .set('Authorization', 'Bearer valid-token')
238
+ .set('Cookie', ['dotpress_refresh_token=ref-token']);
239
+ expect(res.status).toBe(200);
240
+ expect(res.body).toEqual({ success: true });
241
+ // Cookies should be cleared
242
+ expect(res.headers['set-cookie']).toBeDefined();
243
+ const cookies = Array.isArray(res.headers['set-cookie'])
244
+ ? res.headers['set-cookie']
245
+ : [res.headers['set-cookie']];
246
+ // Check that cookies are being cleared (Max-Age=0 or Expires in the past)
247
+ const refreshCookie = cookies.find((c) => c.startsWith('dotpress_refresh_token='));
248
+ expect(refreshCookie).toBeDefined();
249
+ // Cookie should have an expiry in the past or Max-Age=0
250
+ expect(refreshCookie.includes('Max-Age=0') ||
251
+ refreshCookie.includes('Expires=Thu, 01 Jan 1970')).toBe(true);
252
+ });
253
+ });
254
+ describe('GET /auth/me with cookies', () => {
255
+ it('should authenticate user with access token from Authorization header', async () => {
256
+ vi.mocked(dataProvider.findUserById).mockResolvedValue({
257
+ id: 'usr-123',
258
+ username: 'JohnDoe',
259
+ email: 'john@example.com',
260
+ });
261
+ vi.spyOn(tokenFns, 'validateAndDecodeAccessToken').mockReturnValue({
262
+ userId: 'usr-123',
263
+ });
264
+ const app = await createApp({
265
+ plugins: [authPlugin],
266
+ });
267
+ const res = await request(app)
268
+ .get('/auth/me')
269
+ .set('Authorization', 'Bearer valid-token');
270
+ expect(res.status).toBe(200);
271
+ expect(res.body).toEqual({
272
+ id: 'usr-123',
273
+ username: 'JohnDoe',
274
+ email: 'john@example.com',
275
+ });
276
+ });
277
+ it('should authenticate user with access token from cookie when accessTokenInCookie is true', async () => {
278
+ vi.mocked(dataProvider.findUserById).mockResolvedValue({
279
+ id: 'usr-456',
280
+ username: 'JaneDoe',
281
+ email: 'jane@example.com',
282
+ });
283
+ vi.spyOn(tokenFns, 'validateAndDecodeAccessToken').mockReturnValue({
284
+ userId: 'usr-456',
285
+ });
286
+ const authPluginWithAccessCookie = configurePlugin({
287
+ keys: {
288
+ secretKey: '',
289
+ publicKey: '',
290
+ },
291
+ dataProvider,
292
+ cookies: {
293
+ enabled: true,
294
+ secure: false,
295
+ accessTokenInCookie: true,
296
+ },
297
+ });
298
+ const app = await createApp({
299
+ plugins: [authPluginWithAccessCookie],
300
+ });
301
+ const res = await request(app)
302
+ .get('/auth/me')
303
+ .set('Cookie', ['dotpress_access_token=cookie-token']);
304
+ expect(res.status).toBe(200);
305
+ expect(res.body.username).toBe('JaneDoe');
306
+ });
307
+ });
308
+ describe('Custom cookie names', () => {
309
+ it('should use custom cookie names when configured', async () => {
310
+ const pwdHash = await bcrypt.hash('123456', 10);
311
+ vi.mocked(dataProvider.findUserIdentifiers).mockResolvedValue({
312
+ userId: '101',
313
+ passwordHash: pwdHash,
314
+ });
315
+ vi.spyOn(tokenFns, 'generateTokensForUser').mockResolvedValue({
316
+ accessToken: 'acc-token',
317
+ refreshToken: 'ref-token',
318
+ });
319
+ const customAuthPlugin = configurePlugin({
320
+ keys: {
321
+ secretKey: '',
322
+ publicKey: '',
323
+ },
324
+ dataProvider,
325
+ cookies: {
326
+ enabled: true,
327
+ secure: false,
328
+ accessTokenName: 'custom_access',
329
+ refreshTokenName: 'custom_refresh',
330
+ },
331
+ });
332
+ const app = await createApp({
333
+ plugins: [customAuthPlugin],
334
+ });
335
+ const res = await request(app).post('/auth/login').send({
336
+ username: 'JaneDoe',
337
+ password: '123456',
338
+ mode: 'cookie',
339
+ });
340
+ expect(res.status).toBe(200);
341
+ const cookies = Array.isArray(res.headers['set-cookie'])
342
+ ? res.headers['set-cookie']
343
+ : [res.headers['set-cookie']];
344
+ expect(cookies.some((c) => c.startsWith('custom_refresh='))).toBe(true);
345
+ });
346
+ });
347
+ describe('Cross-domain cookie configuration', () => {
348
+ it('should set domain attribute when configured', async () => {
349
+ const pwdHash = await bcrypt.hash('123456', 10);
350
+ vi.mocked(dataProvider.findUserIdentifiers).mockResolvedValue({
351
+ userId: '101',
352
+ passwordHash: pwdHash,
353
+ });
354
+ vi.spyOn(tokenFns, 'generateTokensForUser').mockResolvedValue({
355
+ accessToken: 'acc-token',
356
+ refreshToken: 'ref-token',
357
+ });
358
+ const domainAuthPlugin = configurePlugin({
359
+ keys: {
360
+ secretKey: '',
361
+ publicKey: '',
362
+ },
363
+ dataProvider,
364
+ cookies: {
365
+ enabled: true,
366
+ secure: false,
367
+ domain: '.example.com',
368
+ },
369
+ });
370
+ const app = await createApp({
371
+ plugins: [domainAuthPlugin],
372
+ });
373
+ const res = await request(app).post('/auth/login').send({
374
+ username: 'JaneDoe',
375
+ password: '123456',
376
+ mode: 'cookie',
377
+ });
378
+ expect(res.status).toBe(200);
379
+ const cookies = Array.isArray(res.headers['set-cookie'])
380
+ ? res.headers['set-cookie']
381
+ : [res.headers['set-cookie']];
382
+ const refreshCookie = cookies.find((c) => c.startsWith('dotpress_refresh_token='));
383
+ expect(refreshCookie).toContain('Domain=.example.com');
384
+ });
385
+ });
386
+ });