@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.
- package/README.md +52 -47
- package/dist/{create-org-session-RsDj9cl4.d.cts → create-org-session-ChFdulEM.d.cts} +211 -17
- package/dist/{create-org-session-RsDj9cl4.d.ts → create-org-session-ChFdulEM.d.ts} +211 -17
- package/dist/index.cjs +645 -150
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -9
- package/dist/index.d.ts +27 -9
- package/dist/index.js +644 -150
- package/dist/index.js.map +1 -1
- package/dist/{operation-encryptor-DRmKNWpF.d.cts → operation-encryptor-DDdlb9bm.d.cts} +16 -0
- package/dist/{operation-encryptor-DRmKNWpF.d.ts → operation-encryptor-DDdlb9bm.d.ts} +16 -0
- package/dist/react.d.cts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/server.cjs +2852 -1675
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +779 -168
- package/dist/server.d.ts +779 -168
- package/dist/server.js +2831 -1665
- package/dist/server.js.map +1 -1
- package/dist/svelte.cjs +2 -2
- package/dist/svelte.cjs.map +1 -1
- package/dist/svelte.d.cts +2 -2
- package/dist/svelte.d.ts +2 -2
- package/dist/svelte.js +2 -2
- package/dist/svelte.js.map +1 -1
- package/dist/vue.d.cts +1 -1
- package/dist/vue.d.ts +1 -1
- package/package.json +7 -7
- package/src/admin/admin-api.ts +327 -0
- package/src/admin/audit-log.ts +324 -0
- package/src/admin/webhooks.ts +576 -0
- package/src/bindings/create-auth-session.ts +184 -0
- package/src/bindings/create-org-session.ts +130 -0
- package/src/client/auth-client.ts +1592 -0
- package/src/client/auth-sync.ts +213 -0
- package/src/client/device-session.ts +104 -0
- package/src/client/org-client.ts +399 -0
- package/src/client/quickstart.ts +108 -0
- package/src/client/storage.ts +94 -0
- package/src/device/device-identity.ts +330 -0
- package/src/device/device-store.ts +379 -0
- package/src/encryption/auto-lock.ts +170 -0
- package/src/encryption/database-encryption.ts +265 -0
- package/src/encryption/key-derivation.ts +149 -0
- package/src/encryption/operation-encryptor.ts +361 -0
- package/src/index.ts +132 -0
- package/src/mfa/totp.ts +826 -0
- package/src/org/org-routes.ts +758 -0
- package/src/org/org-store.ts +490 -0
- package/src/org/org-types.ts +230 -0
- package/src/passkey/passkey-client.ts +597 -0
- package/src/passkey/passkey-server.ts +779 -0
- package/src/postgres/ensure-schema.ts +65 -0
- package/src/provider/adapter.ts +246 -0
- package/src/provider/built-in/auth-routes.ts +1313 -0
- package/src/provider/built-in/email-verification.ts +303 -0
- package/src/provider/built-in/password-hash.ts +118 -0
- package/src/provider/built-in/password-reset.ts +416 -0
- package/src/provider/built-in/postgres-user-store.ts +328 -0
- package/src/provider/built-in/quickstart-server.ts +760 -0
- package/src/provider/built-in/sqlite-user-store.ts +322 -0
- package/src/provider/built-in/sync-scopes.ts +85 -0
- package/src/provider/built-in/user-store.ts +465 -0
- package/src/provider/external/clerk-adapter.ts +157 -0
- package/src/provider/external/external-jwt-provider.ts +491 -0
- package/src/provider/external/supabase-adapter.ts +163 -0
- package/src/provider/oauth/linked-identity-store.ts +108 -0
- package/src/provider/oauth/oauth-flow.ts +550 -0
- package/src/provider/oauth/oauth-types.ts +184 -0
- package/src/provider/oauth/postgres-oauth-store.ts +296 -0
- package/src/provider/oauth/sqlite-oauth-store.ts +272 -0
- package/src/rbac/rbac-engine.ts +323 -0
- package/src/rbac/rbac-types.ts +210 -0
- package/src/rbac/scope-resolver.ts +140 -0
- package/src/react/AuthProvider.tsx +97 -0
- package/src/react/OrgProvider.tsx +41 -0
- package/src/react/auth-context.ts +26 -0
- package/src/react/hooks.ts +110 -0
- package/src/react/org-hooks.ts +214 -0
- package/src/react.ts +26 -0
- package/src/server.ts +334 -0
- package/src/session/session.ts +401 -0
- package/src/svelte/auth-context.ts +50 -0
- package/src/svelte/org-context.ts +32 -0
- package/src/svelte/org-hooks.ts +201 -0
- package/src/svelte/use-auth.ts +115 -0
- package/src/svelte.ts +25 -0
- package/src/tokens/encrypted-token-store.ts +360 -0
- package/src/tokens/jwt.ts +236 -0
- package/src/tokens/postgres-token-revocation-store.ts +140 -0
- package/src/tokens/sqlite-token-revocation-store.ts +121 -0
- package/src/tokens/token-manager.ts +821 -0
- package/src/tokens/token-store.ts +192 -0
- package/src/types.ts +394 -0
- package/src/vue/auth-context.ts +10 -0
- package/src/vue/auth-provider-types.ts +5 -0
- package/src/vue/auth-provider.ts +76 -0
- package/src/vue/org-hooks.ts +193 -0
- package/src/vue/org-provider.ts +49 -0
- package/src/vue/use-auth.ts +139 -0
- 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
|
+
}
|