@dotpress/auth 1.2.1 → 1.3.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
@@ -45,6 +45,7 @@ The plugin will check and parse bearer token from Authorization header and will
45
45
 
46
46
  - `POST /auth/login`
47
47
  - `POST /auth/refresh`
48
+ - `POST /auth/restore`
48
49
  - `GET /auth/me`
49
50
  - `POST /auth/logout`
50
51
 
@@ -228,6 +229,81 @@ Refresh tokens using previous token pair. The plugin automatically detects the a
228
229
  - `INVALID_REFRESH_TOKEN`: Refresh token is invalid or already used
229
230
  - `EXPIRED_REFRESH_TOKEN`: Refresh token has expired
230
231
 
232
+ ### `POST /auth/restore`
233
+
234
+ Restore a user session from cookies. This endpoint validates the access token from cookies and automatically refreshes it if expired.
235
+
236
+ **Use Case:**
237
+ This route is designed for web applications to restore user sessions after page reload or when returning after inactivity. Instead of making multiple API calls (`/auth/me` → 401 → `/auth/refresh` → `/auth/me`), you can call `/auth/restore` once to validate or refresh the session in a single request.
238
+
239
+ **Requirements:**
240
+ - Cookie mode must be enabled (`cookies.enabled: true`)
241
+ - Access token must be stored in cookies (`cookies.accessTokenInCookie: true`)
242
+ - Both `accessToken` and `refreshToken` must be present in cookies
243
+
244
+ **Request:**
245
+ No body required. Tokens are automatically extracted from cookies.
246
+
247
+ **Response:**
248
+ ```json
249
+ {
250
+ "userId": "usr-123",
251
+ "accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
252
+ "refreshToken": "3f7b2a8c1e...",
253
+ "expiresIn": 3600,
254
+ "expiresAt": "2025-11-02T16:30:00.000Z",
255
+ "user": {
256
+ "id": "usr-123",
257
+ "username": "JohnDoe",
258
+ "email": "john@example.com"
259
+ }
260
+ }
261
+ ```
262
+
263
+ **Behavior:**
264
+ 1. If `accessToken` is valid: Returns existing tokens with user data
265
+ 2. If `accessToken` is expired but `refreshToken` is valid: Generates new token pair, updates cookies, and returns new tokens with user data
266
+ 3. If both tokens are invalid: Returns 401
267
+
268
+ **Error Codes:**
269
+ - `COOKIES_NOT_ENABLED`: Cookie mode is not enabled in plugin configuration
270
+ - `ACCESS_TOKEN_NOT_IN_COOKIE`: `accessTokenInCookie` is not enabled
271
+ - `NO_REFRESH_TOKEN`: Refresh token cookie is missing
272
+ - `NO_ACCESS_TOKEN`: Access token cookie is missing
273
+ - `INVALID_REFRESH_TOKEN`: Refresh token is invalid or already used
274
+ - `EXPIRED_REFRESH_TOKEN`: Refresh token has expired
275
+ - `USER_NOT_FOUND`: User ID from token not found in database
276
+
277
+ **Example Usage:**
278
+
279
+ ```ts
280
+ // React SPA: Restore session on app initialization
281
+ useEffect(() => {
282
+ const restoreSession = async () => {
283
+ try {
284
+ const response = await fetch('/auth/restore', {
285
+ method: 'POST',
286
+ credentials: 'include', // Important: include cookies
287
+ })
288
+
289
+ if (response.ok) {
290
+ const { user, accessToken, expiresAt } = await response.json()
291
+ dispatch(setUser(user))
292
+ dispatch(setAccessToken(accessToken))
293
+ dispatch(setExpiresAt(expiresAt))
294
+ } else {
295
+ // Session invalid, redirect to login
296
+ navigate('/login')
297
+ }
298
+ } catch (error) {
299
+ console.error('Failed to restore session:', error)
300
+ }
301
+ }
302
+
303
+ restoreSession()
304
+ }, [])
305
+ ```
306
+
231
307
  ### `GET /auth/me`
232
308
 
233
309
  Get current authenticated user.
@@ -383,4 +383,239 @@ describe('Cookie Mode Authentication', () => {
383
383
  expect(refreshCookie).toContain('Domain=.example.com');
384
384
  });
385
385
  });
386
+ describe('POST /auth/restore', () => {
387
+ it('should return 401 when cookies are not enabled', async () => {
388
+ const authPluginNoCookies = configurePlugin({
389
+ keys: {
390
+ secretKey: '',
391
+ publicKey: '',
392
+ },
393
+ dataProvider,
394
+ cookies: {
395
+ enabled: false,
396
+ },
397
+ });
398
+ const app = await createApp({
399
+ plugins: [authPluginNoCookies],
400
+ });
401
+ const res = await request(app).post('/auth/restore');
402
+ expect(res.status).toBe(401);
403
+ });
404
+ it('should return 401 when accessTokenInCookie is not enabled', async () => {
405
+ const authPluginNoAccessCookie = configurePlugin({
406
+ keys: {
407
+ secretKey: '',
408
+ publicKey: '',
409
+ },
410
+ dataProvider,
411
+ cookies: {
412
+ enabled: true,
413
+ accessTokenInCookie: false, // Required for restore
414
+ },
415
+ });
416
+ const app = await createApp({
417
+ plugins: [authPluginNoAccessCookie],
418
+ });
419
+ const res = await request(app)
420
+ .post('/auth/restore')
421
+ .set('Cookie', ['dotpress_refresh_token=ref-token']);
422
+ expect(res.status).toBe(401);
423
+ });
424
+ it('should return 401 when refresh token is missing', async () => {
425
+ const authPluginWithAccessCookie = configurePlugin({
426
+ keys: {
427
+ secretKey: '',
428
+ publicKey: '',
429
+ },
430
+ dataProvider,
431
+ cookies: {
432
+ enabled: true,
433
+ accessTokenInCookie: true,
434
+ },
435
+ });
436
+ const app = await createApp({
437
+ plugins: [authPluginWithAccessCookie],
438
+ });
439
+ const res = await request(app)
440
+ .post('/auth/restore')
441
+ .set('Cookie', ['dotpress_access_token=acc-token']);
442
+ expect(res.status).toBe(401);
443
+ });
444
+ it('should return 401 when access token is missing', async () => {
445
+ const authPluginWithAccessCookie = configurePlugin({
446
+ keys: {
447
+ secretKey: '',
448
+ publicKey: '',
449
+ },
450
+ dataProvider,
451
+ cookies: {
452
+ enabled: true,
453
+ accessTokenInCookie: true,
454
+ },
455
+ });
456
+ const app = await createApp({
457
+ plugins: [authPluginWithAccessCookie],
458
+ });
459
+ const res = await request(app)
460
+ .post('/auth/restore')
461
+ .set('Cookie', ['dotpress_refresh_token=ref-token']);
462
+ expect(res.status).toBe(401);
463
+ });
464
+ it('should return user and tokens when access token is still valid', async () => {
465
+ vi.spyOn(tokenFns, 'validateAndDecodeAccessToken').mockReturnValue({
466
+ userId: 'usr-123',
467
+ });
468
+ vi.mocked(dataProvider.findUserById).mockResolvedValue({
469
+ id: 'usr-123',
470
+ username: 'JohnDoe',
471
+ email: 'john@example.com',
472
+ });
473
+ const authPluginWithAccessCookie = configurePlugin({
474
+ keys: {
475
+ secretKey: '',
476
+ publicKey: '',
477
+ },
478
+ dataProvider,
479
+ cookies: {
480
+ enabled: true,
481
+ accessTokenInCookie: true,
482
+ },
483
+ });
484
+ const app = await createApp({
485
+ plugins: [authPluginWithAccessCookie],
486
+ });
487
+ const res = await request(app)
488
+ .post('/auth/restore')
489
+ .set('Cookie', [
490
+ 'dotpress_access_token=valid-access-token',
491
+ 'dotpress_refresh_token=valid-refresh-token',
492
+ ]);
493
+ expect(res.status).toBe(200);
494
+ expect(res.body).toMatchObject({
495
+ userId: 'usr-123',
496
+ accessToken: 'valid-access-token',
497
+ refreshToken: 'valid-refresh-token',
498
+ expiresIn: expect.any(Number),
499
+ expiresAt: expect.any(String),
500
+ user: {
501
+ id: 'usr-123',
502
+ username: 'JohnDoe',
503
+ email: 'john@example.com',
504
+ },
505
+ });
506
+ });
507
+ it('should refresh tokens and return new tokens when access token is expired', async () => {
508
+ // First call: validateAndDecodeAccessToken throws (expired)
509
+ vi.spyOn(tokenFns, 'validateAndDecodeAccessToken').mockImplementation(() => {
510
+ throw new Error('Token expired');
511
+ });
512
+ // Second call: generateTokensFromRefreshToken returns new tokens
513
+ vi.spyOn(tokenFns, 'generateTokensFromRefreshToken').mockResolvedValue({
514
+ userId: 'usr-456',
515
+ accessToken: 'new-access-token',
516
+ refreshToken: 'new-refresh-token',
517
+ });
518
+ vi.mocked(dataProvider.findUserById).mockResolvedValue({
519
+ id: 'usr-456',
520
+ username: 'JaneDoe',
521
+ email: 'jane@example.com',
522
+ });
523
+ const authPluginWithAccessCookie = configurePlugin({
524
+ keys: {
525
+ secretKey: '',
526
+ publicKey: '',
527
+ },
528
+ dataProvider,
529
+ cookies: {
530
+ enabled: true,
531
+ accessTokenInCookie: true,
532
+ },
533
+ });
534
+ const app = await createApp({
535
+ plugins: [authPluginWithAccessCookie],
536
+ });
537
+ const res = await request(app)
538
+ .post('/auth/restore')
539
+ .set('Cookie', [
540
+ 'dotpress_access_token=expired-access-token',
541
+ 'dotpress_refresh_token=valid-refresh-token',
542
+ ]);
543
+ expect(res.status).toBe(200);
544
+ expect(res.body).toMatchObject({
545
+ userId: 'usr-456',
546
+ accessToken: 'new-access-token',
547
+ refreshToken: 'new-refresh-token',
548
+ expiresIn: expect.any(Number),
549
+ expiresAt: expect.any(String),
550
+ user: {
551
+ id: 'usr-456',
552
+ username: 'JaneDoe',
553
+ email: 'jane@example.com',
554
+ },
555
+ });
556
+ // Verify new cookies were set
557
+ expect(res.headers['set-cookie']).toBeDefined();
558
+ const cookies = Array.isArray(res.headers['set-cookie'])
559
+ ? res.headers['set-cookie']
560
+ : [res.headers['set-cookie']];
561
+ const accessCookie = cookies.find((c) => c.startsWith('dotpress_access_token='));
562
+ const refreshCookie = cookies.find((c) => c.startsWith('dotpress_refresh_token='));
563
+ expect(accessCookie).toBeDefined();
564
+ expect(refreshCookie).toBeDefined();
565
+ });
566
+ it('should return 401 when refresh token is invalid', async () => {
567
+ vi.spyOn(tokenFns, 'validateAndDecodeAccessToken').mockImplementation(() => {
568
+ throw new Error('Token expired');
569
+ });
570
+ vi.spyOn(tokenFns, 'generateTokensFromRefreshToken').mockRejectedValue(new Error('Invalid_Refresh_Token'));
571
+ const authPluginWithAccessCookie = configurePlugin({
572
+ keys: {
573
+ secretKey: '',
574
+ publicKey: '',
575
+ },
576
+ dataProvider,
577
+ cookies: {
578
+ enabled: true,
579
+ accessTokenInCookie: true,
580
+ },
581
+ });
582
+ const app = await createApp({
583
+ plugins: [authPluginWithAccessCookie],
584
+ });
585
+ const res = await request(app)
586
+ .post('/auth/restore')
587
+ .set('Cookie', [
588
+ 'dotpress_access_token=expired-access-token',
589
+ 'dotpress_refresh_token=invalid-refresh-token',
590
+ ]);
591
+ expect(res.status).toBe(401);
592
+ });
593
+ it('should return 401 when refresh token is expired', async () => {
594
+ vi.spyOn(tokenFns, 'validateAndDecodeAccessToken').mockImplementation(() => {
595
+ throw new Error('Token expired');
596
+ });
597
+ vi.spyOn(tokenFns, 'generateTokensFromRefreshToken').mockRejectedValue(new Error('Expired_Refresh_Token'));
598
+ const authPluginWithAccessCookie = configurePlugin({
599
+ keys: {
600
+ secretKey: '',
601
+ publicKey: '',
602
+ },
603
+ dataProvider,
604
+ cookies: {
605
+ enabled: true,
606
+ accessTokenInCookie: true,
607
+ },
608
+ });
609
+ const app = await createApp({
610
+ plugins: [authPluginWithAccessCookie],
611
+ });
612
+ const res = await request(app)
613
+ .post('/auth/restore')
614
+ .set('Cookie', [
615
+ 'dotpress_access_token=expired-access-token',
616
+ 'dotpress_refresh_token=expired-refresh-token',
617
+ ]);
618
+ expect(res.status).toBe(401);
619
+ });
620
+ });
386
621
  });
package/dist/index.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import type { Plugin } from 'dotpress';
2
2
  import { AuthSettings, DataProvider, KeyPairSettings } from './types/types.js';
3
- import { generateAccessToken } from './lib/tokens.js';
3
+ import { generateAccessToken, generateTokensForUser, generateRefreshToken } from './lib/tokens.js';
4
4
  type Settings = Partial<AuthSettings> & {
5
5
  keys: KeyPairSettings;
6
6
  dataProvider: DataProvider;
7
7
  };
8
8
  export declare const configurePlugin: (settings: Settings) => Plugin;
9
9
  export declare const generatePasswordHash: (password: string, salt?: number) => Promise<string>;
10
- export { generateAccessToken };
10
+ export { generateAccessToken, generateRefreshToken, generateTokensForUser };
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import cookieParser from 'cookie-parser';
3
3
  import { setSettings } from './lib/settings.js';
4
4
  import { registerHandlers } from './lib/handlers.js';
5
5
  import { attachAuthenticatedUser } from './lib/middleware.js';
6
- import { generateAccessToken } from './lib/tokens.js';
6
+ import { generateAccessToken, generateTokensForUser, generateRefreshToken, } from './lib/tokens.js';
7
7
  export const configurePlugin = (settings) => {
8
8
  setSettings(settings);
9
9
  return (plugin) => {
@@ -19,4 +19,4 @@ export const configurePlugin = (settings) => {
19
19
  export const generatePasswordHash = (password, salt = 10) => {
20
20
  return bcrypt.hash(password, salt);
21
21
  };
22
- export { generateAccessToken };
22
+ export { generateAccessToken, generateRefreshToken, generateTokensForUser };
@@ -2,8 +2,8 @@ import { unauthorizedError, internalError } from 'dotpress';
2
2
  import bcrypt from 'bcryptjs';
3
3
  import { getSettings } from './settings.js';
4
4
  import { getRequestIp } from './ip.js';
5
- import { generateTokensForUser, generateTokensFromRefreshToken, } from './tokens.js';
6
- import { setAuthCookies, clearAuthCookies, extractTokensFromRequest, calculateExpiration, } from './cookies.js';
5
+ import { generateTokensForUser, generateTokensFromRefreshToken, validateAndDecodeAccessToken, } from './tokens.js';
6
+ import { setAuthCookies, clearAuthCookies, extractTokensFromRequest, calculateExpiration, getCookieSettings, } from './cookies.js';
7
7
  export const registerHandlers = (plugin) => {
8
8
  const authGroup = plugin.addGroup('/auth');
9
9
  const settings = getSettings();
@@ -162,6 +162,91 @@ export const registerHandlers = (plugin) => {
162
162
  return req.user;
163
163
  },
164
164
  });
165
+ authGroup.defineRoute({
166
+ method: 'post',
167
+ path: '/restore',
168
+ schema: (z) => ({
169
+ response: z.object({
170
+ userId: z.string(),
171
+ accessToken: z.string(),
172
+ refreshToken: z.string(),
173
+ expiresIn: z.number(),
174
+ expiresAt: z.string(),
175
+ user: z.unknown(),
176
+ }),
177
+ }),
178
+ handler: async ({ req, res }) => {
179
+ const cookieSettings = getCookieSettings();
180
+ // Only support cookie mode for restore
181
+ if (!cookieSettings.enabled) {
182
+ return unauthorizedError('COOKIES_NOT_ENABLED');
183
+ }
184
+ // This endpoint requires accessTokenInCookie to be enabled
185
+ if (!cookieSettings.accessTokenInCookie) {
186
+ return unauthorizedError('ACCESS_TOKEN_NOT_IN_COOKIE');
187
+ }
188
+ const refreshToken = req.cookies?.[cookieSettings.refreshTokenName];
189
+ const accessToken = req.cookies?.[cookieSettings.accessTokenName];
190
+ if (!refreshToken) {
191
+ return unauthorizedError('NO_REFRESH_TOKEN');
192
+ }
193
+ if (!accessToken) {
194
+ return unauthorizedError('NO_ACCESS_TOKEN');
195
+ }
196
+ // Try to validate access token first
197
+ try {
198
+ const { userId } = validateAndDecodeAccessToken(accessToken);
199
+ const user = await settings.dataProvider.findUserById(userId);
200
+ if (user) {
201
+ const expiration = calculateExpiration(settings.accessTokenTTL);
202
+ return {
203
+ userId,
204
+ accessToken,
205
+ refreshToken,
206
+ ...expiration,
207
+ user,
208
+ };
209
+ }
210
+ }
211
+ catch {
212
+ // Access token is invalid or expired, continue to refresh logic
213
+ }
214
+ // Access token is expired, try to refresh using refresh token
215
+ try {
216
+ const tokens = await generateTokensFromRefreshToken({
217
+ accessToken,
218
+ refreshToken,
219
+ });
220
+ // Set new cookies
221
+ setAuthCookies(res, {
222
+ accessToken: tokens.accessToken,
223
+ refreshToken: tokens.refreshToken,
224
+ }, settings.refreshTokenTTLInDays * 24 * 60 * 60);
225
+ const user = await settings.dataProvider.findUserById(tokens.userId);
226
+ if (!user) {
227
+ return unauthorizedError('USER_NOT_FOUND');
228
+ }
229
+ const expiration = calculateExpiration(settings.accessTokenTTL);
230
+ return {
231
+ userId: tokens.userId,
232
+ accessToken: tokens.accessToken,
233
+ refreshToken: tokens.refreshToken,
234
+ ...expiration,
235
+ user,
236
+ };
237
+ }
238
+ catch (e) {
239
+ const errorMessage = e.message;
240
+ if (errorMessage === 'Invalid_Refresh_Token') {
241
+ return unauthorizedError('INVALID_REFRESH_TOKEN');
242
+ }
243
+ if (errorMessage === 'Expired_Refresh_Token') {
244
+ return unauthorizedError('EXPIRED_REFRESH_TOKEN');
245
+ }
246
+ return internalError('Cannot restore session.');
247
+ }
248
+ },
249
+ });
165
250
  authGroup.defineRoute({
166
251
  method: 'post',
167
252
  path: '/logout',
@@ -65,6 +65,14 @@ export type TokenResponse = {
65
65
  expiresIn: number;
66
66
  expiresAt: string;
67
67
  };
68
+ export type RestoreResponse = {
69
+ userId: string;
70
+ accessToken: string;
71
+ refreshToken: string;
72
+ expiresIn: number;
73
+ expiresAt: string;
74
+ user: unknown;
75
+ };
68
76
  export type CreateTokenPayload = {
69
77
  sub: string;
70
78
  username: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotpress/auth",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
4
4
  "description": "JWT Authentication plugin for Dotpress library",
5
5
  "author": "Renaud Guyon",
6
6
  "license": "MIT",
@@ -70,5 +70,6 @@
70
70
  "jwt",
71
71
  "plugin",
72
72
  "dotpress"
73
- ]
73
+ ],
74
+ "packageManager": "pnpm@10.8.1+sha512.c50088ba998c67b8ca8c99df8a5e02fd2ae2e2b29aaf238feaa9e124248d3f48f9fb6db2424949ff901cffbb5e0f0cc1ad6aedb602cd29450751d11c35023677"
74
75
  }