@mantiq/auth 0.5.23 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mantiq/auth",
3
- "version": "0.5.23",
3
+ "version": "0.6.1",
4
4
  "description": "Session & token auth, guards, providers",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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; hash: string } | null = null
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; hash: string } | null {
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
- * Cookie value format: userId|rememberToken|passwordHash
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|passwordHash
316
+ // Cookie format: userId|rememberToken
277
317
  const parts = cookieValue.split('|')
278
- if (parts.length !== 3) return null
279
318
 
280
- const [userId, token, hash] = parts
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
- isNaN(Number(userId)) ? userId : Number(userId),
334
+ Number(userId),
286
335
  token,
287
336
  )
288
337
 
289
338
  if (!user) return null
290
339
 
291
- // Validate the password hash hasn't changed (tamper detection)
292
- if (hash !== user.getAuthPassword()) return null
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
  */
@@ -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
- const hash = await sha256(plaintext!)
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
- if (hash !== storedHash) return null
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
- return await PersonalAccessToken.where('token', hash).first() as PersonalAccessToken | null
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}|${pending.hash}`
81
+ const cookieValue = `${pending.id}|${pending.token}`
81
82
  headers.append(
82
83
  'Set-Cookie',
83
84
  serializeCookie(guard.getRememberCookieName(), cookieValue, {