@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 +211 -35
- package/dist/__tests__/functional/cookies.test.d.ts +1 -0
- package/dist/__tests__/functional/cookies.test.js +386 -0
- package/dist/__tests__/functional/handlers.test.js +4 -0
- package/dist/__tests__/unit/cookies.test.d.ts +1 -0
- package/dist/__tests__/unit/cookies.test.js +466 -0
- package/dist/index.js +4 -0
- package/dist/lib/cookies.d.ts +42 -0
- package/dist/lib/cookies.js +136 -0
- package/dist/lib/handlers.js +40 -9
- package/dist/lib/middleware.js +2 -3
- package/dist/types/types.d.ts +20 -0
- package/package.json +3 -1
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
|
-
-
|
|
59
|
-
-
|
|
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:
|
|
103
|
-
- password:
|
|
104
|
-
- client
|
|
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
|
-
|
|
107
|
-
-
|
|
108
|
-
- accessToken
|
|
109
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
195
|
+
**Request Body (Body Mode):**
|
|
196
|
+
```json
|
|
197
|
+
{
|
|
198
|
+
"accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
|
|
199
|
+
"refreshToken": "3f7b2a8c1e..."
|
|
200
|
+
}
|
|
201
|
+
```
|
|
120
202
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
256
|
+
**Response:**
|
|
257
|
+
```json
|
|
258
|
+
{
|
|
259
|
+
"success": true
|
|
260
|
+
}
|
|
261
|
+
```
|
|
135
262
|
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|