@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 +177 -0
- package/dist/__tests__/functional/handlers.test.d.ts +1 -0
- package/dist/__tests__/functional/handlers.test.js +235 -0
- package/dist/__tests__/functional/middlewares.test.d.ts +1 -0
- package/dist/__tests__/functional/middlewares.test.js +111 -0
- package/dist/__tests__/unit/tokens.test.d.ts +1 -0
- package/dist/__tests__/unit/tokens.test.js +225 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +16 -0
- package/dist/lib/generators.d.ts +1 -0
- package/dist/lib/generators.js +4 -0
- package/dist/lib/handlers.d.ts +2 -0
- package/dist/lib/handlers.js +131 -0
- package/dist/lib/ip.d.ts +2 -0
- package/dist/lib/ip.js +15 -0
- package/dist/lib/middleware.d.ts +2 -0
- package/dist/lib/middleware.js +24 -0
- package/dist/lib/settings.d.ts +3 -0
- package/dist/lib/settings.js +42 -0
- package/dist/lib/tokens.d.ts +35 -0
- package/dist/lib/tokens.js +107 -0
- package/dist/types/types.d.ts +74 -0
- package/dist/types/types.js +1 -0
- package/package.json +72 -0
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
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -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,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
|
+
};
|
package/dist/lib/ip.d.ts
ADDED
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,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,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
|
+
}
|