@mantiq/auth 0.5.23 → 0.6.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/package.json
CHANGED
package/src/HasApiTokens.ts
CHANGED
|
@@ -7,6 +7,14 @@ export function applyHasApiTokens(ModelClass: any): void {
|
|
|
7
7
|
const proto = ModelClass.prototype
|
|
8
8
|
|
|
9
9
|
proto.createToken = async function(name: string, abilities: string[] = ['*'], expiresAt?: Date): Promise<NewAccessToken> {
|
|
10
|
+
// #212: Default empty abilities to ['*'] (full access) and deduplicate.
|
|
11
|
+
// An empty abilities array would silently deny all permission checks,
|
|
12
|
+
// which is almost certainly not the caller's intent.
|
|
13
|
+
if (abilities.length === 0) {
|
|
14
|
+
abilities = ['*']
|
|
15
|
+
}
|
|
16
|
+
abilities = [...new Set(abilities)]
|
|
17
|
+
|
|
10
18
|
// Generate 64 random hex characters
|
|
11
19
|
const randomBytes = new Uint8Array(32)
|
|
12
20
|
crypto.getRandomValues(randomBytes)
|
|
@@ -4,6 +4,7 @@ import type { UserProvider } from '../contracts/UserProvider.ts'
|
|
|
4
4
|
import type { MantiqRequest, EventDispatcher } from '@mantiq/core'
|
|
5
5
|
import type { Encrypter } from '@mantiq/core'
|
|
6
6
|
import { Attempting, Authenticated, Login as LoginEvent, Failed, Logout as LogoutEvent } from '../events/AuthEvents.ts'
|
|
7
|
+
import { timingSafeEqual } from 'node:crypto'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Session-based authentication guard.
|
|
@@ -22,7 +23,7 @@ export class SessionGuard implements StatefulGuard {
|
|
|
22
23
|
private _request: MantiqRequest | null = null
|
|
23
24
|
|
|
24
25
|
/** Pending remember cookie data (set during login, read by middleware). */
|
|
25
|
-
private _pendingRememberCookie: { id: string | number; token: string
|
|
26
|
+
private _pendingRememberCookie: { id: string | number; token: string } | null = null
|
|
26
27
|
/** Flag to clear the remember cookie (set during logout). */
|
|
27
28
|
private _clearRememberCookie = false
|
|
28
29
|
|
|
@@ -67,6 +68,11 @@ export class SessionGuard implements StatefulGuard {
|
|
|
67
68
|
this._user = await this.recallFromCookie()
|
|
68
69
|
if (this._user) {
|
|
69
70
|
this._viaRemember = true
|
|
71
|
+
|
|
72
|
+
// #207: Regenerate session before storing user ID to prevent
|
|
73
|
+
// session fixation attacks when logging in via remember cookie.
|
|
74
|
+
await request.session().regenerate(true)
|
|
75
|
+
|
|
70
76
|
// Re-store in session so subsequent requests don't need the cookie
|
|
71
77
|
this.updateSession(this._user.getAuthIdentifier())
|
|
72
78
|
request.setUser(this._user as any)
|
|
@@ -200,8 +206,13 @@ export class SessionGuard implements StatefulGuard {
|
|
|
200
206
|
|
|
201
207
|
/**
|
|
202
208
|
* Get pending remember cookie data (read by middleware to set cookie).
|
|
209
|
+
*
|
|
210
|
+
* #166: Cookie value format is now `userId|rememberToken` (no password hash).
|
|
211
|
+
* The password hash was previously included but exposed sensitive material
|
|
212
|
+
* in the cookie. Validation now relies solely on the remember token stored
|
|
213
|
+
* in the database, which is cycled on logout and password change.
|
|
203
214
|
*/
|
|
204
|
-
getPendingRememberCookie(): { id: string | number; token: string
|
|
215
|
+
getPendingRememberCookie(): { id: string | number; token: string } | null {
|
|
205
216
|
return this._pendingRememberCookie
|
|
206
217
|
}
|
|
207
218
|
|
|
@@ -219,6 +230,24 @@ export class SessionGuard implements StatefulGuard {
|
|
|
219
230
|
return this.name
|
|
220
231
|
}
|
|
221
232
|
|
|
233
|
+
// ── Public security helpers ───────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* #196: Generate a new remember token and save it to the database.
|
|
237
|
+
*
|
|
238
|
+
* Call this method when a user changes their password to invalidate
|
|
239
|
+
* all existing remember cookies. Example usage in a password update
|
|
240
|
+
* controller:
|
|
241
|
+
*
|
|
242
|
+
* await guard.cycleRememberToken(user)
|
|
243
|
+
*
|
|
244
|
+
* This is also called internally during logout().
|
|
245
|
+
*/
|
|
246
|
+
async cycleRememberToken(user: Authenticatable): Promise<void> {
|
|
247
|
+
const token = generateRandomToken(60)
|
|
248
|
+
await this.provider.updateRememberToken(user, token)
|
|
249
|
+
}
|
|
250
|
+
|
|
222
251
|
// ── Internal ────────────────────────────────────────────────────────────
|
|
223
252
|
|
|
224
253
|
private getRequest(): MantiqRequest {
|
|
@@ -245,56 +274,93 @@ export class SessionGuard implements StatefulGuard {
|
|
|
245
274
|
}
|
|
246
275
|
}
|
|
247
276
|
|
|
248
|
-
/**
|
|
249
|
-
* Generate a new remember token and save it to the database.
|
|
250
|
-
*/
|
|
251
|
-
private async cycleRememberToken(user: Authenticatable): Promise<void> {
|
|
252
|
-
const token = generateRandomToken(60)
|
|
253
|
-
await this.provider.updateRememberToken(user, token)
|
|
254
|
-
}
|
|
255
|
-
|
|
256
277
|
/**
|
|
257
278
|
* Queue the remember cookie for the middleware to set.
|
|
258
|
-
*
|
|
279
|
+
*
|
|
280
|
+
* #166: Cookie value format: `userId|rememberToken` — password hash removed.
|
|
281
|
+
* #208: Refuse to queue the cookie if the encrypter is not available,
|
|
282
|
+
* unless we want to silently expose tokens in plaintext cookies.
|
|
259
283
|
*/
|
|
260
284
|
private queueRememberCookie(user: Authenticatable): void {
|
|
285
|
+
// #208: Warn and refuse to send remember cookie without encryption.
|
|
286
|
+
// Sending a plaintext remember token in a cookie allows any network
|
|
287
|
+
// observer to hijack the session. Require encryption or explicit opt-in.
|
|
288
|
+
if (!this.encrypter) {
|
|
289
|
+
console.warn(
|
|
290
|
+
`[mantiq/auth] SessionGuard "${this.name}": Cannot set remember cookie — ` +
|
|
291
|
+
`no Encrypter is available. The remember cookie would be sent unencrypted, ` +
|
|
292
|
+
`exposing the token to network observers. Configure an Encrypter or disable ` +
|
|
293
|
+
`the remember me feature.`
|
|
294
|
+
)
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
|
|
261
298
|
this._pendingRememberCookie = {
|
|
262
299
|
id: user.getAuthIdentifier(),
|
|
263
300
|
token: user.getRememberToken()!,
|
|
264
|
-
hash: user.getAuthPassword(),
|
|
265
301
|
}
|
|
266
302
|
}
|
|
267
303
|
|
|
268
304
|
/**
|
|
269
305
|
* Attempt to recall the user from the remember me cookie.
|
|
306
|
+
*
|
|
307
|
+
* #215: Validates cookie format strictly — userId must be numeric,
|
|
308
|
+
* token must be a hex string of at least 40 characters.
|
|
309
|
+
* #166: Cookie format is now `userId|rememberToken` (2 parts, no password hash).
|
|
270
310
|
*/
|
|
271
311
|
private async recallFromCookie(): Promise<Authenticatable | null> {
|
|
272
312
|
const request = this.getRequest()
|
|
273
313
|
const cookieValue = request.cookie(this.getRememberCookieName())
|
|
274
314
|
if (!cookieValue) return null
|
|
275
315
|
|
|
276
|
-
// Cookie format: userId|rememberToken
|
|
316
|
+
// Cookie format: userId|rememberToken
|
|
277
317
|
const parts = cookieValue.split('|')
|
|
278
|
-
if (parts.length !== 3) return null
|
|
279
318
|
|
|
280
|
-
|
|
319
|
+
// #166: Accept both old 3-part format (for migration) and new 2-part format
|
|
320
|
+
if (parts.length !== 2 && parts.length !== 3) return null
|
|
321
|
+
|
|
322
|
+
const [userId, token] = parts
|
|
281
323
|
|
|
282
324
|
if (!userId || !token) return null
|
|
283
325
|
|
|
326
|
+
// #215: Validate userId is numeric to prevent injection
|
|
327
|
+
if (!/^\d+$/.test(userId)) return null
|
|
328
|
+
|
|
329
|
+
// #215: Validate token is a hex string of expected length (at least 40 chars)
|
|
330
|
+
// to reject obviously malformed or tampered cookies early
|
|
331
|
+
if (!/^[0-9a-f]{40,}$/i.test(token)) return null
|
|
332
|
+
|
|
284
333
|
const user = await this.provider.retrieveByToken(
|
|
285
|
-
|
|
334
|
+
Number(userId),
|
|
286
335
|
token,
|
|
287
336
|
)
|
|
288
337
|
|
|
289
338
|
if (!user) return null
|
|
290
339
|
|
|
291
|
-
// Validate the
|
|
292
|
-
|
|
340
|
+
// #166: Validate the remember token using constant-time comparison
|
|
341
|
+
// to prevent timing side-channel attacks on the token value.
|
|
342
|
+
const storedToken = user.getRememberToken()
|
|
343
|
+
if (!storedToken || !constantTimeEqual(token, storedToken)) return null
|
|
293
344
|
|
|
294
345
|
return user
|
|
295
346
|
}
|
|
296
347
|
}
|
|
297
348
|
|
|
349
|
+
/**
|
|
350
|
+
* Constant-time string comparison using node:crypto's timingSafeEqual.
|
|
351
|
+
* Prevents timing side-channel attacks on token comparisons.
|
|
352
|
+
*/
|
|
353
|
+
function constantTimeEqual(a: string, b: string): boolean {
|
|
354
|
+
if (a.length !== b.length) return false
|
|
355
|
+
try {
|
|
356
|
+
const bufA = Buffer.from(a, 'utf-8')
|
|
357
|
+
const bufB = Buffer.from(b, 'utf-8')
|
|
358
|
+
return timingSafeEqual(bufA, bufB)
|
|
359
|
+
} catch {
|
|
360
|
+
return false
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
298
364
|
/**
|
|
299
365
|
* Generate a random hex token of the given length.
|
|
300
366
|
*/
|
package/src/guards/TokenGuard.ts
CHANGED
|
@@ -4,7 +4,20 @@ import type { UserProvider } from '../contracts/UserProvider.ts'
|
|
|
4
4
|
import type { MantiqRequest } from '@mantiq/core'
|
|
5
5
|
import { PersonalAccessToken } from '../models/PersonalAccessToken.ts'
|
|
6
6
|
import { sha256 } from '../helpers/hash.ts'
|
|
7
|
-
|
|
7
|
+
import { timingSafeEqual } from 'node:crypto'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Bearer-token authentication guard.
|
|
11
|
+
*
|
|
12
|
+
* SECURITY NOTE: Rate limiting is NOT applied at the guard level.
|
|
13
|
+
* Consumers MUST apply rate-limit middleware (e.g. ThrottleMiddleware)
|
|
14
|
+
* to routes protected by this guard to prevent brute-force token
|
|
15
|
+
* enumeration attacks. Example:
|
|
16
|
+
*
|
|
17
|
+
* router.group({ middleware: ['throttle:60,1'] }, () => {
|
|
18
|
+
* router.get('/api/user', ...)
|
|
19
|
+
* })
|
|
20
|
+
*/
|
|
8
21
|
export class TokenGuard implements Guard {
|
|
9
22
|
private _user: Authenticatable | null = null
|
|
10
23
|
private _request: MantiqRequest | null = null
|
|
@@ -36,9 +49,6 @@ export class TokenGuard implements Guard {
|
|
|
36
49
|
const token = await this.resolveToken(bearerToken)
|
|
37
50
|
if (!token) return null
|
|
38
51
|
|
|
39
|
-
// Check expiration
|
|
40
|
-
if (token.isExpired()) return null
|
|
41
|
-
|
|
42
52
|
// Optionally track last usage (disabled by default to avoid write-per-request)
|
|
43
53
|
if (this.trackLastUsed) {
|
|
44
54
|
token.setAttribute('last_used_at', new Date().toISOString())
|
|
@@ -85,24 +95,69 @@ export class TokenGuard implements Guard {
|
|
|
85
95
|
this._resolved = false
|
|
86
96
|
}
|
|
87
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Validate that a bearer token string matches the expected format.
|
|
100
|
+
* Expected format: `{numericId}|{hexPlaintext}` where plaintext is at
|
|
101
|
+
* least 40 hex characters. Rejects malformed tokens early to avoid
|
|
102
|
+
* unnecessary DB lookups and hash computation.
|
|
103
|
+
*/
|
|
104
|
+
private isValidTokenFormat(id: string, plaintext: string): boolean {
|
|
105
|
+
// id must be a positive integer
|
|
106
|
+
if (!/^\d+$/.test(id)) return false
|
|
107
|
+
// plaintext must be a hex string of at least 40 characters
|
|
108
|
+
if (!/^[0-9a-f]{40,}$/i.test(plaintext)) return false
|
|
109
|
+
return true
|
|
110
|
+
}
|
|
111
|
+
|
|
88
112
|
private async resolveToken(bearerToken: string): Promise<PersonalAccessToken | null> {
|
|
89
113
|
const parts = bearerToken.split('|')
|
|
90
114
|
|
|
91
115
|
if (parts.length === 2) {
|
|
92
116
|
// Format: {id}|{plaintext}
|
|
93
117
|
const [id, plaintext] = parts
|
|
118
|
+
|
|
119
|
+
// #201: Validate token format before any DB lookup or hashing
|
|
120
|
+
if (!id || !plaintext || !this.isValidTokenFormat(id, plaintext)) return null
|
|
121
|
+
|
|
94
122
|
const token = await PersonalAccessToken.find(Number(id))
|
|
95
123
|
if (!token) return null
|
|
96
124
|
|
|
97
|
-
|
|
125
|
+
// #206: Check expiration before doing the expensive hash comparison.
|
|
126
|
+
// This avoids wasting CPU on tokens that are already expired.
|
|
127
|
+
if (token.isExpired()) return null
|
|
128
|
+
|
|
129
|
+
const hash = await sha256(plaintext)
|
|
98
130
|
const storedHash = token.getAttribute('token') as string
|
|
99
|
-
|
|
131
|
+
|
|
132
|
+
// #200: Use constant-time comparison to prevent timing side-channel attacks
|
|
133
|
+
// that could allow an attacker to guess the token hash byte-by-byte.
|
|
134
|
+
if (!constantTimeEqual(hash, storedHash)) return null
|
|
100
135
|
|
|
101
136
|
return token
|
|
102
137
|
}
|
|
103
138
|
|
|
104
139
|
// Fallback: hash the entire string and search by token hash
|
|
105
140
|
const hash = await sha256(bearerToken)
|
|
106
|
-
|
|
141
|
+
const token = await PersonalAccessToken.where('token', hash).first() as PersonalAccessToken | null
|
|
142
|
+
|
|
143
|
+
// #206: Check expiration for fallback path as well
|
|
144
|
+
if (token && token.isExpired()) return null
|
|
145
|
+
|
|
146
|
+
return token
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Constant-time string comparison using node:crypto's timingSafeEqual.
|
|
152
|
+
* Prevents timing side-channel attacks on hash comparisons.
|
|
153
|
+
*/
|
|
154
|
+
function constantTimeEqual(a: string, b: string): boolean {
|
|
155
|
+
if (a.length !== b.length) return false
|
|
156
|
+
try {
|
|
157
|
+
const bufA = Buffer.from(a, 'utf-8')
|
|
158
|
+
const bufB = Buffer.from(b, 'utf-8')
|
|
159
|
+
return timingSafeEqual(bufA, bufB)
|
|
160
|
+
} catch {
|
|
161
|
+
return false
|
|
107
162
|
}
|
|
108
163
|
}
|
|
@@ -75,9 +75,10 @@ export class Authenticate implements Middleware {
|
|
|
75
75
|
const headers = new Headers(response.headers)
|
|
76
76
|
|
|
77
77
|
// Set remember cookie
|
|
78
|
+
// #166: Cookie format is now userId|token (no password hash)
|
|
78
79
|
const pending = guard.getPendingRememberCookie()
|
|
79
80
|
if (pending) {
|
|
80
|
-
const cookieValue = `${pending.id}|${pending.token}
|
|
81
|
+
const cookieValue = `${pending.id}|${pending.token}`
|
|
81
82
|
headers.append(
|
|
82
83
|
'Set-Cookie',
|
|
83
84
|
serializeCookie(guard.getRememberCookieName(), cookieValue, {
|