@dotpress/auth 1.0.0

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 ADDED
@@ -0,0 +1,177 @@
1
+ # Dotpress Auth
2
+
3
+ Authentication plugin for [dotpress](https://www.npmjs.com/package/dotpress) library using JWT tokens.
4
+ You can use Prisma or any other ORM and easily connect it using the plugin DataProvider interface.
5
+
6
+
7
+ [Full documentation here](https://dotpress.dev)
8
+
9
+ ---
10
+
11
+ ## Quick Start
12
+
13
+ ### 1. Install
14
+
15
+ ```bash
16
+ npm install @dotpress/auth
17
+ ```
18
+
19
+ ### 2. Register your plugin
20
+
21
+ ```ts
22
+ // index.ts
23
+ import { createApp, defineRoute } from 'dotpress'
24
+ import { configurePlugin } from '@dotpress/auth'
25
+
26
+ const authPlugin = configurePlugin({
27
+ // See configuration object below
28
+ })
29
+
30
+ const app = await createApp({
31
+ plugins: [authPlugin]
32
+ })
33
+
34
+ app.listen(3000, () => {
35
+ console.log('Server listening on port 3000')
36
+ })
37
+ ```
38
+
39
+ ### 3. Usage
40
+
41
+ The plugin will check and parse bearer token from Authorization header and will register the following routes (full API below)
42
+
43
+ - `POST /auth/login`
44
+ - `POST /auth/refresh`
45
+ - `GET /auth/me`
46
+ - `POST /auth/logout`
47
+
48
+ ---
49
+
50
+ ## Configuration object properties
51
+
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
60
+
61
+ ### DataProvider type
62
+
63
+ ```ts
64
+ type DataProvider = {
65
+ storeRefreshToken: (payload: StoreRefreshTokenDto) => Promise<void>
66
+ findRefreshToken: (tokenHash: string) => Promise<RefreshTokenData | undefined>
67
+ markRefreshTokenAsUsed: (tokenHash: string) => Promise<void>
68
+ revokeTokens: (userId: string) => Promise<void>
69
+ findUserById: <TUser = Record<string, unknown>>(
70
+ userId: string
71
+ ) => Promise<TUser | undefined>
72
+ findUserIdentifiers: (
73
+ username: string
74
+ ) => Promise<{ userId: string; passwordHash: string } | undefined>
75
+ }
76
+
77
+
78
+ type StoreRefreshTokenDto = {
79
+ token: string
80
+ tokenHash: string
81
+ userId: string
82
+ jwtId: string
83
+ expiresAt: Date
84
+ }
85
+
86
+ type RefreshTokenData = {
87
+ userId: string
88
+ jwtId: string
89
+ expiresAt: Date
90
+ isUsed: boolean
91
+ revokedAt?: Date | null
92
+ }
93
+ ```
94
+
95
+ ## Endpoints
96
+
97
+ ### `POST /auth/login`
98
+
99
+ Authenticate a user and generate tokens.
100
+
101
+ Request Body:
102
+ - username: string
103
+ - password: string
104
+ - client: optional user agent string
105
+
106
+ Response:
107
+ - userId: string
108
+ - accessToken: string
109
+ - refreshToken: string
110
+
111
+ Returns 401 on failed login.
112
+
113
+ ### `POST /auth/refresh`
114
+
115
+ Refresh tokens using previous token pair.
116
+
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
120
+
121
+ Response:
122
+ - userId: string
123
+ - accessToken: string
124
+ - refreshToken: string
125
+
126
+ Returns 401 if tokens are not valid.
127
+
128
+ ### `POST /auth/logout`
129
+
130
+ Revoke tokens for current authenticated user.
131
+
132
+ Response:
133
+ - 200 OK on success
134
+ - 401 if user is not authenticated
135
+
136
+ ### `GET /auth/me`
137
+
138
+ Get current authenticated user
139
+
140
+ Response:
141
+ - User object if user is authenticated
142
+ - 401 if user is not authenticated
143
+
144
+
145
+ ## Prisma schema examples
146
+
147
+ You will find below recommended schema for User and RefreshToken entities. Feel free to adapt them according to your needs.
148
+
149
+ ```text
150
+ model User {
151
+ id String @id @default(cuid())
152
+ firstName String? @db.VarChar(100)
153
+ lastName String? @db.VarChar(100)
154
+ username String @unique @db.VarChar(50)
155
+ pwdHash String @db.VarChar(200)
156
+ email String @db.VarChar(200)
157
+ role String @db.VarChar(50)
158
+ createdAt DateTime @default(now())
159
+ updatedAt DateTime @updatedAt
160
+
161
+ @@map("users")
162
+ }
163
+
164
+ model RefreshToken {
165
+ id String @id @default(cuid())
166
+ userId String @db.VarChar(50)
167
+ jwtId String @db.VarChar(100)
168
+ tokenHash String @unique @db.VarChar(100)
169
+ expiresAt DateTime
170
+ revokedAt DateTime?
171
+ isUsed Boolean
172
+ createdAt DateTime @default(now())
173
+ updatedAt DateTime @updatedAt
174
+
175
+ @@map("refresh_tokens")
176
+ }
177
+ ```
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,235 @@
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('Handlers', () => {
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 onLoginFailedCallback = vi.fn();
21
+ const onLoginSuccessCallback = vi.fn();
22
+ const authPlugin = configurePlugin({
23
+ keys: {
24
+ secretKey: '',
25
+ publicKey: '',
26
+ },
27
+ dataProvider,
28
+ onLoginFailed: onLoginFailedCallback,
29
+ onLoginSuccess: onLoginSuccessCallback,
30
+ });
31
+ describe('Login Route', () => {
32
+ it('should return 401 if user not found', async () => {
33
+ const findUserIdentifiersFn = vi
34
+ .mocked(dataProvider.findUserIdentifiers)
35
+ .mockResolvedValue(undefined);
36
+ const app = await createApp({
37
+ plugins: [authPlugin],
38
+ });
39
+ const res = await request(app).post('/auth/login').send({
40
+ username: 'JaneDoe',
41
+ password: '123456',
42
+ client: 'User-Agent String',
43
+ });
44
+ expect(res.status).toBe(401);
45
+ expect(findUserIdentifiersFn).toHaveBeenCalledTimes(1);
46
+ expect(findUserIdentifiersFn).toHaveBeenCalledWith('JaneDoe');
47
+ expect(onLoginFailedCallback).toHaveBeenCalledTimes(1);
48
+ expect(onLoginFailedCallback).toHaveBeenCalledWith({
49
+ username: 'JaneDoe',
50
+ errorType: 'invalid_username',
51
+ client: 'User-Agent String',
52
+ ipAddress: '',
53
+ });
54
+ });
55
+ it('should return 401 if user password mismatches', async () => {
56
+ const findUserIdentifiersFn = vi
57
+ .mocked(dataProvider.findUserIdentifiers)
58
+ .mockResolvedValue({
59
+ userId: '101',
60
+ passwordHash: '',
61
+ });
62
+ const app = await createApp({
63
+ plugins: [authPlugin],
64
+ });
65
+ const res = await request(app).post('/auth/login').send({
66
+ username: 'JaneDoe',
67
+ password: '123456',
68
+ });
69
+ expect(res.status).toBe(401);
70
+ expect(findUserIdentifiersFn).toHaveBeenCalledTimes(1);
71
+ expect(findUserIdentifiersFn).toHaveBeenCalledWith('JaneDoe');
72
+ expect(onLoginFailedCallback).toHaveBeenCalledTimes(1);
73
+ expect(onLoginFailedCallback).toHaveBeenCalledWith({
74
+ username: 'JaneDoe',
75
+ errorType: 'invalid_password',
76
+ client: '',
77
+ ipAddress: '',
78
+ });
79
+ });
80
+ it('should return tokens if password is correct', async () => {
81
+ const pwdHash = await bcrypt.hash('123456', 10);
82
+ const findUserIdentifiersFn = vi
83
+ .mocked(dataProvider.findUserIdentifiers)
84
+ .mockResolvedValue({
85
+ userId: '101',
86
+ passwordHash: pwdHash,
87
+ });
88
+ vi.spyOn(tokenFns, 'generateTokensForUser').mockResolvedValue({
89
+ accessToken: 'acc-111222333',
90
+ refreshToken: 'ref-999888777',
91
+ });
92
+ const app = await createApp({
93
+ plugins: [authPlugin],
94
+ });
95
+ const res = await request(app).post('/auth/login').send({
96
+ username: 'JaneDoe',
97
+ password: '123456',
98
+ });
99
+ expect(res.status).toBe(200);
100
+ expect(findUserIdentifiersFn).toHaveBeenCalledTimes(1);
101
+ expect(findUserIdentifiersFn).toHaveBeenCalledWith('JaneDoe');
102
+ expect(onLoginSuccessCallback).toHaveBeenCalledTimes(1);
103
+ expect(onLoginSuccessCallback).toHaveBeenCalledWith({
104
+ userId: '101',
105
+ username: 'JaneDoe',
106
+ client: '',
107
+ ipAddress: '',
108
+ });
109
+ expect(res.body).toEqual({
110
+ userId: '101',
111
+ accessToken: 'acc-111222333',
112
+ refreshToken: 'ref-999888777',
113
+ });
114
+ });
115
+ });
116
+ describe('Refresh Token Route', () => {
117
+ it('should return 401 if refresh token is invalid', async () => {
118
+ const generateTokenFn = vi
119
+ .spyOn(tokenFns, 'generateTokensFromRefreshToken')
120
+ .mockImplementation(() => {
121
+ throw new Error('Invalid_Refresh_Token');
122
+ });
123
+ const app = await createApp({
124
+ plugins: [authPlugin],
125
+ });
126
+ const res = await request(app).post('/auth/refresh').send({
127
+ accessToken: '123456789',
128
+ refreshToken: '000000000',
129
+ });
130
+ expect(res.status).toBe(401);
131
+ expect(res.body).toEqual({
132
+ status: 401,
133
+ error: 'UNAUTHORIZED',
134
+ message: 'INVALID_REFRESH_TOKEN',
135
+ });
136
+ expect(generateTokenFn).toHaveBeenCalledTimes(1);
137
+ expect(generateTokenFn).toHaveBeenCalledWith({
138
+ accessToken: '123456789',
139
+ refreshToken: '000000000',
140
+ });
141
+ });
142
+ it('should return 200 OK and new tokens if refresh token is valid', async () => {
143
+ const generateTokenFn = vi
144
+ .spyOn(tokenFns, 'generateTokensFromRefreshToken')
145
+ .mockResolvedValue({
146
+ userId: '102',
147
+ accessToken: '111222333',
148
+ refreshToken: '111111111',
149
+ });
150
+ const app = await createApp({
151
+ plugins: [authPlugin],
152
+ });
153
+ const res = await request(app).post('/auth/refresh').send({
154
+ accessToken: '123456789',
155
+ refreshToken: '000000000',
156
+ });
157
+ expect(res.status).toBe(200);
158
+ expect(res.body).toEqual({
159
+ userId: '102',
160
+ accessToken: '111222333',
161
+ refreshToken: '111111111',
162
+ });
163
+ expect(generateTokenFn).toHaveBeenCalledTimes(1);
164
+ expect(generateTokenFn).toHaveBeenCalledWith({
165
+ accessToken: '123456789',
166
+ refreshToken: '000000000',
167
+ });
168
+ });
169
+ });
170
+ describe('Current User Route', () => {
171
+ it('should return 401 if no current user', async () => {
172
+ const app = await createApp({
173
+ plugins: [authPlugin],
174
+ });
175
+ const res = await request(app).get('/auth/me');
176
+ expect(res.status).toBe(401);
177
+ });
178
+ it('should load current user', async () => {
179
+ const findUserFn = vi
180
+ .mocked(dataProvider.findUserById)
181
+ .mockResolvedValue({
182
+ id: 'usr-123',
183
+ username: 'JohnDoe111',
184
+ email: 'JohnDoe111@example.com',
185
+ });
186
+ const decodeFn = vi
187
+ .spyOn(tokenFns, 'validateAndDecodeAccessToken')
188
+ .mockReturnValue({ userId: 'usr-123' });
189
+ const app = await createApp({
190
+ plugins: [authPlugin],
191
+ });
192
+ const res = await request(app)
193
+ .get('/auth/me')
194
+ .set('Authorization', 'Bearer 0123456789');
195
+ expect(decodeFn).toBeCalledWith('0123456789');
196
+ expect(findUserFn).toBeCalledTimes(1);
197
+ expect(findUserFn).toBeCalledWith('usr-123');
198
+ expect(res.status).toBe(200);
199
+ expect(res.body).toEqual({
200
+ id: 'usr-123',
201
+ username: 'JohnDoe111',
202
+ email: 'JohnDoe111@example.com',
203
+ });
204
+ });
205
+ });
206
+ describe('Logout Route', () => {
207
+ it('should return 401 if no current user', async () => {
208
+ const app = await createApp({
209
+ plugins: [authPlugin],
210
+ });
211
+ const res = await request(app).post('/auth/logout');
212
+ expect(res.status).toBe(401);
213
+ });
214
+ it('should revoke token and return 200 OK', async () => {
215
+ vi.mocked(dataProvider.findUserById).mockResolvedValue({
216
+ id: 'usr-123',
217
+ username: 'JohnDoe111',
218
+ email: 'JohnDoe111@example.com',
219
+ });
220
+ vi.spyOn(tokenFns, 'validateAndDecodeAccessToken').mockReturnValue({
221
+ userId: 'usr-123',
222
+ });
223
+ const app = await createApp({
224
+ plugins: [authPlugin],
225
+ });
226
+ const res = await request(app)
227
+ .post('/auth/logout')
228
+ .set('Authorization', 'Bearer 0123456789');
229
+ expect(vi.mocked(dataProvider.revokeTokens)).toBeCalledTimes(1);
230
+ expect(vi.mocked(dataProvider.revokeTokens)).toBeCalledWith('usr-123');
231
+ expect(res.status).toBe(200);
232
+ expect(res.body).toEqual({ success: true });
233
+ });
234
+ });
235
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import request from 'supertest';
3
+ import { createApp, defineRoute } from 'dotpress';
4
+ import * as tokenFns from '../../lib/tokens.js';
5
+ import { configurePlugin } from '../../index.js';
6
+ describe('Middlewares', () => {
7
+ beforeEach(() => {
8
+ vi.resetAllMocks();
9
+ });
10
+ const dataProvider = {
11
+ storeRefreshToken: vi.fn(),
12
+ findRefreshToken: vi.fn(),
13
+ markRefreshTokenAsUsed: vi.fn(),
14
+ revokeTokens: vi.fn(),
15
+ findUserById: vi.fn(),
16
+ findUserIdentifiers: vi.fn(),
17
+ };
18
+ const authPlugin = configurePlugin({
19
+ keys: {
20
+ secretKey: '',
21
+ publicKey: '',
22
+ },
23
+ dataProvider,
24
+ });
25
+ it('should not attach a user if token not provided', async () => {
26
+ defineRoute({
27
+ path: '/test',
28
+ handler: async ({ req }) => {
29
+ return {
30
+ status: 'ok',
31
+ user: req.user,
32
+ };
33
+ },
34
+ });
35
+ const app = await createApp({
36
+ plugins: [authPlugin],
37
+ });
38
+ const res = await request(app).get('/test');
39
+ expect(res.status).toBe(200);
40
+ expect(res.body).toEqual({
41
+ status: 'ok',
42
+ });
43
+ });
44
+ it('should attach a user to request if authenticated', async () => {
45
+ const decodeFn = vi
46
+ .spyOn(tokenFns, 'validateAndDecodeAccessToken')
47
+ .mockReturnValue({ userId: 'usr-123' });
48
+ const findUserFn = vi.mocked(dataProvider.findUserById).mockResolvedValue({
49
+ id: 'usr-123',
50
+ username: 'JohnDoe111',
51
+ email: 'JohnDoe111@example.com',
52
+ });
53
+ defineRoute({
54
+ path: '/test',
55
+ handler: async ({ req }) => {
56
+ return {
57
+ status: 'ok',
58
+ user: req.user,
59
+ };
60
+ },
61
+ });
62
+ const app = await createApp({
63
+ plugins: [authPlugin],
64
+ });
65
+ const res = await request(app)
66
+ .get('/test')
67
+ .set('Authorization', 'Bearer 1234567890');
68
+ expect(decodeFn).toBeCalledWith('1234567890');
69
+ expect(findUserFn).toBeCalledTimes(1);
70
+ expect(findUserFn).toBeCalledWith('usr-123');
71
+ expect(res.status).toBe(200);
72
+ expect(res.body).toEqual({
73
+ status: 'ok',
74
+ user: {
75
+ id: 'usr-123',
76
+ username: 'JohnDoe111',
77
+ email: 'JohnDoe111@example.com',
78
+ },
79
+ });
80
+ });
81
+ it('should not attach a user if token is valid but user provider returns nothing', async () => {
82
+ const decodeFn = vi
83
+ .spyOn(tokenFns, 'validateAndDecodeAccessToken')
84
+ .mockReturnValue({ userId: 'usr-123' });
85
+ const findUserFn = vi
86
+ .mocked(dataProvider.findUserById)
87
+ .mockResolvedValue(undefined);
88
+ defineRoute({
89
+ path: '/test',
90
+ handler: async ({ req }) => {
91
+ return {
92
+ status: 'ok',
93
+ user: req.user,
94
+ };
95
+ },
96
+ });
97
+ const app = await createApp({
98
+ plugins: [authPlugin],
99
+ });
100
+ const res = await request(app)
101
+ .get('/test')
102
+ .set('Authorization', 'Bearer 1234567890');
103
+ expect(decodeFn).toBeCalledWith('1234567890');
104
+ expect(findUserFn).toBeCalledTimes(1);
105
+ expect(findUserFn).toBeCalledWith('usr-123');
106
+ expect(res.status).toBe(200);
107
+ expect(res.body).toEqual({
108
+ status: 'ok',
109
+ });
110
+ });
111
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,225 @@
1
+ import { describe, it, expect, beforeEach, vi, beforeAll, afterEach, } from 'vitest';
2
+ import jwt from 'jsonwebtoken';
3
+ import { setSettings } from '../../lib/settings.js';
4
+ import * as tokenFns from '../../lib/tokens.js';
5
+ import * as generators from '../../lib/generators.js';
6
+ describe('Tokens', () => {
7
+ const dataProvider = {
8
+ storeRefreshToken: vi.fn(),
9
+ findRefreshToken: vi.fn(),
10
+ markRefreshTokenAsUsed: vi.fn(),
11
+ revokeTokens: vi.fn(),
12
+ findUserById: vi.fn(),
13
+ findUserIdentifiers: vi.fn(),
14
+ };
15
+ beforeAll(() => {
16
+ setSettings({
17
+ keys: {
18
+ secretKey: 'secret_111222',
19
+ publicKey: 'public_111222',
20
+ },
21
+ issuer: 'auth.myapp.com',
22
+ accessTokenTTL: 3600,
23
+ dataProvider,
24
+ });
25
+ });
26
+ beforeEach(() => {
27
+ vi.useFakeTimers();
28
+ vi.setSystemTime(new Date('2025-01-01T00:00:00Z'));
29
+ vi.spyOn(generators, 'generateUUID').mockReturnValue('000-000-000-000-000');
30
+ });
31
+ afterEach(() => {
32
+ vi.resetAllMocks();
33
+ vi.useRealTimers();
34
+ });
35
+ describe('Function: createAccessTokenFromPayload()', () => {
36
+ it('should generate token and return expected data', () => {
37
+ // @ts-expect-error issues with jsonwebtoken types
38
+ const jwtSignFn = vi.spyOn(jwt, 'sign').mockReturnValue('acc-1111111111');
39
+ const output = tokenFns.createAccessTokenFromPayload({
40
+ sub: '101',
41
+ username: 'JaneDoe',
42
+ auth_time: 1735689600000,
43
+ });
44
+ expect(jwtSignFn).toHaveBeenCalledTimes(1);
45
+ expect(jwtSignFn).toHaveBeenCalledWith({
46
+ sub: '101',
47
+ username: 'JaneDoe',
48
+ auth_time: 1735689600000,
49
+ }, 'secret_111222', {
50
+ algorithm: 'RS256',
51
+ issuer: 'auth.myapp.com',
52
+ expiresIn: 3600,
53
+ jwtid: '000-000-000-000-000',
54
+ });
55
+ expect(output).toEqual({
56
+ jwtId: '000-000-000-000-000',
57
+ accessToken: 'acc-1111111111',
58
+ });
59
+ });
60
+ });
61
+ describe('Function: generateRefreshToken()', () => {
62
+ it('should return token and hashed token', () => {
63
+ const output = tokenFns.generateRefreshToken();
64
+ expect(output).toEqual({
65
+ token: expect.any(String),
66
+ tokenHash: expect.any(String),
67
+ });
68
+ });
69
+ });
70
+ describe('Function: generateTokensForUser()', () => {
71
+ it('should generate tokens and return expected data', async () => {
72
+ const storeRefreshTokenFn = vi
73
+ .mocked(dataProvider.storeRefreshToken)
74
+ .mockResolvedValue();
75
+ // @ts-expect-error issues with jsonwebtoken types
76
+ vi.spyOn(jwt, 'sign').mockReturnValue('acc-1111111111');
77
+ const output = await tokenFns.generateTokensForUser({
78
+ userId: '110',
79
+ username: 'JaneDoe',
80
+ });
81
+ expect(storeRefreshTokenFn).toHaveBeenCalledTimes(1);
82
+ expect(storeRefreshTokenFn).toHaveBeenCalledWith({
83
+ token: expect.any(String),
84
+ tokenHash: expect.any(String),
85
+ userId: '110',
86
+ jwtId: '000-000-000-000-000',
87
+ expiresAt: new Date('2025-01-15T00:00:00.000Z'),
88
+ });
89
+ expect(output).toEqual({
90
+ accessToken: 'acc-1111111111',
91
+ refreshToken: expect.any(String),
92
+ });
93
+ });
94
+ });
95
+ describe('Function: generateTokensFromRefreshToken()', () => {
96
+ beforeEach(() => {
97
+ // @ts-expect-error issues with jsonwebtoken types
98
+ vi.spyOn(jwt, 'verify').mockReturnValue({
99
+ sub: '110',
100
+ jti: 'jwt900',
101
+ });
102
+ });
103
+ it('should throw error if token not found', async () => {
104
+ const findRefreshTokenSpy = vi
105
+ .spyOn(dataProvider, 'findRefreshToken')
106
+ .mockResolvedValue(undefined);
107
+ await expect(tokenFns.generateTokensFromRefreshToken({
108
+ accessToken: 'acc100',
109
+ refreshToken: 'refr100',
110
+ })).rejects.toThrow('Invalid_Refresh_Token');
111
+ expect(findRefreshTokenSpy).toHaveBeenCalledTimes(1);
112
+ });
113
+ it('should throw error if token is revoked found', async () => {
114
+ const findRefreshTokenSpy = vi
115
+ .spyOn(dataProvider, 'findRefreshToken')
116
+ .mockResolvedValue({
117
+ userId: '110',
118
+ jwtId: 'jwt900',
119
+ expiresAt: new Date('2025-01-15T00:00:00.000Z'),
120
+ isUsed: false,
121
+ revokedAt: new Date(),
122
+ });
123
+ await expect(tokenFns.generateTokensFromRefreshToken({
124
+ accessToken: 'acc100',
125
+ refreshToken: 'refr100',
126
+ })).rejects.toThrow('Invalid_Refresh_Token');
127
+ expect(findRefreshTokenSpy).toHaveBeenCalledTimes(1);
128
+ });
129
+ it('should throw error if token is used', async () => {
130
+ const findRefreshTokenSpy = vi
131
+ .spyOn(dataProvider, 'findRefreshToken')
132
+ .mockResolvedValue({
133
+ userId: '110',
134
+ jwtId: 'jwt900',
135
+ expiresAt: new Date('2025-01-15T00:00:00.000Z'),
136
+ isUsed: true,
137
+ revokedAt: null,
138
+ });
139
+ await expect(tokenFns.generateTokensFromRefreshToken({
140
+ accessToken: 'acc100',
141
+ refreshToken: 'refr100',
142
+ })).rejects.toThrow('Invalid_Refresh_Token');
143
+ expect(findRefreshTokenSpy).toHaveBeenCalledTimes(1);
144
+ });
145
+ it('should throw error if userId mismatches', async () => {
146
+ const findRefreshTokenSpy = vi
147
+ .spyOn(dataProvider, 'findRefreshToken')
148
+ .mockResolvedValue({
149
+ userId: '200',
150
+ jwtId: 'jwt900',
151
+ expiresAt: new Date('2025-01-15T00:00:00.000Z'),
152
+ isUsed: false,
153
+ revokedAt: null,
154
+ });
155
+ await expect(tokenFns.generateTokensFromRefreshToken({
156
+ accessToken: 'acc100',
157
+ refreshToken: 'refr100',
158
+ })).rejects.toThrow('Invalid_Refresh_Token');
159
+ expect(findRefreshTokenSpy).toHaveBeenCalledTimes(1);
160
+ });
161
+ it('should throw error if jwtid mismatches', async () => {
162
+ const findRefreshTokenSpy = vi
163
+ .spyOn(dataProvider, 'findRefreshToken')
164
+ .mockResolvedValue({
165
+ userId: '110',
166
+ jwtId: 'jwt100',
167
+ expiresAt: new Date('2025-01-15T00:00:00.000Z'),
168
+ isUsed: false,
169
+ revokedAt: null,
170
+ });
171
+ await expect(tokenFns.generateTokensFromRefreshToken({
172
+ accessToken: 'acc100',
173
+ refreshToken: 'refr100',
174
+ })).rejects.toThrow('Invalid_Refresh_Token');
175
+ expect(findRefreshTokenSpy).toHaveBeenCalledTimes(1);
176
+ });
177
+ it('should throw error if refresh token is expired', async () => {
178
+ const findRefreshTokenSpy = vi
179
+ .spyOn(dataProvider, 'findRefreshToken')
180
+ .mockResolvedValue({
181
+ userId: '110',
182
+ jwtId: 'jwt900',
183
+ expiresAt: new Date('2024-12-31T00:00:00.000Z'),
184
+ isUsed: false,
185
+ revokedAt: null,
186
+ });
187
+ await expect(tokenFns.generateTokensFromRefreshToken({
188
+ accessToken: 'acc100',
189
+ refreshToken: 'refr100',
190
+ })).rejects.toThrow('Expired_Refresh_Token');
191
+ expect(findRefreshTokenSpy).toHaveBeenCalledTimes(1);
192
+ });
193
+ it('should generate new tokens if refresh token is valid', async () => {
194
+ // @ts-expect-error issues with jsonwebtoken types
195
+ vi.spyOn(jwt, 'sign').mockReturnValue('acc-1111111111');
196
+ const findRefreshTokenSpy = vi
197
+ .spyOn(dataProvider, 'findRefreshToken')
198
+ .mockResolvedValue({
199
+ userId: '110',
200
+ jwtId: 'jwt900',
201
+ expiresAt: new Date('2025-01-15T00:00:00.000Z'),
202
+ isUsed: false,
203
+ revokedAt: null,
204
+ });
205
+ const storeRefreshTokenFn = vi
206
+ .mocked(dataProvider.storeRefreshToken)
207
+ .mockResolvedValue();
208
+ const markRefreshTokenAsUsedFn = vi
209
+ .mocked(dataProvider.markRefreshTokenAsUsed)
210
+ .mockResolvedValue();
211
+ const output = await tokenFns.generateTokensFromRefreshToken({
212
+ accessToken: 'acc100',
213
+ refreshToken: 'refr100',
214
+ });
215
+ expect(findRefreshTokenSpy).toHaveBeenCalledTimes(1);
216
+ expect(storeRefreshTokenFn).toHaveBeenCalledTimes(1);
217
+ expect(markRefreshTokenAsUsedFn).toHaveBeenCalledTimes(1);
218
+ expect(output).toEqual({
219
+ userId: '110',
220
+ accessToken: 'acc-1111111111',
221
+ refreshToken: expect.any(String),
222
+ });
223
+ });
224
+ });
225
+ });
@@ -0,0 +1,9 @@
1
+ import type { Plugin } from 'dotpress';
2
+ import { AuthSettings, DataProvider, KeyPairSettings } from './types/types.js';
3
+ type Settings = Partial<AuthSettings> & {
4
+ keys: KeyPairSettings;
5
+ dataProvider: DataProvider;
6
+ };
7
+ export declare const configurePlugin: (settings: Settings) => Plugin;
8
+ export declare const generatePasswordHash: (password: string, salt?: number) => Promise<string>;
9
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ import { setSettings } from './lib/settings.js';
2
+ import { registerHandlers } from './lib/handlers.js';
3
+ import { attachAuthenticatedUser } from './lib/middleware.js';
4
+ import bcrypt from 'bcryptjs';
5
+ export const configurePlugin = (settings) => {
6
+ setSettings(settings);
7
+ return (plugin) => {
8
+ plugin.useBeforeRoutes((app) => {
9
+ app.use(attachAuthenticatedUser);
10
+ });
11
+ registerHandlers(plugin);
12
+ };
13
+ };
14
+ export const generatePasswordHash = (password, salt = 10) => {
15
+ return bcrypt.hash(password, salt);
16
+ };
@@ -0,0 +1 @@
1
+ export declare const generateUUID: () => `${string}-${string}-${string}-${string}-${string}`;
@@ -0,0 +1,4 @@
1
+ import { randomUUID } from 'crypto';
2
+ export const generateUUID = () => {
3
+ return randomUUID();
4
+ };
@@ -0,0 +1,2 @@
1
+ import type { PluginAPI } from 'dotpress';
2
+ export declare const registerHandlers: (plugin: PluginAPI) => void;
@@ -0,0 +1,131 @@
1
+ import { unauthorizedError, internalError } from 'dotpress';
2
+ import bcrypt from 'bcryptjs';
3
+ import { getSettings } from './settings.js';
4
+ import { getRequestIp } from './ip.js';
5
+ import { generateTokensForUser, generateTokensFromRefreshToken, } from './tokens.js';
6
+ export const registerHandlers = (plugin) => {
7
+ const authGroup = plugin.addGroup('/auth');
8
+ const settings = getSettings();
9
+ authGroup.defineRoute({
10
+ method: 'post',
11
+ path: '/login',
12
+ schema: (z) => ({
13
+ body: z.object({
14
+ username: z.string(),
15
+ password: z.string(),
16
+ client: z.string().optional(),
17
+ }),
18
+ response: z.object({
19
+ userId: z.string(),
20
+ accessToken: z.string(),
21
+ refreshToken: z.string(),
22
+ }),
23
+ }),
24
+ handler: async ({ req }) => {
25
+ const { username, password, client } = req.body;
26
+ const ipAddress = getRequestIp(req);
27
+ const user = await settings.dataProvider.findUserIdentifiers(username);
28
+ if (!user) {
29
+ if (settings.onLoginFailed) {
30
+ await settings.onLoginFailed({
31
+ username,
32
+ errorType: 'invalid_username',
33
+ client: client || '',
34
+ ipAddress: ipAddress || '',
35
+ });
36
+ }
37
+ return unauthorizedError('INVALID_LOGIN');
38
+ }
39
+ const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
40
+ if (!isPasswordValid) {
41
+ if (settings.onLoginFailed) {
42
+ await settings.onLoginFailed({
43
+ username,
44
+ errorType: 'invalid_password',
45
+ client: client || '',
46
+ ipAddress: ipAddress || '',
47
+ });
48
+ }
49
+ return unauthorizedError('INVALID_LOGIN');
50
+ }
51
+ const { accessToken, refreshToken } = await generateTokensForUser({
52
+ username,
53
+ userId: user.userId,
54
+ });
55
+ if (settings.onLoginSuccess) {
56
+ await settings.onLoginSuccess({
57
+ userId: user.userId,
58
+ username,
59
+ client: client || '',
60
+ ipAddress: ipAddress || '',
61
+ });
62
+ }
63
+ return {
64
+ userId: user.userId,
65
+ accessToken,
66
+ refreshToken,
67
+ };
68
+ },
69
+ });
70
+ authGroup.defineRoute({
71
+ method: 'post',
72
+ path: '/refresh',
73
+ schema: (z) => ({
74
+ body: z.object({
75
+ accessToken: z.string(),
76
+ refreshToken: z.string(),
77
+ }),
78
+ response: z.object({
79
+ userId: z.string(),
80
+ accessToken: z.string(),
81
+ refreshToken: z.string(),
82
+ }),
83
+ }),
84
+ handler: async ({ req }) => {
85
+ const { accessToken, refreshToken } = req.body;
86
+ try {
87
+ const tokens = await generateTokensFromRefreshToken({
88
+ accessToken,
89
+ refreshToken,
90
+ });
91
+ return {
92
+ userId: tokens.userId,
93
+ accessToken: tokens.accessToken,
94
+ refreshToken: tokens.refreshToken,
95
+ };
96
+ }
97
+ catch (e) {
98
+ const errorMessage = e.message;
99
+ if (errorMessage === 'Invalid_Refresh_Token') {
100
+ return unauthorizedError('INVALID_REFRESH_TOKEN');
101
+ }
102
+ if (errorMessage === 'Expired_Refresh_Token') {
103
+ return unauthorizedError('EXPIRED_REFRESH_TOKEN');
104
+ }
105
+ return internalError('Can not refresh tokens.');
106
+ }
107
+ },
108
+ });
109
+ authGroup.defineRoute({
110
+ method: 'get',
111
+ path: '/me',
112
+ handler: async ({ req }) => {
113
+ if (!req.user) {
114
+ return unauthorizedError();
115
+ }
116
+ return req.user;
117
+ },
118
+ });
119
+ authGroup.defineRoute({
120
+ method: 'post',
121
+ path: '/logout',
122
+ handler: async ({ req }) => {
123
+ const userId = req.user?.id || '';
124
+ if (!userId) {
125
+ return unauthorizedError();
126
+ }
127
+ await settings.dataProvider.revokeTokens(userId);
128
+ return { success: true };
129
+ },
130
+ });
131
+ };
@@ -0,0 +1,2 @@
1
+ import type { Request } from 'express';
2
+ export declare const getRequestIp: (req: Request) => string | null;
package/dist/lib/ip.js ADDED
@@ -0,0 +1,15 @@
1
+ import requestIp from 'request-ip';
2
+ export const getRequestIp = (req) => {
3
+ const clientIp = requestIp.getClientIp(req);
4
+ const localIps = [
5
+ '127.0.0.1',
6
+ 'localhost',
7
+ '::1',
8
+ '::ffff',
9
+ '::ffff:127.0.0.1',
10
+ ];
11
+ if (clientIp && localIps.includes(clientIp)) {
12
+ return null;
13
+ }
14
+ return clientIp;
15
+ };
@@ -0,0 +1,2 @@
1
+ import type { Request, Response, NextFunction } from 'express';
2
+ export declare const attachAuthenticatedUser: (req: Request, _res: Response, next: NextFunction) => Promise<void>;
@@ -0,0 +1,24 @@
1
+ import { getSettings } from './settings.js';
2
+ import { validateAndDecodeAccessToken } from './tokens.js';
3
+ export const attachAuthenticatedUser = async (req, _res, next) => {
4
+ const accessToken = req.headers && req.headers.authorization
5
+ ? req.headers.authorization.replace('Bearer ', '')
6
+ : '';
7
+ if (!accessToken) {
8
+ next();
9
+ return;
10
+ }
11
+ try {
12
+ const settings = getSettings();
13
+ const { userId } = validateAndDecodeAccessToken(accessToken);
14
+ const user = await settings.dataProvider.findUserById(userId);
15
+ if (user) {
16
+ req.user = user;
17
+ }
18
+ next();
19
+ }
20
+ catch (e) {
21
+ console.warn(e);
22
+ next();
23
+ }
24
+ };
@@ -0,0 +1,3 @@
1
+ import { PluginSettings } from '../types/types.js';
2
+ export declare const setSettings: (settings: Partial<PluginSettings>) => void;
3
+ export declare const getSettings: () => PluginSettings;
@@ -0,0 +1,42 @@
1
+ let pluginSettings = {
2
+ accessTokenTTL: 3600,
3
+ refreshTokenTTLInDays: 14,
4
+ issuer: '',
5
+ keys: {
6
+ secretKey: '',
7
+ publicKey: '',
8
+ },
9
+ dataProvider: {
10
+ storeRefreshToken: () => {
11
+ throw new Error('storeRefreshToken() is not implemented.');
12
+ },
13
+ findRefreshToken: () => {
14
+ throw new Error('findRefreshToken() is not implemented.');
15
+ },
16
+ markRefreshTokenAsUsed: () => {
17
+ throw new Error('markRefreshTokenAsUsed() is not implemented.');
18
+ },
19
+ revokeTokens: () => {
20
+ throw new Error('revokeTokens() is not implemented.');
21
+ },
22
+ findUserById: () => {
23
+ throw new Error('findUserById() is not implemented.');
24
+ },
25
+ findUserIdentifiers: () => {
26
+ throw new Error('findUserByUsername() is not implemented.');
27
+ },
28
+ },
29
+ onLoginFailed: () => {
30
+ return Promise.resolve();
31
+ },
32
+ onLoginSuccess: () => {
33
+ return Promise.resolve();
34
+ },
35
+ };
36
+ export const setSettings = (settings) => {
37
+ pluginSettings = {
38
+ ...pluginSettings,
39
+ ...settings,
40
+ };
41
+ };
42
+ export const getSettings = () => pluginSettings;
@@ -0,0 +1,35 @@
1
+ import { TokenPair } from '../types/types.js';
2
+ type CreateTokenPayload = {
3
+ sub: string;
4
+ username: string;
5
+ auth_time: number;
6
+ };
7
+ type CreatedAccessToken = {
8
+ jwtId: string;
9
+ accessToken: string;
10
+ };
11
+ type CreatedRefreshToken = {
12
+ token: string;
13
+ tokenHash: string;
14
+ };
15
+ type UserIdentifiers = {
16
+ userId: string;
17
+ username: string;
18
+ };
19
+ export declare const createAccessTokenFromPayload: (payload: CreateTokenPayload) => CreatedAccessToken;
20
+ export declare const generateRefreshToken: () => CreatedRefreshToken;
21
+ /**
22
+ * Generate a pair of access and refresh tokens for a given user
23
+ */
24
+ export declare const generateTokensForUser: ({ userId, username, }: UserIdentifiers) => Promise<TokenPair>;
25
+ /**
26
+ * Regenerate a pair of token using a refresh token
27
+ * Expired access token must be provided in order to validate userId and jwtId
28
+ */
29
+ export declare const generateTokensFromRefreshToken: ({ accessToken, refreshToken, }: TokenPair) => Promise<TokenPair & {
30
+ userId: string;
31
+ }>;
32
+ export declare const validateAndDecodeAccessToken: (accessToken: string) => {
33
+ userId: string;
34
+ };
35
+ export {};
@@ -0,0 +1,107 @@
1
+ import jwt from 'jsonwebtoken';
2
+ import { randomBytes, createHash } from 'crypto';
3
+ import { getSettings } from './settings.js';
4
+ import { generateUUID } from './generators.js';
5
+ export const createAccessTokenFromPayload = (payload) => {
6
+ const jwtId = generateUUID();
7
+ const settings = getSettings();
8
+ const accessToken = jwt.sign(payload, settings.keys.secretKey, {
9
+ algorithm: 'RS256',
10
+ issuer: settings.issuer,
11
+ expiresIn: settings.accessTokenTTL,
12
+ jwtid: jwtId,
13
+ });
14
+ return {
15
+ jwtId,
16
+ accessToken,
17
+ };
18
+ };
19
+ export const generateRefreshToken = () => {
20
+ const token = randomBytes(128).toString('hex');
21
+ const tokenHash = createHash('sha256').update(token).digest('hex');
22
+ return {
23
+ token,
24
+ tokenHash,
25
+ };
26
+ };
27
+ /**
28
+ * Generate a pair of access and refresh tokens for a given user
29
+ */
30
+ export const generateTokensForUser = async ({ userId, username, }) => {
31
+ const settings = getSettings();
32
+ const { jwtId, accessToken } = createAccessTokenFromPayload({
33
+ sub: userId,
34
+ username,
35
+ auth_time: Math.floor(Date.now() / 1000),
36
+ });
37
+ const { token: refreshToken, tokenHash: refreshTokenHash } = generateRefreshToken();
38
+ await settings.dataProvider.storeRefreshToken({
39
+ token: refreshToken,
40
+ tokenHash: refreshTokenHash,
41
+ userId,
42
+ jwtId,
43
+ expiresAt: addDaysFromNow(settings.refreshTokenTTLInDays),
44
+ });
45
+ return {
46
+ accessToken,
47
+ refreshToken,
48
+ };
49
+ };
50
+ const addDaysFromNow = (days) => {
51
+ const now = new Date();
52
+ now.setDate(now.getDate() + days);
53
+ return now;
54
+ };
55
+ /**
56
+ * Regenerate a pair of token using a refresh token
57
+ * Expired access token must be provided in order to validate userId and jwtId
58
+ */
59
+ export const generateTokensFromRefreshToken = async ({ accessToken, refreshToken, }) => {
60
+ const settings = getSettings();
61
+ const decodedToken = jwt.verify(accessToken, settings.keys.publicKey, {
62
+ ignoreExpiration: true,
63
+ });
64
+ const userId = String(decodedToken.sub);
65
+ const refreshTokenHash = createHash('sha256')
66
+ .update(refreshToken)
67
+ .digest('hex');
68
+ const refreshTokenData = await settings.dataProvider.findRefreshToken(refreshTokenHash);
69
+ if (!refreshTokenData ||
70
+ refreshTokenData.revokedAt ||
71
+ refreshTokenData.isUsed ||
72
+ refreshTokenData.userId !== userId ||
73
+ refreshTokenData.jwtId !== decodedToken.jti) {
74
+ throw new Error('Invalid_Refresh_Token');
75
+ }
76
+ if (refreshTokenData.expiresAt.getTime() <= Date.now()) {
77
+ throw new Error('Expired_Refresh_Token');
78
+ }
79
+ const { jwtId, accessToken: newAccessToken } = createAccessTokenFromPayload({
80
+ sub: userId,
81
+ username: decodedToken.username,
82
+ auth_time: decodedToken.auth_time,
83
+ });
84
+ const newRefreshToken = generateRefreshToken();
85
+ await settings.dataProvider.storeRefreshToken({
86
+ ...newRefreshToken,
87
+ userId,
88
+ jwtId,
89
+ expiresAt: addDaysFromNow(settings.refreshTokenTTLInDays),
90
+ });
91
+ await settings.dataProvider.markRefreshTokenAsUsed(refreshTokenHash);
92
+ return {
93
+ userId,
94
+ accessToken: newAccessToken,
95
+ refreshToken: newRefreshToken.token,
96
+ };
97
+ };
98
+ export const validateAndDecodeAccessToken = (accessToken) => {
99
+ const settings = getSettings();
100
+ const { sub: userId } = jwt.verify(accessToken, settings.keys.publicKey);
101
+ if (!userId) {
102
+ throw new Error('Invalid_Access_Token');
103
+ }
104
+ return {
105
+ userId: String(userId),
106
+ };
107
+ };
@@ -0,0 +1,74 @@
1
+ import type { JwtPayload } from 'jsonwebtoken';
2
+ declare global {
3
+ namespace Express {
4
+ interface Request {
5
+ user?: unknown;
6
+ }
7
+ }
8
+ }
9
+ export type KeyPairSettings = {
10
+ secretKey: string;
11
+ publicKey: string;
12
+ };
13
+ export type AuthSettings = {
14
+ accessTokenTTL: number;
15
+ refreshTokenTTLInDays: number;
16
+ issuer: string;
17
+ onLoginFailed?: (e: LoginFailedEvent) => Promise<void>;
18
+ onLoginSuccess?: (e: LoginSuccessEvent) => Promise<void>;
19
+ };
20
+ type LoginFailedEvent = {
21
+ username: string;
22
+ errorType: 'invalid_username' | 'invalid_password' | string;
23
+ client: string;
24
+ ipAddress: string;
25
+ };
26
+ type LoginSuccessEvent = {
27
+ userId: string;
28
+ username: string;
29
+ client: string;
30
+ ipAddress: string;
31
+ };
32
+ export type PluginSettings = AuthSettings & {
33
+ keys: KeyPairSettings;
34
+ dataProvider: DataProvider;
35
+ };
36
+ export interface AppJwtPayload extends JwtPayload {
37
+ username: string;
38
+ auth_time: number;
39
+ }
40
+ export type TokenPair = {
41
+ accessToken: string;
42
+ refreshToken: string;
43
+ };
44
+ export type CreateTokenPayload = {
45
+ sub: string;
46
+ username: string;
47
+ auth_time: number;
48
+ };
49
+ export type StoreRefreshTokenDto = {
50
+ token: string;
51
+ tokenHash: string;
52
+ userId: string;
53
+ jwtId: string;
54
+ expiresAt: Date;
55
+ };
56
+ export type RefreshTokenData = {
57
+ userId: string;
58
+ jwtId: string;
59
+ expiresAt: Date;
60
+ isUsed: boolean;
61
+ revokedAt?: Date | null;
62
+ };
63
+ export type DataProvider = {
64
+ storeRefreshToken: (payload: StoreRefreshTokenDto) => Promise<void>;
65
+ findRefreshToken: (tokenHash: string) => Promise<RefreshTokenData | undefined>;
66
+ markRefreshTokenAsUsed: (tokenHash: string) => Promise<void>;
67
+ revokeTokens: (userId: string) => Promise<void>;
68
+ findUserById: <TUser = Record<string, unknown>>(userId: string) => Promise<TUser | undefined>;
69
+ findUserIdentifiers: (username: string) => Promise<{
70
+ userId: string;
71
+ passwordHash: string;
72
+ } | undefined>;
73
+ };
74
+ export {};
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "@dotpress/auth",
3
+ "version": "1.0.0",
4
+ "description": "JWT Authentication plugin for Dotpress library",
5
+ "author": "Renaud Guyon",
6
+ "license": "MIT",
7
+ "homepage": "https://dotpress.dev",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/renoguyon/dotpress-auth-plugin"
11
+ },
12
+ "type": "module",
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "main": "dist/index.js",
17
+ "types": "dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "import": "./dist/index.js",
21
+ "require": "./dist/index.js",
22
+ "types": "./dist/index.d.ts"
23
+ }
24
+ },
25
+ "scripts": {
26
+ "build": "tsc",
27
+ "lint": "eslint ./src",
28
+ "lint:fix": "eslint ./src --fix",
29
+ "test": "vitest run",
30
+ "test:ui": "vitest --ui",
31
+ "test:watch": "vitest watch"
32
+ },
33
+ "peerDependencies": {
34
+ "dotpress": "^1",
35
+ "zod": "^3"
36
+ },
37
+ "dependencies": {
38
+ "bcryptjs": "^3.0.2",
39
+ "jsonwebtoken": "^9.0.2",
40
+ "request-ip": "^3.3.0"
41
+ },
42
+ "devDependencies": {
43
+ "@eslint/js": "^9.24.0",
44
+ "@types/bcrypt": "^5.0.2",
45
+ "@types/express": "^4.17.21",
46
+ "@types/jsonwebtoken": "^9.0.9",
47
+ "@types/request-ip": "^0.0.41",
48
+ "@types/supertest": "^6.0.3",
49
+ "@vitest/ui": "^3.1.1",
50
+ "dotpress": "^1",
51
+ "eslint": "^9.24.0",
52
+ "eslint-config-prettier": "^10.1.1",
53
+ "eslint-plugin-prettier": "^5.2.6",
54
+ "prettier": "^3.5.3",
55
+ "supertest": "^7.1.0",
56
+ "typescript": "^5.8.3",
57
+ "typescript-eslint": "^8.29.1",
58
+ "vitest": "^3.1.2"
59
+ },
60
+ "keywords": [
61
+ "express",
62
+ "typescript",
63
+ "auth",
64
+ "authentication",
65
+ "node",
66
+ "backend",
67
+ "rest",
68
+ "jwt",
69
+ "plugin",
70
+ "dotpress"
71
+ ]
72
+ }