@korajs/auth 1.0.0-beta.12 → 1.0.0-beta.14
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 +2880 -1667
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +810 -169
- package/dist/server.d.ts +810 -169
- package/dist/server.js +2848 -1646
- 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 +365 -0
- package/src/provider/built-in/quickstart-server.ts +760 -0
- package/src/provider/built-in/sqlite-user-store.ts +335 -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 +285 -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 +338 -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
package/src/mfa/totp.ts
ADDED
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
import { KoraError } from '@korajs/core'
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// TOTP Types
|
|
5
|
+
// ============================================================================
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Configuration for TOTP MFA.
|
|
9
|
+
*/
|
|
10
|
+
export interface TotpConfig {
|
|
11
|
+
/** Issuer name shown in authenticator apps (e.g., "MyApp") */
|
|
12
|
+
issuer: string
|
|
13
|
+
/** Number of digits in the TOTP code. Default: 6 */
|
|
14
|
+
digits?: number
|
|
15
|
+
/** Time step in seconds. Default: 30 */
|
|
16
|
+
period?: number
|
|
17
|
+
/** Hash algorithm. Default: 'SHA-1' (most compatible with authenticator apps) */
|
|
18
|
+
algorithm?: 'SHA-1' | 'SHA-256' | 'SHA-512'
|
|
19
|
+
/** Number of time windows to check before/after current. Default: 1 */
|
|
20
|
+
window?: number
|
|
21
|
+
/** Number of recovery codes to generate. Default: 8 */
|
|
22
|
+
recoveryCodes?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A TOTP secret with metadata for a user.
|
|
27
|
+
*/
|
|
28
|
+
export interface TotpSecret {
|
|
29
|
+
/** User ID */
|
|
30
|
+
userId: string
|
|
31
|
+
/** Raw secret bytes (base32-encoded for display) */
|
|
32
|
+
secret: string
|
|
33
|
+
/** Whether MFA is verified (user has confirmed setup with a valid code) */
|
|
34
|
+
verified: boolean
|
|
35
|
+
/** Hashed recovery codes (unused ones only) */
|
|
36
|
+
recoveryCodes: string[]
|
|
37
|
+
/** When this secret was created */
|
|
38
|
+
createdAt: number
|
|
39
|
+
/** When MFA was verified (confirmed with first valid code) */
|
|
40
|
+
verifiedAt: number | null
|
|
41
|
+
/**
|
|
42
|
+
* The most recent TOTP time-step counter that was successfully consumed
|
|
43
|
+
* during authentication. Used to enforce single-use of codes (RFC 6238 §5.2):
|
|
44
|
+
* a code at or before this time-step is rejected as a replay. `undefined`
|
|
45
|
+
* until the first code is consumed.
|
|
46
|
+
*/
|
|
47
|
+
lastUsedTimeStep?: number
|
|
48
|
+
/** Consecutive failed code checks (TOTP or recovery). Reset on success. */
|
|
49
|
+
failedAttempts?: number
|
|
50
|
+
/** While set and in the future, every code check fails without being evaluated. */
|
|
51
|
+
lockedUntil?: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Setup result returned when enabling TOTP MFA.
|
|
56
|
+
*/
|
|
57
|
+
export interface TotpSetupResult {
|
|
58
|
+
/** The raw secret in base32 encoding (for manual entry) */
|
|
59
|
+
secret: string
|
|
60
|
+
/** otpauth:// URI for QR code generation */
|
|
61
|
+
uri: string
|
|
62
|
+
/** Plaintext recovery codes (shown once, then discarded) */
|
|
63
|
+
recoveryCodes: string[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Store for TOTP secrets.
|
|
68
|
+
*/
|
|
69
|
+
export interface TotpStore {
|
|
70
|
+
/** Save or update a TOTP secret for a user */
|
|
71
|
+
save(secret: TotpSecret): Promise<void>
|
|
72
|
+
/** Get a TOTP secret by user ID */
|
|
73
|
+
getByUserId(userId: string): Promise<TotpSecret | null>
|
|
74
|
+
/** Delete TOTP secret for a user (disable MFA) */
|
|
75
|
+
delete(userId: string): Promise<void>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ============================================================================
|
|
79
|
+
// Errors
|
|
80
|
+
// ============================================================================
|
|
81
|
+
|
|
82
|
+
export class TotpError extends KoraError {
|
|
83
|
+
constructor(message: string, code: string, context?: Record<string, unknown>) {
|
|
84
|
+
super(message, code, context)
|
|
85
|
+
this.name = 'TotpError'
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Thrown by `disable` / `regenerateRecoveryCodes` while code checks are locked
|
|
91
|
+
* after repeated failures (AUTH-10). `verify` returns false instead.
|
|
92
|
+
*/
|
|
93
|
+
export class TotpLockedError extends TotpError {
|
|
94
|
+
constructor(public readonly lockedUntil: number) {
|
|
95
|
+
super('Too many invalid codes. Try again later.', 'TOTP_LOCKED', { lockedUntil })
|
|
96
|
+
this.name = 'TotpLockedError'
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export class TotpInvalidCodeError extends TotpError {
|
|
101
|
+
constructor() {
|
|
102
|
+
super('Invalid TOTP code.', 'TOTP_INVALID_CODE')
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export class TotpNotEnabledError extends TotpError {
|
|
107
|
+
constructor(userId: string) {
|
|
108
|
+
super('TOTP MFA is not enabled for this user.', 'TOTP_NOT_ENABLED', { userId })
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export class TotpAlreadyEnabledError extends TotpError {
|
|
113
|
+
constructor(userId: string) {
|
|
114
|
+
super('TOTP MFA is already enabled for this user.', 'TOTP_ALREADY_ENABLED', { userId })
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export class TotpNotVerifiedError extends TotpError {
|
|
119
|
+
constructor(userId: string) {
|
|
120
|
+
super(
|
|
121
|
+
'TOTP MFA setup is pending verification. Verify with a valid code first.',
|
|
122
|
+
'TOTP_NOT_VERIFIED',
|
|
123
|
+
{ userId },
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export class TotpRecoveryExhaustedError extends TotpError {
|
|
129
|
+
constructor() {
|
|
130
|
+
super('All recovery codes have been used. Please regenerate.', 'TOTP_RECOVERY_EXHAUSTED')
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ============================================================================
|
|
135
|
+
// InMemoryTotpStore
|
|
136
|
+
// ============================================================================
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* In-memory TOTP store for development and testing.
|
|
140
|
+
*/
|
|
141
|
+
export class InMemoryTotpStore implements TotpStore {
|
|
142
|
+
private readonly secrets = new Map<string, TotpSecret>()
|
|
143
|
+
|
|
144
|
+
async save(secret: TotpSecret): Promise<void> {
|
|
145
|
+
this.secrets.set(secret.userId, secret)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async getByUserId(userId: string): Promise<TotpSecret | null> {
|
|
149
|
+
return this.secrets.get(userId) ?? null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async delete(userId: string): Promise<void> {
|
|
153
|
+
this.secrets.delete(userId)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ============================================================================
|
|
158
|
+
// TotpManager
|
|
159
|
+
// ============================================================================
|
|
160
|
+
|
|
161
|
+
const DEFAULT_DIGITS = 6
|
|
162
|
+
const DEFAULT_PERIOD = 30
|
|
163
|
+
const DEFAULT_ALGORITHM = 'SHA-1'
|
|
164
|
+
const DEFAULT_WINDOW = 1
|
|
165
|
+
const DEFAULT_RECOVERY_CODES = 8
|
|
166
|
+
const RECOVERY_CODE_LENGTH = 10
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Manages TOTP-based Multi-Factor Authentication.
|
|
170
|
+
*
|
|
171
|
+
* Implements RFC 6238 (TOTP) and RFC 4226 (HOTP) with Web Crypto API.
|
|
172
|
+
* Compatible with Google Authenticator, Authy, 1Password, and other
|
|
173
|
+
* TOTP-compatible authenticator apps.
|
|
174
|
+
*
|
|
175
|
+
* @example
|
|
176
|
+
* ```typescript
|
|
177
|
+
* const totp = new TotpManager({
|
|
178
|
+
* issuer: 'MyApp',
|
|
179
|
+
* store: new InMemoryTotpStore(),
|
|
180
|
+
* })
|
|
181
|
+
*
|
|
182
|
+
* // Step 1: Enable MFA (returns QR code URI and recovery codes)
|
|
183
|
+
* const setup = await totp.enable('user-123', 'alice@example.com')
|
|
184
|
+
* // Show setup.uri as QR code, show setup.recoveryCodes once
|
|
185
|
+
*
|
|
186
|
+
* // Step 2: Verify setup with a code from authenticator app
|
|
187
|
+
* await totp.verifySetup('user-123', '123456')
|
|
188
|
+
*
|
|
189
|
+
* // Step 3: On login, verify TOTP code
|
|
190
|
+
* const valid = await totp.verify('user-123', '654321')
|
|
191
|
+
* ```
|
|
192
|
+
*/
|
|
193
|
+
/** Consecutive failures allowed before code checks start locking. */
|
|
194
|
+
const FREE_FAILURES = 5
|
|
195
|
+
const LOCK_BASE_MS = 30_000
|
|
196
|
+
const LOCK_MAX_MS = 15 * 60_000
|
|
197
|
+
|
|
198
|
+
export class TotpManager {
|
|
199
|
+
private readonly store: TotpStore
|
|
200
|
+
private readonly issuer: string
|
|
201
|
+
private readonly digits: number
|
|
202
|
+
private readonly period: number
|
|
203
|
+
private readonly algorithm: 'SHA-1' | 'SHA-256' | 'SHA-512'
|
|
204
|
+
private readonly window: number
|
|
205
|
+
private readonly recoveryCodeCount: number
|
|
206
|
+
|
|
207
|
+
constructor(config: TotpConfig & { store: TotpStore }) {
|
|
208
|
+
this.store = config.store
|
|
209
|
+
this.issuer = config.issuer
|
|
210
|
+
this.digits = config.digits ?? DEFAULT_DIGITS
|
|
211
|
+
this.period = config.period ?? DEFAULT_PERIOD
|
|
212
|
+
this.algorithm = config.algorithm ?? DEFAULT_ALGORITHM
|
|
213
|
+
this.window = config.window ?? DEFAULT_WINDOW
|
|
214
|
+
this.recoveryCodeCount = config.recoveryCodes ?? DEFAULT_RECOVERY_CODES
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Enable TOTP MFA for a user.
|
|
219
|
+
* Returns the secret URI (for QR code) and recovery codes.
|
|
220
|
+
* The user must verify setup with a valid code before MFA is active.
|
|
221
|
+
*/
|
|
222
|
+
async enable(userId: string, accountName: string): Promise<TotpSetupResult> {
|
|
223
|
+
const existing = await this.store.getByUserId(userId)
|
|
224
|
+
if (existing?.verified) {
|
|
225
|
+
throw new TotpAlreadyEnabledError(userId)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const secretBytes = generateSecret(20)
|
|
229
|
+
const secret = base32Encode(secretBytes)
|
|
230
|
+
const recoveryCodes = generateRecoveryCodes(this.recoveryCodeCount, RECOVERY_CODE_LENGTH)
|
|
231
|
+
const hashedCodes = await Promise.all(recoveryCodes.map((c) => hashRecoveryCode(c)))
|
|
232
|
+
|
|
233
|
+
const totpSecret: TotpSecret = {
|
|
234
|
+
userId,
|
|
235
|
+
secret,
|
|
236
|
+
verified: false,
|
|
237
|
+
recoveryCodes: hashedCodes,
|
|
238
|
+
createdAt: Date.now(),
|
|
239
|
+
verifiedAt: null,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
await this.store.save(totpSecret)
|
|
243
|
+
|
|
244
|
+
const uri = buildOtpauthUri({
|
|
245
|
+
issuer: this.issuer,
|
|
246
|
+
accountName,
|
|
247
|
+
secret,
|
|
248
|
+
algorithm: this.algorithm,
|
|
249
|
+
digits: this.digits,
|
|
250
|
+
period: this.period,
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
return { secret, uri, recoveryCodes }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Verify the TOTP setup by confirming the user can generate a valid code.
|
|
258
|
+
* Must be called after `enable()` and before MFA is enforced.
|
|
259
|
+
*/
|
|
260
|
+
async verifySetup(userId: string, code: string): Promise<boolean> {
|
|
261
|
+
const stored = await this.store.getByUserId(userId)
|
|
262
|
+
if (!stored) {
|
|
263
|
+
throw new TotpNotEnabledError(userId)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (stored.verified) {
|
|
267
|
+
// Already verified, just validate the code (single-use enforced)
|
|
268
|
+
return this.consumeCode(stored, code)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const valid = await this.consumeCode(stored, code)
|
|
272
|
+
if (!valid) {
|
|
273
|
+
throw new TotpInvalidCodeError()
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Mark as verified
|
|
277
|
+
stored.verified = true
|
|
278
|
+
stored.verifiedAt = Date.now()
|
|
279
|
+
await this.store.save(stored)
|
|
280
|
+
|
|
281
|
+
return true
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Verify a TOTP code during login.
|
|
286
|
+
* Returns true if the code is valid, false otherwise.
|
|
287
|
+
*
|
|
288
|
+
* Enforces single-use: a code is rejected if its time-step has already
|
|
289
|
+
* been consumed (or predates a previously consumed time-step). This
|
|
290
|
+
* prevents an attacker who observes one valid code from replaying it
|
|
291
|
+
* within the acceptance window (RFC 6238 §5.2).
|
|
292
|
+
*/
|
|
293
|
+
async verify(userId: string, code: string): Promise<boolean> {
|
|
294
|
+
const stored = await this.store.getByUserId(userId)
|
|
295
|
+
if (!stored) {
|
|
296
|
+
throw new TotpNotEnabledError(userId)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (!stored.verified) {
|
|
300
|
+
throw new TotpNotVerifiedError(userId)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (this.isLocked(stored)) {
|
|
304
|
+
return false
|
|
305
|
+
}
|
|
306
|
+
return this.checkTotp(stored, code)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Verify a recovery code as an alternative to TOTP.
|
|
311
|
+
* Recovery codes are single-use.
|
|
312
|
+
*/
|
|
313
|
+
async verifyRecoveryCode(userId: string, recoveryCode: string): Promise<boolean> {
|
|
314
|
+
const stored = await this.store.getByUserId(userId)
|
|
315
|
+
if (!stored) {
|
|
316
|
+
throw new TotpNotEnabledError(userId)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (!stored.verified) {
|
|
320
|
+
throw new TotpNotVerifiedError(userId)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (this.isLocked(stored)) {
|
|
324
|
+
return false
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const hashed = await hashRecoveryCode(recoveryCode.trim())
|
|
328
|
+
|
|
329
|
+
const index = stored.recoveryCodes.indexOf(hashed)
|
|
330
|
+
if (index === -1) {
|
|
331
|
+
await this.recordFailure(stored)
|
|
332
|
+
return false
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Consume the recovery code (single-use)
|
|
336
|
+
stored.recoveryCodes.splice(index, 1)
|
|
337
|
+
stored.failedAttempts = 0
|
|
338
|
+
stored.lockedUntil = undefined
|
|
339
|
+
await this.store.save(stored)
|
|
340
|
+
|
|
341
|
+
return true
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Regenerate recovery codes. Requires a valid TOTP code for authorization.
|
|
346
|
+
* Replaces all existing recovery codes.
|
|
347
|
+
*/
|
|
348
|
+
async regenerateRecoveryCodes(userId: string, totpCode: string): Promise<string[]> {
|
|
349
|
+
const stored = await this.store.getByUserId(userId)
|
|
350
|
+
if (!stored) {
|
|
351
|
+
throw new TotpNotEnabledError(userId)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (!stored.verified) {
|
|
355
|
+
throw new TotpNotVerifiedError(userId)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
this.assertNotLocked(stored)
|
|
359
|
+
// consumeCode (not a bare validation): an observed or already-used code
|
|
360
|
+
// cannot authorize this a second time.
|
|
361
|
+
const valid = await this.checkTotp(stored, totpCode)
|
|
362
|
+
if (!valid) {
|
|
363
|
+
throw new TotpInvalidCodeError()
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const recoveryCodes = generateRecoveryCodes(this.recoveryCodeCount, RECOVERY_CODE_LENGTH)
|
|
367
|
+
const hashedCodes = await Promise.all(recoveryCodes.map((c) => hashRecoveryCode(c)))
|
|
368
|
+
|
|
369
|
+
stored.recoveryCodes = hashedCodes
|
|
370
|
+
await this.store.save(stored)
|
|
371
|
+
|
|
372
|
+
return recoveryCodes
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Disable TOTP MFA for a user.
|
|
377
|
+
* Requires a valid TOTP code or recovery code for authorization.
|
|
378
|
+
*/
|
|
379
|
+
async disable(userId: string, code: string): Promise<void> {
|
|
380
|
+
const stored = await this.store.getByUserId(userId)
|
|
381
|
+
if (!stored) {
|
|
382
|
+
throw new TotpNotEnabledError(userId)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
this.assertNotLocked(stored)
|
|
386
|
+
|
|
387
|
+
// Accept either a fresh (never used) TOTP code or a recovery code
|
|
388
|
+
let authorized = false
|
|
389
|
+
|
|
390
|
+
if (stored.verified) {
|
|
391
|
+
authorized = this.matchFreshTimeStep(stored, code) !== null
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (!authorized) {
|
|
395
|
+
const hashed = await hashRecoveryCode(code.trim())
|
|
396
|
+
authorized = stored.recoveryCodes.includes(hashed)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (!authorized) {
|
|
400
|
+
await this.recordFailure(stored)
|
|
401
|
+
throw new TotpInvalidCodeError()
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
await this.store.delete(userId)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Check if a user has TOTP MFA enabled and verified.
|
|
409
|
+
*/
|
|
410
|
+
async isEnabled(userId: string): Promise<boolean> {
|
|
411
|
+
const stored = await this.store.getByUserId(userId)
|
|
412
|
+
return stored?.verified ?? false
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Get the number of remaining recovery codes for a user.
|
|
417
|
+
*/
|
|
418
|
+
async remainingRecoveryCodes(userId: string): Promise<number> {
|
|
419
|
+
const stored = await this.store.getByUserId(userId)
|
|
420
|
+
if (!stored || !stored.verified) return 0
|
|
421
|
+
return stored.recoveryCodes.length
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// --- Private ---
|
|
425
|
+
|
|
426
|
+
private isLocked(stored: TotpSecret): boolean {
|
|
427
|
+
return stored.lockedUntil !== undefined && Date.now() < stored.lockedUntil
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private assertNotLocked(stored: TotpSecret): void {
|
|
431
|
+
if (stored.lockedUntil !== undefined && Date.now() < stored.lockedUntil) {
|
|
432
|
+
throw new TotpLockedError(stored.lockedUntil)
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Count a failed code check. After FREE_FAILURES consecutive failures, code
|
|
438
|
+
* checks lock for an exponentially growing period (30s, 60s, ... up to 15
|
|
439
|
+
* minutes), which bounds online guessing of a 6-digit code (AUTH-10) without
|
|
440
|
+
* a permanent lockout an attacker could trigger at will.
|
|
441
|
+
*/
|
|
442
|
+
private async recordFailure(stored: TotpSecret): Promise<void> {
|
|
443
|
+
const failures = (stored.failedAttempts ?? 0) + 1
|
|
444
|
+
stored.failedAttempts = failures
|
|
445
|
+
if (failures >= FREE_FAILURES) {
|
|
446
|
+
const delay = Math.min(LOCK_BASE_MS * 2 ** (failures - FREE_FAILURES), LOCK_MAX_MS)
|
|
447
|
+
stored.lockedUntil = Date.now() + delay
|
|
448
|
+
}
|
|
449
|
+
await this.store.save(stored)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** consumeCode plus failure accounting. */
|
|
453
|
+
private async checkTotp(stored: TotpSecret, code: string): Promise<boolean> {
|
|
454
|
+
const ok = await this.consumeCode(stored, code)
|
|
455
|
+
if (!ok) {
|
|
456
|
+
await this.recordFailure(stored)
|
|
457
|
+
return false
|
|
458
|
+
}
|
|
459
|
+
if (stored.failedAttempts || stored.lockedUntil !== undefined) {
|
|
460
|
+
stored.failedAttempts = 0
|
|
461
|
+
stored.lockedUntil = undefined
|
|
462
|
+
await this.store.save(stored)
|
|
463
|
+
}
|
|
464
|
+
return true
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** A matching time-step that has not been consumed yet, or null. */
|
|
468
|
+
private matchFreshTimeStep(stored: TotpSecret, code: string): number | null {
|
|
469
|
+
const matched = this.matchTimeStep(stored.secret, code)
|
|
470
|
+
if (matched === null) return null
|
|
471
|
+
if (stored.lastUsedTimeStep !== undefined && matched <= stored.lastUsedTimeStep) return null
|
|
472
|
+
return matched
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Find the TOTP time-step counter (within the acceptance window) whose
|
|
477
|
+
* generated code matches `code`, or null if none matches. Comparison is
|
|
478
|
+
* timing-safe.
|
|
479
|
+
*/
|
|
480
|
+
private matchTimeStep(base32Secret: string, code: string): number | null {
|
|
481
|
+
const secretBytes = base32Decode(base32Secret)
|
|
482
|
+
const now = Math.floor(Date.now() / 1000)
|
|
483
|
+
|
|
484
|
+
// Check current window and adjacent windows
|
|
485
|
+
for (let offset = -this.window; offset <= this.window; offset++) {
|
|
486
|
+
const timeCounter = Math.floor((now + offset * this.period) / this.period)
|
|
487
|
+
const expected = generateTotpCode(secretBytes, timeCounter, this.digits, this.algorithm)
|
|
488
|
+
if (timingSafeEqual(code, expected)) {
|
|
489
|
+
return timeCounter
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return null
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Validate a code AND consume its time-step so it (and any earlier code in
|
|
498
|
+
* the window) cannot be reused. Returns false for an invalid code or a
|
|
499
|
+
* replay of an already-consumed time-step. Persists the consumed time-step.
|
|
500
|
+
*/
|
|
501
|
+
private async consumeCode(stored: TotpSecret, code: string): Promise<boolean> {
|
|
502
|
+
// Reject replays: a code whose time-step was already used (or predates
|
|
503
|
+
// the last consumed time-step) must not authenticate a second time.
|
|
504
|
+
const matched = this.matchFreshTimeStep(stored, code)
|
|
505
|
+
if (matched === null) {
|
|
506
|
+
return false
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
stored.lastUsedTimeStep = matched
|
|
510
|
+
await this.store.save(stored)
|
|
511
|
+
return true
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ============================================================================
|
|
516
|
+
// TOTP Core (RFC 6238 / RFC 4226)
|
|
517
|
+
// ============================================================================
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Generate a TOTP code for a given time counter.
|
|
521
|
+
* Implements HOTP (RFC 4226) with a time-based counter (RFC 6238).
|
|
522
|
+
*/
|
|
523
|
+
function generateTotpCode(
|
|
524
|
+
secret: Uint8Array,
|
|
525
|
+
counter: number,
|
|
526
|
+
digits: number,
|
|
527
|
+
algorithm: string,
|
|
528
|
+
): string {
|
|
529
|
+
// Counter as 8-byte big-endian
|
|
530
|
+
const counterBytes = new Uint8Array(8)
|
|
531
|
+
let c = counter
|
|
532
|
+
for (let i = 7; i >= 0; i--) {
|
|
533
|
+
counterBytes[i] = c & 0xff
|
|
534
|
+
c = Math.floor(c / 256)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// HMAC-SHA1 (or SHA-256/SHA-512)
|
|
538
|
+
const hash = hmacSha(algorithm, secret, counterBytes)
|
|
539
|
+
|
|
540
|
+
// Dynamic truncation (RFC 4226 section 5.4)
|
|
541
|
+
const offset = (hash[hash.length - 1] as number) & 0x0f
|
|
542
|
+
const binary =
|
|
543
|
+
(((hash[offset] as number) & 0x7f) << 24) |
|
|
544
|
+
(((hash[offset + 1] as number) & 0xff) << 16) |
|
|
545
|
+
(((hash[offset + 2] as number) & 0xff) << 8) |
|
|
546
|
+
((hash[offset + 3] as number) & 0xff)
|
|
547
|
+
|
|
548
|
+
const otp = binary % 10 ** digits
|
|
549
|
+
return otp.toString().padStart(digits, '0')
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Synchronous HMAC using a simplified implementation.
|
|
554
|
+
* TOTP only needs HMAC-SHA1 which we implement directly
|
|
555
|
+
* to avoid async Web Crypto for the hot path (validation).
|
|
556
|
+
*/
|
|
557
|
+
function hmacSha(algorithm: string, key: Uint8Array, message: Uint8Array): Uint8Array {
|
|
558
|
+
// Use the appropriate block size and hash
|
|
559
|
+
const blockSize = algorithm === 'SHA-512' ? 128 : 64
|
|
560
|
+
|
|
561
|
+
// Pad or hash the key
|
|
562
|
+
let keyPad = key
|
|
563
|
+
if (keyPad.length > blockSize) {
|
|
564
|
+
keyPad = sha1(keyPad)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Create padded key
|
|
568
|
+
const ipad = new Uint8Array(blockSize)
|
|
569
|
+
const opad = new Uint8Array(blockSize)
|
|
570
|
+
for (let i = 0; i < blockSize; i++) {
|
|
571
|
+
const k = i < keyPad.length ? (keyPad[i] as number) : 0
|
|
572
|
+
ipad[i] = k ^ 0x36
|
|
573
|
+
opad[i] = k ^ 0x5c
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Inner hash: H(key XOR ipad || message)
|
|
577
|
+
const innerData = new Uint8Array(blockSize + message.length)
|
|
578
|
+
innerData.set(ipad)
|
|
579
|
+
innerData.set(message, blockSize)
|
|
580
|
+
const innerHash = sha1(innerData)
|
|
581
|
+
|
|
582
|
+
// Outer hash: H(key XOR opad || inner_hash)
|
|
583
|
+
const outerData = new Uint8Array(blockSize + innerHash.length)
|
|
584
|
+
outerData.set(opad)
|
|
585
|
+
outerData.set(innerHash, blockSize)
|
|
586
|
+
return sha1(outerData)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* SHA-1 implementation for TOTP HMAC.
|
|
591
|
+
* SHA-1 is the standard for TOTP (RFC 6238) and is NOT used for security
|
|
592
|
+
* hashing here — it's used as a PRF inside HMAC which is still secure.
|
|
593
|
+
*/
|
|
594
|
+
function sha1(data: Uint8Array): Uint8Array {
|
|
595
|
+
let h0 = 0x67452301
|
|
596
|
+
let h1 = 0xefcdab89
|
|
597
|
+
let h2 = 0x98badcfe
|
|
598
|
+
let h3 = 0x10325476
|
|
599
|
+
let h4 = 0xc3d2e1f0
|
|
600
|
+
|
|
601
|
+
const bitLength = data.length * 8
|
|
602
|
+
|
|
603
|
+
// Pre-processing: add padding
|
|
604
|
+
// message + 1 bit + zeros + 64-bit length
|
|
605
|
+
const paddedLength = Math.ceil((data.length + 9) / 64) * 64
|
|
606
|
+
const padded = new Uint8Array(paddedLength)
|
|
607
|
+
padded.set(data)
|
|
608
|
+
padded[data.length] = 0x80
|
|
609
|
+
|
|
610
|
+
// Append length as 64-bit big-endian
|
|
611
|
+
const view = new DataView(padded.buffer, padded.byteOffset)
|
|
612
|
+
// For messages < 2^32 bits, high 32 bits are 0
|
|
613
|
+
view.setUint32(paddedLength - 4, bitLength, false)
|
|
614
|
+
|
|
615
|
+
// Process each 512-bit (64-byte) block
|
|
616
|
+
const w = new Int32Array(80)
|
|
617
|
+
|
|
618
|
+
for (let offset = 0; offset < paddedLength; offset += 64) {
|
|
619
|
+
for (let i = 0; i < 16; i++) {
|
|
620
|
+
w[i] = view.getInt32(offset + i * 4, false)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
for (let i = 16; i < 80; i++) {
|
|
624
|
+
w[i] = rotl32(
|
|
625
|
+
((w[i - 3] as number) ^
|
|
626
|
+
(w[i - 8] as number) ^
|
|
627
|
+
(w[i - 14] as number) ^
|
|
628
|
+
(w[i - 16] as number)) |
|
|
629
|
+
0,
|
|
630
|
+
1,
|
|
631
|
+
)
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
let a = h0
|
|
635
|
+
let b = h1
|
|
636
|
+
let c = h2
|
|
637
|
+
let d = h3
|
|
638
|
+
let e = h4
|
|
639
|
+
|
|
640
|
+
for (let i = 0; i < 80; i++) {
|
|
641
|
+
let f: number
|
|
642
|
+
let k: number
|
|
643
|
+
|
|
644
|
+
if (i < 20) {
|
|
645
|
+
f = (b & c) | (~b & d)
|
|
646
|
+
k = 0x5a827999
|
|
647
|
+
} else if (i < 40) {
|
|
648
|
+
f = b ^ c ^ d
|
|
649
|
+
k = 0x6ed9eba1
|
|
650
|
+
} else if (i < 60) {
|
|
651
|
+
f = (b & c) | (b & d) | (c & d)
|
|
652
|
+
k = 0x8f1bbcdc
|
|
653
|
+
} else {
|
|
654
|
+
f = b ^ c ^ d
|
|
655
|
+
k = 0xca62c1d6
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const temp = (rotl32(a, 5) + f + e + k + (w[i] as number)) | 0
|
|
659
|
+
e = d
|
|
660
|
+
d = c
|
|
661
|
+
c = rotl32(b, 30)
|
|
662
|
+
b = a
|
|
663
|
+
a = temp
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
h0 = (h0 + a) | 0
|
|
667
|
+
h1 = (h1 + b) | 0
|
|
668
|
+
h2 = (h2 + c) | 0
|
|
669
|
+
h3 = (h3 + d) | 0
|
|
670
|
+
h4 = (h4 + e) | 0
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const result = new Uint8Array(20)
|
|
674
|
+
const rv = new DataView(result.buffer)
|
|
675
|
+
rv.setInt32(0, h0, false)
|
|
676
|
+
rv.setInt32(4, h1, false)
|
|
677
|
+
rv.setInt32(8, h2, false)
|
|
678
|
+
rv.setInt32(12, h3, false)
|
|
679
|
+
rv.setInt32(16, h4, false)
|
|
680
|
+
|
|
681
|
+
return result
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function rotl32(value: number, shift: number): number {
|
|
685
|
+
return (value << shift) | (value >>> (32 - shift)) | 0
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// ============================================================================
|
|
689
|
+
// Base32 Encoding/Decoding (RFC 4648)
|
|
690
|
+
// ============================================================================
|
|
691
|
+
|
|
692
|
+
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Encode bytes to base32 string (RFC 4648, no padding).
|
|
696
|
+
*/
|
|
697
|
+
export function base32Encode(data: Uint8Array): string {
|
|
698
|
+
let result = ''
|
|
699
|
+
let bits = 0
|
|
700
|
+
let buffer = 0
|
|
701
|
+
|
|
702
|
+
for (let i = 0; i < data.length; i++) {
|
|
703
|
+
buffer = (buffer << 8) | (data[i] as number)
|
|
704
|
+
bits += 8
|
|
705
|
+
while (bits >= 5) {
|
|
706
|
+
bits -= 5
|
|
707
|
+
result += BASE32_ALPHABET[(buffer >>> bits) & 0x1f]
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (bits > 0) {
|
|
712
|
+
result += BASE32_ALPHABET[(buffer << (5 - bits)) & 0x1f]
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
return result
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Decode a base32 string to bytes (RFC 4648).
|
|
720
|
+
*/
|
|
721
|
+
export function base32Decode(encoded: string): Uint8Array {
|
|
722
|
+
const cleaned = encoded.replace(/=+$/, '').toUpperCase()
|
|
723
|
+
const output: number[] = []
|
|
724
|
+
let bits = 0
|
|
725
|
+
let buffer = 0
|
|
726
|
+
|
|
727
|
+
for (let i = 0; i < cleaned.length; i++) {
|
|
728
|
+
const char = cleaned[i] as string
|
|
729
|
+
const value = BASE32_ALPHABET.indexOf(char)
|
|
730
|
+
if (value === -1) continue // skip invalid chars
|
|
731
|
+
|
|
732
|
+
buffer = (buffer << 5) | value
|
|
733
|
+
bits += 5
|
|
734
|
+
|
|
735
|
+
if (bits >= 8) {
|
|
736
|
+
bits -= 8
|
|
737
|
+
output.push((buffer >>> bits) & 0xff)
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
return new Uint8Array(output)
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// ============================================================================
|
|
745
|
+
// Helpers
|
|
746
|
+
// ============================================================================
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Generate a random secret of the given byte length.
|
|
750
|
+
*/
|
|
751
|
+
function generateSecret(byteLength: number): Uint8Array {
|
|
752
|
+
const bytes = new Uint8Array(byteLength)
|
|
753
|
+
globalThis.crypto.getRandomValues(bytes)
|
|
754
|
+
return bytes
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Generate random recovery codes.
|
|
759
|
+
*/
|
|
760
|
+
function generateRecoveryCodes(count: number, length: number): string[] {
|
|
761
|
+
const codes: string[] = []
|
|
762
|
+
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
|
763
|
+
|
|
764
|
+
for (let i = 0; i < count; i++) {
|
|
765
|
+
const bytes = new Uint8Array(length)
|
|
766
|
+
globalThis.crypto.getRandomValues(bytes)
|
|
767
|
+
let code = ''
|
|
768
|
+
for (let j = 0; j < length; j++) {
|
|
769
|
+
code += chars[(bytes[j] as number) % chars.length]
|
|
770
|
+
}
|
|
771
|
+
// Format as xxxxx-xxxxx for readability
|
|
772
|
+
codes.push(`${code.slice(0, 5)}-${code.slice(5)}`)
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
return codes
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Hash a recovery code for storage (SHA-256).
|
|
780
|
+
*/
|
|
781
|
+
async function hashRecoveryCode(code: string): Promise<string> {
|
|
782
|
+
const encoded = new TextEncoder().encode(code.toLowerCase().replace(/[-\s]/g, ''))
|
|
783
|
+
const hash = await globalThis.crypto.subtle.digest('SHA-256', encoded)
|
|
784
|
+
const bytes = new Uint8Array(hash)
|
|
785
|
+
let hex = ''
|
|
786
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
787
|
+
hex += bytes[i]?.toString(16).padStart(2, '0')
|
|
788
|
+
}
|
|
789
|
+
return hex
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Build an otpauth:// URI for QR code generation.
|
|
794
|
+
*/
|
|
795
|
+
function buildOtpauthUri(params: {
|
|
796
|
+
issuer: string
|
|
797
|
+
accountName: string
|
|
798
|
+
secret: string
|
|
799
|
+
algorithm: string
|
|
800
|
+
digits: number
|
|
801
|
+
period: number
|
|
802
|
+
}): string {
|
|
803
|
+
const label = `${encodeURIComponent(params.issuer)}:${encodeURIComponent(params.accountName)}`
|
|
804
|
+
const query = new URLSearchParams({
|
|
805
|
+
secret: params.secret,
|
|
806
|
+
issuer: params.issuer,
|
|
807
|
+
algorithm: params.algorithm.replace('-', ''),
|
|
808
|
+
digits: params.digits.toString(),
|
|
809
|
+
period: params.period.toString(),
|
|
810
|
+
})
|
|
811
|
+
return `otpauth://totp/${label}?${query.toString()}`
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Timing-safe string comparison to prevent timing attacks.
|
|
816
|
+
*/
|
|
817
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
818
|
+
if (a.length !== b.length) return false
|
|
819
|
+
|
|
820
|
+
let result = 0
|
|
821
|
+
for (let i = 0; i < a.length; i++) {
|
|
822
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i)
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
return result === 0
|
|
826
|
+
}
|