@korajs/auth 1.0.0-beta.12 → 1.0.0-beta.13

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.
Files changed (101) hide show
  1. package/README.md +52 -47
  2. package/dist/{create-org-session-RsDj9cl4.d.cts → create-org-session-ChFdulEM.d.cts} +211 -17
  3. package/dist/{create-org-session-RsDj9cl4.d.ts → create-org-session-ChFdulEM.d.ts} +211 -17
  4. package/dist/index.cjs +645 -150
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +27 -9
  7. package/dist/index.d.ts +27 -9
  8. package/dist/index.js +644 -150
  9. package/dist/index.js.map +1 -1
  10. package/dist/{operation-encryptor-DRmKNWpF.d.cts → operation-encryptor-DDdlb9bm.d.cts} +16 -0
  11. package/dist/{operation-encryptor-DRmKNWpF.d.ts → operation-encryptor-DDdlb9bm.d.ts} +16 -0
  12. package/dist/react.d.cts +2 -2
  13. package/dist/react.d.ts +2 -2
  14. package/dist/server.cjs +2852 -1675
  15. package/dist/server.cjs.map +1 -1
  16. package/dist/server.d.cts +779 -168
  17. package/dist/server.d.ts +779 -168
  18. package/dist/server.js +2831 -1665
  19. package/dist/server.js.map +1 -1
  20. package/dist/svelte.cjs +2 -2
  21. package/dist/svelte.cjs.map +1 -1
  22. package/dist/svelte.d.cts +2 -2
  23. package/dist/svelte.d.ts +2 -2
  24. package/dist/svelte.js +2 -2
  25. package/dist/svelte.js.map +1 -1
  26. package/dist/vue.d.cts +1 -1
  27. package/dist/vue.d.ts +1 -1
  28. package/package.json +7 -7
  29. package/src/admin/admin-api.ts +327 -0
  30. package/src/admin/audit-log.ts +324 -0
  31. package/src/admin/webhooks.ts +576 -0
  32. package/src/bindings/create-auth-session.ts +184 -0
  33. package/src/bindings/create-org-session.ts +130 -0
  34. package/src/client/auth-client.ts +1592 -0
  35. package/src/client/auth-sync.ts +213 -0
  36. package/src/client/device-session.ts +104 -0
  37. package/src/client/org-client.ts +399 -0
  38. package/src/client/quickstart.ts +108 -0
  39. package/src/client/storage.ts +94 -0
  40. package/src/device/device-identity.ts +330 -0
  41. package/src/device/device-store.ts +379 -0
  42. package/src/encryption/auto-lock.ts +170 -0
  43. package/src/encryption/database-encryption.ts +265 -0
  44. package/src/encryption/key-derivation.ts +149 -0
  45. package/src/encryption/operation-encryptor.ts +361 -0
  46. package/src/index.ts +132 -0
  47. package/src/mfa/totp.ts +826 -0
  48. package/src/org/org-routes.ts +758 -0
  49. package/src/org/org-store.ts +490 -0
  50. package/src/org/org-types.ts +230 -0
  51. package/src/passkey/passkey-client.ts +597 -0
  52. package/src/passkey/passkey-server.ts +779 -0
  53. package/src/postgres/ensure-schema.ts +65 -0
  54. package/src/provider/adapter.ts +246 -0
  55. package/src/provider/built-in/auth-routes.ts +1313 -0
  56. package/src/provider/built-in/email-verification.ts +303 -0
  57. package/src/provider/built-in/password-hash.ts +118 -0
  58. package/src/provider/built-in/password-reset.ts +416 -0
  59. package/src/provider/built-in/postgres-user-store.ts +328 -0
  60. package/src/provider/built-in/quickstart-server.ts +760 -0
  61. package/src/provider/built-in/sqlite-user-store.ts +322 -0
  62. package/src/provider/built-in/sync-scopes.ts +85 -0
  63. package/src/provider/built-in/user-store.ts +465 -0
  64. package/src/provider/external/clerk-adapter.ts +157 -0
  65. package/src/provider/external/external-jwt-provider.ts +491 -0
  66. package/src/provider/external/supabase-adapter.ts +163 -0
  67. package/src/provider/oauth/linked-identity-store.ts +108 -0
  68. package/src/provider/oauth/oauth-flow.ts +550 -0
  69. package/src/provider/oauth/oauth-types.ts +184 -0
  70. package/src/provider/oauth/postgres-oauth-store.ts +296 -0
  71. package/src/provider/oauth/sqlite-oauth-store.ts +272 -0
  72. package/src/rbac/rbac-engine.ts +323 -0
  73. package/src/rbac/rbac-types.ts +210 -0
  74. package/src/rbac/scope-resolver.ts +140 -0
  75. package/src/react/AuthProvider.tsx +97 -0
  76. package/src/react/OrgProvider.tsx +41 -0
  77. package/src/react/auth-context.ts +26 -0
  78. package/src/react/hooks.ts +110 -0
  79. package/src/react/org-hooks.ts +214 -0
  80. package/src/react.ts +26 -0
  81. package/src/server.ts +334 -0
  82. package/src/session/session.ts +401 -0
  83. package/src/svelte/auth-context.ts +50 -0
  84. package/src/svelte/org-context.ts +32 -0
  85. package/src/svelte/org-hooks.ts +201 -0
  86. package/src/svelte/use-auth.ts +115 -0
  87. package/src/svelte.ts +25 -0
  88. package/src/tokens/encrypted-token-store.ts +360 -0
  89. package/src/tokens/jwt.ts +236 -0
  90. package/src/tokens/postgres-token-revocation-store.ts +140 -0
  91. package/src/tokens/sqlite-token-revocation-store.ts +121 -0
  92. package/src/tokens/token-manager.ts +821 -0
  93. package/src/tokens/token-store.ts +192 -0
  94. package/src/types.ts +394 -0
  95. package/src/vue/auth-context.ts +10 -0
  96. package/src/vue/auth-provider-types.ts +5 -0
  97. package/src/vue/auth-provider.ts +76 -0
  98. package/src/vue/org-hooks.ts +193 -0
  99. package/src/vue/org-provider.ts +49 -0
  100. package/src/vue/use-auth.ts +139 -0
  101. package/src/vue.ts +10 -0
@@ -0,0 +1,821 @@
1
+ import { createHmac, randomBytes, randomUUID } from 'node:crypto'
2
+ import type { AuthTokens, DeviceCredentialPayload, TokenPayload } from '../types'
3
+ import {
4
+ DEFAULT_ACCESS_TOKEN_LIFETIME,
5
+ DEFAULT_DEVICE_CREDENTIAL_LIFETIME,
6
+ DEFAULT_REFRESH_TOKEN_LIFETIME,
7
+ } from '../types'
8
+ import { encodeJwt, isExpired, verifyJwt } from './jwt'
9
+
10
+ /**
11
+ * Minimum HMAC secret length in bytes. A 256-bit key provides full security
12
+ * for HMAC-SHA256 (NIST SP 800-107). Shorter keys weaken the MAC and are
13
+ * vulnerable to brute-force attacks.
14
+ */
15
+ const MIN_SECRET_LENGTH = 32
16
+
17
+ /**
18
+ * Default window during which a just-rotated refresh token may be presented
19
+ * once more and receive the SAME successor pair (NEW-AUTH-3). Covers a rotation
20
+ * response lost on a flaky network without opening a long replay window.
21
+ */
22
+ export const DEFAULT_REFRESH_REUSE_GRACE_MS = 30_000
23
+
24
+ /** Lifetime of the short-lived token that bridges password and second factor. */
25
+ export const MFA_PENDING_TOKEN_LIFETIME_MS = 5 * 60_000
26
+
27
+ /**
28
+ * Payload of an `mfa_pending` token: proof that the first factor succeeded,
29
+ * accepted ONLY by the MFA verification step and rejected everywhere else
30
+ * (`validateToken` does not recognise its type).
31
+ */
32
+ export interface MfaPendingPayload {
33
+ jti: string
34
+ sub: string
35
+ dev: string
36
+ type: 'mfa_pending'
37
+ iat: number
38
+ exp: number
39
+ /** Methods already satisfied (for example `['pwd']`). */
40
+ amr: string[]
41
+ }
42
+
43
+ /** Result of an atomic {@link TokenRevocationStore.consume}. */
44
+ export interface ConsumeResult {
45
+ /** True only for the single call that consumed the id first. */
46
+ firstUse: boolean
47
+ /** When the id was first consumed (milliseconds since epoch). */
48
+ consumedAt: number
49
+ }
50
+
51
+ /**
52
+ * Interface for server-side token revocation storage.
53
+ *
54
+ * Implementing this interface allows the TokenManager to:
55
+ * - Revoke individual tokens (and token families) by id
56
+ * - Rotate refresh tokens atomically (each refresh token mints at most one successor)
57
+ * - Invalidate every credential a device or a user obtained before a point in time
58
+ *
59
+ * Shipped implementations: {@link InMemoryTokenRevocationStore} (development),
60
+ * `SqliteTokenRevocationStore` and `PostgresTokenRevocationStore`. The SQLite and
61
+ * Postgres user stores expose one sharing their database through
62
+ * `getTokenRevocationStore()`, which `createKoraAuthServer` uses by default.
63
+ */
64
+ export interface TokenRevocationStore {
65
+ /**
66
+ * Check whether a token (or token family, keyed `family:<id>`) has been revoked.
67
+ * @param jti - The JWT ID (or family key) to check
68
+ */
69
+ isRevoked(jti: string): Promise<boolean>
70
+
71
+ /**
72
+ * Revoke a specific token (or token family) by id.
73
+ * @param jti - The JWT ID (or family key) to revoke
74
+ * @param expiresAt - Expiry in seconds since epoch; the store may purge after it
75
+ */
76
+ revoke(jti: string, expiresAt: number): Promise<void>
77
+
78
+ /**
79
+ * Atomically mark an id as consumed (test-and-set). Exactly one concurrent
80
+ * caller observes `firstUse: true`, on every instance sharing the store.
81
+ * @param jti - The id to consume
82
+ * @param expiresAt - Expiry in seconds since epoch; the store may purge after it
83
+ */
84
+ consume(jti: string, expiresAt: number): Promise<ConsumeResult>
85
+
86
+ /** Whether an id has been consumed (read-only; never consumes). */
87
+ isConsumed(jti: string): Promise<boolean>
88
+
89
+ /**
90
+ * Reject every token for a device issued at or before `before` (ms). Later
91
+ * tokens (a fresh sign-in on the same device) stay valid. Monotonic: a
92
+ * smaller `before` never lowers an existing cut-off.
93
+ */
94
+ revokeAllForDevice(deviceId: string, before?: number): Promise<void>
95
+
96
+ /** Cut-off (ms) recorded by {@link revokeAllForDevice}, or null. */
97
+ getDeviceRevokedBefore(deviceId: string): Promise<number | null>
98
+
99
+ /**
100
+ * Reject every token for a user issued at or before `before` (ms). Used by
101
+ * password reset and change, admin session revocation and user deletion.
102
+ */
103
+ revokeAllForUser(userId: string, before?: number): Promise<void>
104
+
105
+ /** Cut-off (ms) recorded by {@link revokeAllForUser}, or null. */
106
+ getUserRevokedBefore(userId: string): Promise<number | null>
107
+ }
108
+
109
+ /**
110
+ * In-memory token revocation store.
111
+ *
112
+ * Suitable for development and testing. Revocations are lost on restart and not
113
+ * shared across instances, so `createKoraAuthServer` refuses it in production
114
+ * unless `allowInMemory: true` is set.
115
+ */
116
+ export class InMemoryTokenRevocationStore implements TokenRevocationStore {
117
+ private readonly revokedTokens = new Map<string, number>()
118
+ private readonly consumed = new Map<string, { consumedAt: number; expiresAt: number }>()
119
+ private readonly deviceCutoffs = new Map<string, number>()
120
+ private readonly userCutoffs = new Map<string, number>()
121
+
122
+ async isRevoked(jti: string): Promise<boolean> {
123
+ return this.revokedTokens.has(jti)
124
+ }
125
+
126
+ async revoke(jti: string, expiresAt: number): Promise<void> {
127
+ this.revokedTokens.set(jti, expiresAt)
128
+ }
129
+
130
+ async consume(jti: string, expiresAt: number): Promise<ConsumeResult> {
131
+ // Single-threaded: the check and the set happen in one synchronous step.
132
+ const existing = this.consumed.get(jti)
133
+ if (existing) return { firstUse: false, consumedAt: existing.consumedAt }
134
+ const consumedAt = Date.now()
135
+ this.consumed.set(jti, { consumedAt, expiresAt })
136
+ return { firstUse: true, consumedAt }
137
+ }
138
+
139
+ async isConsumed(jti: string): Promise<boolean> {
140
+ return this.consumed.has(jti)
141
+ }
142
+
143
+ async revokeAllForDevice(deviceId: string, before: number = Date.now()): Promise<void> {
144
+ this.deviceCutoffs.set(deviceId, Math.max(this.deviceCutoffs.get(deviceId) ?? 0, before))
145
+ }
146
+
147
+ async getDeviceRevokedBefore(deviceId: string): Promise<number | null> {
148
+ return this.deviceCutoffs.get(deviceId) ?? null
149
+ }
150
+
151
+ async revokeAllForUser(userId: string, before: number = Date.now()): Promise<void> {
152
+ this.userCutoffs.set(userId, Math.max(this.userCutoffs.get(userId) ?? 0, before))
153
+ }
154
+
155
+ async getUserRevokedBefore(userId: string): Promise<number | null> {
156
+ return this.userCutoffs.get(userId) ?? null
157
+ }
158
+
159
+ /**
160
+ * Remove expired revocations to prevent unbounded memory growth.
161
+ * Call periodically (e.g., every hour) in long-running servers.
162
+ */
163
+ cleanup(): void {
164
+ const nowSeconds = Math.floor(Date.now() / 1000)
165
+ for (const [jti, expiresAt] of this.revokedTokens) {
166
+ if (nowSeconds > expiresAt) this.revokedTokens.delete(jti)
167
+ }
168
+ for (const [jti, entry] of this.consumed) {
169
+ if (nowSeconds > entry.expiresAt) this.consumed.delete(jti)
170
+ }
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Configuration for the server-side TokenManager.
176
+ */
177
+ export interface TokenManagerConfig {
178
+ /**
179
+ * Secret key for signing JWTs (HMAC-SHA256).
180
+ *
181
+ * Must be at least 32 characters (256 bits). Use {@link TokenManager.generateSecret}
182
+ * to create a cryptographically random secret.
183
+ *
184
+ * For key rotation, provide an array of secrets. The first secret is used for
185
+ * signing new tokens; all secrets are tried during verification (newest first).
186
+ */
187
+ secret: string | string[]
188
+
189
+ /** Access token lifetime in milliseconds (default: 15 minutes) */
190
+ accessTokenLifetime?: number
191
+
192
+ /** Refresh token lifetime in milliseconds (default: 90 days) */
193
+ refreshTokenLifetime?: number
194
+
195
+ /** Device credential lifetime in milliseconds (default: 90 days) */
196
+ deviceCredentialLifetime?: number
197
+
198
+ /**
199
+ * Optional token revocation store. When provided, enables revocation,
200
+ * atomic refresh rotation with reuse detection, and device/user cut-offs.
201
+ * Without a revocation store, tokens are valid until they expire.
202
+ */
203
+ revocationStore?: TokenRevocationStore
204
+
205
+ /**
206
+ * Window (ms) during which a just-rotated refresh token is accepted ONCE more
207
+ * and returns the same successor pair. Set 0 to disable. Default 30 seconds.
208
+ */
209
+ refreshReuseGraceMs?: number
210
+ }
211
+
212
+ /** Options for minting a token set. */
213
+ export interface IssueTokenOptions {
214
+ /** Refresh-token family to continue. A new family is started when omitted. */
215
+ family?: string
216
+ /** Authentication methods (RFC 8176) to record, for example `['pwd', 'otp']`. */
217
+ amr?: string[]
218
+ }
219
+
220
+ /** Why a refresh was refused. */
221
+ export type RefreshFailureReason =
222
+ | 'invalid'
223
+ | 'revoked'
224
+ | 'reused'
225
+ | 'in_progress'
226
+ | 'device_revoked'
227
+ | 'user_revoked'
228
+
229
+ /** Outcome of {@link TokenManager.rotateRefreshToken}. */
230
+ export type RefreshResult =
231
+ | {
232
+ ok: true
233
+ tokens: { accessToken: string; refreshToken: string }
234
+ /** The verified payload of the refresh token that was presented. */
235
+ payload: TokenPayload
236
+ /** True when this was the one grace replay of a just-rotated token. */
237
+ replayed: boolean
238
+ }
239
+ | { ok: false; reason: RefreshFailureReason }
240
+
241
+ /**
242
+ * Server-side token manager responsible for issuing, refreshing, and validating
243
+ * Kora authentication tokens.
244
+ *
245
+ * Uses HMAC-SHA256 signed JWTs with unique `jti` identifiers for every token.
246
+ * Supports key rotation (multiple secrets), token revocation, token families and
247
+ * atomic, rotation-safe refresh.
248
+ *
249
+ * @example
250
+ * ```typescript
251
+ * const tokenManager = new TokenManager({
252
+ * secret: TokenManager.generateSecret(),
253
+ * revocationStore: new InMemoryTokenRevocationStore(),
254
+ * })
255
+ *
256
+ * const tokens = tokenManager.issueTokens('user-123', 'device-456')
257
+ * const payload = await tokenManager.validateTokenWithRevocation(tokens.accessToken)
258
+ * const next = await tokenManager.rotateRefreshToken(tokens.refreshToken)
259
+ * ```
260
+ */
261
+ export class TokenManager {
262
+ /** All signing/verification secrets (index 0 = current signing key) */
263
+ private readonly secrets: string[]
264
+ private readonly accessTokenLifetime: number
265
+ private readonly refreshTokenLifetime: number
266
+ private readonly deviceCredentialLifetime: number
267
+ private readonly revocationStore: TokenRevocationStore | undefined
268
+ private readonly refreshReuseGraceMs: number
269
+ /** Refresh jtis whose rotation is executing on this instance right now. */
270
+ private readonly rotating = new Set<string>()
271
+
272
+ constructor(config: TokenManagerConfig) {
273
+ const secrets = Array.isArray(config.secret) ? config.secret : [config.secret]
274
+
275
+ if (secrets.length === 0) {
276
+ throw new Error('TokenManager requires at least one secret.')
277
+ }
278
+
279
+ for (const secret of secrets) {
280
+ if (secret.length < MIN_SECRET_LENGTH) {
281
+ throw new Error(
282
+ `JWT secret must be at least ${MIN_SECRET_LENGTH} characters (256 bits) for HMAC-SHA256 security. ` +
283
+ `Received ${secret.length} characters. Use TokenManager.generateSecret() to generate a secure secret.`,
284
+ )
285
+ }
286
+ }
287
+
288
+ this.secrets = secrets
289
+ this.accessTokenLifetime = config.accessTokenLifetime ?? DEFAULT_ACCESS_TOKEN_LIFETIME
290
+ this.refreshTokenLifetime = config.refreshTokenLifetime ?? DEFAULT_REFRESH_TOKEN_LIFETIME
291
+ this.deviceCredentialLifetime =
292
+ config.deviceCredentialLifetime ?? DEFAULT_DEVICE_CREDENTIAL_LIFETIME
293
+ this.revocationStore = config.revocationStore
294
+ this.refreshReuseGraceMs = config.refreshReuseGraceMs ?? DEFAULT_REFRESH_REUSE_GRACE_MS
295
+ }
296
+
297
+ /**
298
+ * Generate a cryptographically random secret suitable for HMAC-SHA256 signing.
299
+ *
300
+ * @returns A random 256-bit hex-encoded secret
301
+ */
302
+ static generateSecret(): string {
303
+ return randomBytes(32).toString('hex')
304
+ }
305
+
306
+ /** The revocation store this manager enforces, if any. */
307
+ getRevocationStore(): TokenRevocationStore | undefined {
308
+ return this.revocationStore
309
+ }
310
+
311
+ /**
312
+ * Issue a signed JWT access token.
313
+ *
314
+ * @param userId - The subject (user ID) to encode in the token
315
+ * @param deviceId - The device ID of the requesting device
316
+ * @param options - Family and authentication methods to record
317
+ * @returns A signed JWT string with type 'access'
318
+ */
319
+ issueAccessToken(userId: string, deviceId: string, options: IssueTokenOptions = {}): string {
320
+ const nowMs = Date.now()
321
+ return this.sign(
322
+ this.basePayload({
323
+ jti: randomUUID(),
324
+ sub: userId,
325
+ dev: deviceId,
326
+ type: 'access',
327
+ iatMs: nowMs,
328
+ lifetimeMs: this.accessTokenLifetime,
329
+ family: options.family,
330
+ amr: options.amr,
331
+ }),
332
+ )
333
+ }
334
+
335
+ /**
336
+ * Issue a signed JWT refresh token.
337
+ *
338
+ * @param userId - The subject (user ID) to encode in the token
339
+ * @param deviceId - The device ID of the requesting device
340
+ * @param options - Family and authentication methods to record
341
+ * @returns A signed JWT string with type 'refresh'
342
+ */
343
+ issueRefreshToken(userId: string, deviceId: string, options: IssueTokenOptions = {}): string {
344
+ const nowMs = Date.now()
345
+ return this.sign(
346
+ this.basePayload({
347
+ jti: randomUUID(),
348
+ sub: userId,
349
+ dev: deviceId,
350
+ type: 'refresh',
351
+ iatMs: nowMs,
352
+ lifetimeMs: this.refreshTokenLifetime,
353
+ family: options.family ?? randomUUID(),
354
+ amr: options.amr,
355
+ }),
356
+ )
357
+ }
358
+
359
+ /**
360
+ * Issue a signed device credential token bound to a device's public key.
361
+ *
362
+ * @param userId - The subject (user ID) to encode in the token
363
+ * @param deviceId - The device ID of the requesting device
364
+ * @param publicKeyThumbprint - SHA-256 thumbprint of the device's public key
365
+ * @returns A signed JWT string with type 'device_credential'
366
+ */
367
+ issueDeviceCredential(userId: string, deviceId: string, publicKeyThumbprint: string): string {
368
+ const nowMs = Date.now()
369
+ const nowSeconds = Math.floor(nowMs / 1000)
370
+ const lifetimeSeconds = Math.floor(this.deviceCredentialLifetime / 1000)
371
+ const payload: DeviceCredentialPayload = {
372
+ jti: randomUUID(),
373
+ sub: userId,
374
+ dev: deviceId,
375
+ type: 'device_credential',
376
+ iat: nowSeconds,
377
+ exp: nowSeconds + lifetimeSeconds,
378
+ iatMs: nowMs,
379
+ dpk: publicKeyThumbprint,
380
+ mustCheckinBy: nowSeconds + lifetimeSeconds,
381
+ }
382
+ return encodeJwt(payload as unknown as Record<string, unknown>, this.secrets[0] as string)
383
+ }
384
+
385
+ /**
386
+ * Issue a complete set of authentication tokens for a new session. The access
387
+ * and refresh token share a fresh family id.
388
+ *
389
+ * @param userId - The subject (user ID) to encode in the tokens
390
+ * @param deviceId - The device ID of the requesting device
391
+ * @param publicKeyThumbprint - Optional device key thumbprint; adds a device credential
392
+ * @param options - Authentication methods to record
393
+ * @returns An {@link AuthTokens} object containing the issued tokens
394
+ */
395
+ issueTokens(
396
+ userId: string,
397
+ deviceId: string,
398
+ publicKeyThumbprint?: string,
399
+ options: Omit<IssueTokenOptions, 'family'> = {},
400
+ ): AuthTokens {
401
+ const family = randomUUID()
402
+ const tokens: AuthTokens = {
403
+ accessToken: this.issueAccessToken(userId, deviceId, { family, amr: options.amr }),
404
+ refreshToken: this.issueRefreshToken(userId, deviceId, { family, amr: options.amr }),
405
+ }
406
+
407
+ if (publicKeyThumbprint !== undefined) {
408
+ tokens.deviceCredential = this.issueDeviceCredential(userId, deviceId, publicKeyThumbprint)
409
+ }
410
+
411
+ return tokens
412
+ }
413
+
414
+ /**
415
+ * Issue a short-lived `mfa_pending` token after a successful first factor
416
+ * for a user enrolled in MFA (AUTH-10). It grants nothing by itself.
417
+ *
418
+ * @param userId - The user who passed the first factor
419
+ * @param deviceId - The device the session will be bound to
420
+ * @param amr - Methods already satisfied (for example `['pwd']`)
421
+ * @returns A signed JWT of type `mfa_pending`
422
+ */
423
+ issueMfaPendingToken(userId: string, deviceId: string, amr: string[]): string {
424
+ const nowSeconds = Math.floor(Date.now() / 1000)
425
+ const payload: MfaPendingPayload = {
426
+ jti: randomUUID(),
427
+ sub: userId,
428
+ dev: deviceId,
429
+ type: 'mfa_pending',
430
+ iat: nowSeconds,
431
+ exp: nowSeconds + Math.floor(MFA_PENDING_TOKEN_LIFETIME_MS / 1000),
432
+ amr: [...amr],
433
+ }
434
+ return encodeJwt(payload as unknown as Record<string, unknown>, this.secrets[0] as string)
435
+ }
436
+
437
+ /**
438
+ * Verify an `mfa_pending` token without consuming it, so a mistyped code can
439
+ * be retried with the same token (TOTP checks have their own backoff).
440
+ *
441
+ * @param token - The `mfa_pending` JWT
442
+ * @returns Its payload, or null when invalid, expired or already redeemed
443
+ */
444
+ async verifyMfaPendingToken(token: string): Promise<MfaPendingPayload | null> {
445
+ const decoded = this.verifySignature(token)
446
+ if (decoded === null || isExpired(decoded as { exp?: number })) return null
447
+ if (
448
+ decoded.type !== 'mfa_pending' ||
449
+ typeof decoded.jti !== 'string' ||
450
+ typeof decoded.sub !== 'string' ||
451
+ typeof decoded.dev !== 'string' ||
452
+ typeof decoded.iat !== 'number' ||
453
+ typeof decoded.exp !== 'number' ||
454
+ !Array.isArray(decoded.amr)
455
+ ) {
456
+ return null
457
+ }
458
+ if (this.revocationStore && (await this.revocationStore.isConsumed(mfaKey(decoded.jti)))) {
459
+ return null
460
+ }
461
+ return {
462
+ jti: decoded.jti,
463
+ sub: decoded.sub,
464
+ dev: decoded.dev,
465
+ type: 'mfa_pending',
466
+ iat: decoded.iat,
467
+ exp: decoded.exp,
468
+ amr: decoded.amr.filter((m): m is string => typeof m === 'string'),
469
+ }
470
+ }
471
+
472
+ /**
473
+ * Redeem an `mfa_pending` token exactly once (atomic across instances).
474
+ *
475
+ * @param payload - A payload returned by {@link verifyMfaPendingToken}
476
+ * @returns True only for the single successful redemption
477
+ */
478
+ async redeemMfaPendingToken(payload: MfaPendingPayload): Promise<boolean> {
479
+ if (!this.revocationStore) return true
480
+ const result = await this.revocationStore.consume(mfaKey(payload.jti), payload.exp)
481
+ return result.firstUse
482
+ }
483
+
484
+ /**
485
+ * Validate and decode a token's signature, expiry and claims.
486
+ *
487
+ * This does NOT consult revocation. Every request-authorization path must use
488
+ * {@link validateTokenWithRevocation} (or `BuiltInAuthRoutes.authenticateAccess`).
489
+ *
490
+ * @param token - The JWT string to validate
491
+ * @returns The decoded {@link TokenPayload}, or null if invalid or expired
492
+ */
493
+ validateToken(token: string): TokenPayload | null {
494
+ const decoded = this.verifySignature(token)
495
+ if (decoded === null) {
496
+ return null
497
+ }
498
+
499
+ // verifyJwt validates the signature but not expiration; a token without a
500
+ // numeric exp is rejected by the claim check below.
501
+ if (isExpired(decoded as { exp?: number })) {
502
+ return null
503
+ }
504
+
505
+ if (
506
+ typeof decoded.jti !== 'string' ||
507
+ typeof decoded.sub !== 'string' ||
508
+ typeof decoded.dev !== 'string' ||
509
+ typeof decoded.type !== 'string' ||
510
+ typeof decoded.iat !== 'number' ||
511
+ typeof decoded.exp !== 'number'
512
+ ) {
513
+ return null
514
+ }
515
+
516
+ const type = decoded.type
517
+ if (type !== 'access' && type !== 'refresh' && type !== 'device_credential') {
518
+ return null
519
+ }
520
+
521
+ const payload: TokenPayload = {
522
+ jti: decoded.jti,
523
+ sub: decoded.sub,
524
+ dev: decoded.dev,
525
+ type,
526
+ iat: decoded.iat,
527
+ exp: decoded.exp,
528
+ }
529
+ if (typeof decoded.fam === 'string') payload.fam = decoded.fam
530
+ if (typeof decoded.iatMs === 'number') payload.iatMs = decoded.iatMs
531
+ if (Array.isArray(decoded.amr) && decoded.amr.every((m) => typeof m === 'string')) {
532
+ payload.amr = decoded.amr as string[]
533
+ }
534
+ return payload
535
+ }
536
+
537
+ /**
538
+ * Validate a token and check every revocation primitive: the token's own
539
+ * `jti`, its family, the device cut-off and the per-user cut-off.
540
+ *
541
+ * @param token - The JWT string to validate
542
+ * @returns The decoded {@link TokenPayload} if valid and not revoked, or null otherwise
543
+ */
544
+ async validateTokenWithRevocation(token: string): Promise<TokenPayload | null> {
545
+ const payload = this.validateToken(token)
546
+ if (payload === null) {
547
+ return null
548
+ }
549
+ return (await this.revocationReason(payload)) === null ? payload : null
550
+ }
551
+
552
+ /**
553
+ * Why an otherwise valid token is no longer accepted, or null when it is.
554
+ */
555
+ async revocationReason(
556
+ payload: TokenPayload,
557
+ ): Promise<'revoked' | 'device_revoked' | 'user_revoked' | null> {
558
+ const store = this.revocationStore
559
+ if (!store) return null
560
+ if (await store.isRevoked(payload.jti)) return 'revoked'
561
+ if (payload.fam && (await store.isRevoked(familyKey(payload.fam)))) return 'revoked'
562
+ const issuedAt = issuedAtMs(payload)
563
+ const deviceCutoff = await store.getDeviceRevokedBefore(payload.dev)
564
+ if (deviceCutoff !== null && issuedAt <= deviceCutoff) return 'device_revoked'
565
+ const userCutoff = await store.getUserRevokedBefore(payload.sub)
566
+ if (userCutoff !== null && issuedAt <= userCutoff) return 'user_revoked'
567
+ return null
568
+ }
569
+
570
+ /**
571
+ * Revoke a specific token by its JWT ID.
572
+ *
573
+ * @param jti - The JWT ID of the token to revoke
574
+ * @param expiresAt - The token's expiration time (seconds since epoch)
575
+ */
576
+ async revokeToken(jti: string, expiresAt: number): Promise<void> {
577
+ if (this.revocationStore) {
578
+ await this.revocationStore.revoke(jti, expiresAt)
579
+ }
580
+ }
581
+
582
+ /**
583
+ * Revoke a whole refresh-token family (one sign-in and all its rotations,
584
+ * including the access tokens minted along the way).
585
+ *
586
+ * @param family - The family id (`fam` claim)
587
+ * @param expiresAt - Latest expiry of any token in the family (seconds since epoch)
588
+ */
589
+ async revokeFamily(family: string, expiresAt: number): Promise<void> {
590
+ if (this.revocationStore) {
591
+ await this.revocationStore.revoke(familyKey(family), expiresAt)
592
+ }
593
+ }
594
+
595
+ /**
596
+ * Revoke every token issued to a device up to now. A later sign-in on the
597
+ * same device issues tokens that are accepted again.
598
+ *
599
+ * @param deviceId - The device ID whose tokens should be revoked
600
+ */
601
+ async revokeDeviceTokens(deviceId: string): Promise<void> {
602
+ if (this.revocationStore) {
603
+ await this.revocationStore.revokeAllForDevice(deviceId, Date.now())
604
+ }
605
+ }
606
+
607
+ /**
608
+ * Revoke every token issued to a user up to now (password reset or change,
609
+ * admin session revocation, account deletion).
610
+ *
611
+ * @param userId - The user whose credentials should be revoked
612
+ */
613
+ async revokeAllForUser(userId: string): Promise<void> {
614
+ if (this.revocationStore) {
615
+ await this.revocationStore.revokeAllForUser(userId, Date.now())
616
+ }
617
+ }
618
+
619
+ /**
620
+ * Rotate a refresh token: atomically consume it and mint its successor pair.
621
+ *
622
+ * - Each refresh `jti` mints at most one successor family member (AUTH-6).
623
+ * - A concurrent duplicate on this instance gets `in_progress` (retry later).
624
+ * - Within the grace window, presenting a just-rotated token ONCE more returns
625
+ * the SAME successor pair, so a response lost on the wire does not sign the
626
+ * user out (NEW-AUTH-3).
627
+ * - Any other reuse revokes the token FAMILY, never the device (NEW-AUTH-1).
628
+ *
629
+ * @param refreshToken - The refresh token JWT string
630
+ * @returns The successor pair, or the reason the refresh was refused
631
+ */
632
+ async rotateRefreshToken(refreshToken: string): Promise<RefreshResult> {
633
+ const payload = this.validateToken(refreshToken)
634
+ if (payload === null || payload.type !== 'refresh') {
635
+ return { ok: false, reason: 'invalid' }
636
+ }
637
+ // Checked and set before the first await, so a same-instance duplicate
638
+ // that arrives while this rotation is still running is told to retry
639
+ // instead of being mistaken for a replay.
640
+ if (this.rotating.has(payload.jti)) {
641
+ return { ok: false, reason: 'in_progress' }
642
+ }
643
+ this.rotating.add(payload.jti)
644
+ try {
645
+ return await this.rotate(payload)
646
+ } finally {
647
+ this.rotating.delete(payload.jti)
648
+ }
649
+ }
650
+
651
+ /**
652
+ * Refresh an access token using a valid refresh token.
653
+ *
654
+ * Convenience wrapper over {@link rotateRefreshToken} that collapses every
655
+ * failure to null. HTTP handlers should use `rotateRefreshToken` so they can
656
+ * tell a client to retry (`in_progress`) instead of signing it out.
657
+ *
658
+ * @param refreshToken - The refresh token JWT string
659
+ * @returns A new access/refresh token pair, or null if the refresh was refused
660
+ */
661
+ async refreshAccessToken(
662
+ refreshToken: string,
663
+ ): Promise<{ accessToken: string; refreshToken: string } | null> {
664
+ const result = await this.rotateRefreshToken(refreshToken)
665
+ return result.ok ? result.tokens : null
666
+ }
667
+
668
+ private async rotate(payload: TokenPayload): Promise<RefreshResult> {
669
+ const store = this.revocationStore
670
+ const successor = (consumedAt: number): { accessToken: string; refreshToken: string } =>
671
+ this.successorTokens(payload, consumedAt)
672
+ if (!store) {
673
+ return { ok: true, tokens: successor(Date.now()), payload, replayed: false }
674
+ }
675
+
676
+ const reason = await this.revocationReason(payload)
677
+ if (reason !== null) {
678
+ return { ok: false, reason }
679
+ }
680
+
681
+ const consumed = await store.consume(payload.jti, payload.exp)
682
+ // RT-9: a device or user revocation can land between the check above and the
683
+ // consume. Its cut-off is older than `consumedAt` (the successors' iat), so
684
+ // the successors would outlive it. Re-check now that the parent is consumed:
685
+ // any revocation from here on has a cut-off at or after `consumedAt` and
686
+ // therefore also covers the successors.
687
+ const lateReason = await this.revocationReason(payload)
688
+ if (lateReason !== null) {
689
+ return { ok: false, reason: lateReason }
690
+ }
691
+ if (consumed.firstUse) {
692
+ return { ok: true, tokens: successor(consumed.consumedAt), payload, replayed: false }
693
+ }
694
+
695
+ const family = payload.fam ?? payload.jti
696
+ const withinGrace =
697
+ this.refreshReuseGraceMs > 0 && Date.now() - consumed.consumedAt <= this.refreshReuseGraceMs
698
+ if (withinGrace) {
699
+ const successorRefreshJti = this.deriveJti('refresh', payload.jti)
700
+ // Once the client has used the successor it clearly received it, so a
701
+ // replay of the parent can no longer be a lost response.
702
+ const successorSpent =
703
+ (await store.isRevoked(successorRefreshJti)) ||
704
+ (await store.isConsumed(successorRefreshJti))
705
+ const grace = await store.consume(graceKey(payload.jti), payload.exp)
706
+ if (grace.firstUse && !successorSpent) {
707
+ return { ok: true, tokens: successor(consumed.consumedAt), payload, replayed: true }
708
+ }
709
+ }
710
+
711
+ // A consumed token presented again outside the one grace replay: treat the
712
+ // family as compromised. Other sign-ins on the same device are unaffected.
713
+ await this.revokeFamily(family, payload.exp)
714
+ return { ok: false, reason: 'reused' }
715
+ }
716
+
717
+ /**
718
+ * Deterministically mint the successor pair of a refresh token. The jtis and
719
+ * issue time derive from the consumed token and its consumption time, so a
720
+ * grace replay re-signs byte-identical tokens without storing them.
721
+ */
722
+ private successorTokens(
723
+ payload: TokenPayload,
724
+ consumedAtMs: number,
725
+ ): { accessToken: string; refreshToken: string } {
726
+ const family = payload.fam ?? payload.jti
727
+ return {
728
+ accessToken: this.sign(
729
+ this.basePayload({
730
+ jti: this.deriveJti('access', payload.jti),
731
+ sub: payload.sub,
732
+ dev: payload.dev,
733
+ type: 'access',
734
+ iatMs: consumedAtMs,
735
+ lifetimeMs: this.accessTokenLifetime,
736
+ family,
737
+ amr: payload.amr,
738
+ }),
739
+ ),
740
+ refreshToken: this.sign(
741
+ this.basePayload({
742
+ jti: this.deriveJti('refresh', payload.jti),
743
+ sub: payload.sub,
744
+ dev: payload.dev,
745
+ type: 'refresh',
746
+ iatMs: consumedAtMs,
747
+ lifetimeMs: this.refreshTokenLifetime,
748
+ family,
749
+ amr: payload.amr,
750
+ }),
751
+ ),
752
+ }
753
+ }
754
+
755
+ private deriveJti(kind: 'access' | 'refresh', parentJti: string): string {
756
+ const digest = createHmac('sha256', this.secrets[0] as string)
757
+ .update(`kora-rotation:${kind}:${parentJti}`)
758
+ .digest('hex')
759
+ // Format as a UUID-shaped string so stores sized for UUIDs keep working.
760
+ return `${digest.slice(0, 8)}-${digest.slice(8, 12)}-${digest.slice(12, 16)}-${digest.slice(16, 20)}-${digest.slice(20, 32)}`
761
+ }
762
+
763
+ private basePayload(input: {
764
+ jti: string
765
+ sub: string
766
+ dev: string
767
+ type: 'access' | 'refresh'
768
+ iatMs: number
769
+ lifetimeMs: number
770
+ family?: string
771
+ amr?: string[]
772
+ }): TokenPayload {
773
+ const iat = Math.floor(input.iatMs / 1000)
774
+ // Field order is fixed so a deterministic re-issue is byte-identical.
775
+ const payload: TokenPayload = {
776
+ jti: input.jti,
777
+ sub: input.sub,
778
+ dev: input.dev,
779
+ type: input.type,
780
+ iat,
781
+ exp: iat + Math.floor(input.lifetimeMs / 1000),
782
+ iatMs: input.iatMs,
783
+ }
784
+ if (input.family) payload.fam = input.family
785
+ if (input.amr && input.amr.length > 0) payload.amr = [...input.amr]
786
+ return payload
787
+ }
788
+
789
+ private sign(payload: TokenPayload): string {
790
+ return encodeJwt(payload as unknown as Record<string, unknown>, this.secrets[0] as string)
791
+ }
792
+
793
+ private verifySignature(token: string): Record<string, unknown> | null {
794
+ for (const secret of this.secrets) {
795
+ const decoded = verifyJwt(token, secret)
796
+ if (decoded !== null) return decoded
797
+ }
798
+ return null
799
+ }
800
+ }
801
+
802
+ function familyKey(family: string): string {
803
+ return `family:${family}`
804
+ }
805
+
806
+ function mfaKey(jti: string): string {
807
+ return `mfa:${jti}`
808
+ }
809
+
810
+ function graceKey(jti: string): string {
811
+ return `grace:${jti}`
812
+ }
813
+
814
+ /**
815
+ * Issue time in ms. Tokens minted before beta.13 only carry second-resolution
816
+ * `iat`; treat them as issued at the START of that second so a revocation made
817
+ * later within the same second still covers them (fail closed).
818
+ */
819
+ function issuedAtMs(payload: TokenPayload): number {
820
+ return payload.iatMs ?? payload.iat * 1000
821
+ }