@korajs/auth 0.3.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-FSU4SK32.js → chunk-IO2MCCG2.js} +35 -40
- package/dist/chunk-IO2MCCG2.js.map +1 -0
- package/dist/{chunk-HOZXDR6Y.js → chunk-L7GXPS74.js} +3 -9
- package/dist/chunk-L7GXPS74.js.map +1 -0
- package/dist/index.cjs +102 -122
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +69 -84
- package/dist/index.js.map +1 -1
- package/dist/{password-hash-HDH6VQCQ.js → password-hash-QRBG6BNJ.js} +2 -2
- package/dist/react.cjs.map +1 -1
- package/dist/react.js.map +1 -1
- package/dist/server.cjs +1486 -1515
- package/dist/server.cjs.map +1 -1
- package/dist/server.js +1331 -1351
- package/dist/server.js.map +1 -1
- package/package.json +5 -5
- package/dist/chunk-FSU4SK32.js.map +0 -1
- package/dist/chunk-HOZXDR6Y.js.map +0 -1
- /package/dist/{password-hash-HDH6VQCQ.js.map → password-hash-QRBG6BNJ.js.map} +0 -0
package/dist/server.cjs
CHANGED
|
@@ -47,14 +47,8 @@ __export(password_hash_exports, {
|
|
|
47
47
|
verifyPassword: () => verifyPassword
|
|
48
48
|
});
|
|
49
49
|
async function hashPassword(password) {
|
|
50
|
-
const salt = (0,
|
|
51
|
-
const derivedKey = await pbkdf2Async(
|
|
52
|
-
password,
|
|
53
|
-
salt,
|
|
54
|
-
PBKDF2_ITERATIONS,
|
|
55
|
-
KEY_LENGTH,
|
|
56
|
-
PBKDF2_DIGEST
|
|
57
|
-
);
|
|
50
|
+
const salt = (0, import_node_crypto.randomBytes)(SALT_LENGTH);
|
|
51
|
+
const derivedKey = await pbkdf2Async(password, salt, PBKDF2_ITERATIONS, KEY_LENGTH, PBKDF2_DIGEST);
|
|
58
52
|
return {
|
|
59
53
|
hash: derivedKey.toString("hex"),
|
|
60
54
|
salt: salt.toString("hex")
|
|
@@ -73,11 +67,11 @@ async function verifyPassword(password, hash, salt) {
|
|
|
73
67
|
if (derivedKey.length !== hashBuffer.length) {
|
|
74
68
|
return false;
|
|
75
69
|
}
|
|
76
|
-
return (0,
|
|
70
|
+
return (0, import_node_crypto.timingSafeEqual)(derivedKey, hashBuffer);
|
|
77
71
|
}
|
|
78
72
|
function pbkdf2Async(password, salt, iterations, keyLength, digest) {
|
|
79
73
|
return new Promise((resolve, reject) => {
|
|
80
|
-
(0,
|
|
74
|
+
(0, import_node_crypto.pbkdf2)(password, salt, iterations, keyLength, digest, (err, derivedKey) => {
|
|
81
75
|
if (err) {
|
|
82
76
|
reject(err);
|
|
83
77
|
} else {
|
|
@@ -86,12 +80,12 @@ function pbkdf2Async(password, salt, iterations, keyLength, digest) {
|
|
|
86
80
|
});
|
|
87
81
|
});
|
|
88
82
|
}
|
|
89
|
-
var
|
|
83
|
+
var import_node_crypto, PBKDF2_ITERATIONS, PBKDF2_DIGEST, KEY_LENGTH, SALT_LENGTH;
|
|
90
84
|
var init_password_hash = __esm({
|
|
91
85
|
"src/provider/built-in/password-hash.ts"() {
|
|
92
86
|
"use strict";
|
|
93
87
|
init_cjs_shims();
|
|
94
|
-
|
|
88
|
+
import_node_crypto = require("crypto");
|
|
95
89
|
PBKDF2_ITERATIONS = 6e5;
|
|
96
90
|
PBKDF2_DIGEST = "sha512";
|
|
97
91
|
KEY_LENGTH = 64;
|
|
@@ -217,1399 +211,1156 @@ init_cjs_shims();
|
|
|
217
211
|
|
|
218
212
|
// src/provider/built-in/auth-routes.ts
|
|
219
213
|
init_cjs_shims();
|
|
220
|
-
var import_node_crypto5 = require("crypto");
|
|
221
|
-
|
|
222
|
-
// src/tokens/token-manager.ts
|
|
223
|
-
init_cjs_shims();
|
|
224
214
|
var import_node_crypto2 = require("crypto");
|
|
225
215
|
|
|
226
|
-
// src/
|
|
216
|
+
// src/device/device-identity.ts
|
|
227
217
|
init_cjs_shims();
|
|
228
218
|
var import_core = require("@korajs/core");
|
|
229
|
-
var
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
init_cjs_shims();
|
|
237
|
-
var import_node_crypto = require("crypto");
|
|
238
|
-
function base64urlEncode(input) {
|
|
239
|
-
return Buffer.from(input, "utf-8").toString("base64url");
|
|
240
|
-
}
|
|
241
|
-
function base64urlDecode(input) {
|
|
242
|
-
return Buffer.from(input, "base64url").toString("utf-8");
|
|
243
|
-
}
|
|
244
|
-
function hmacSha256Base64url(data, secret) {
|
|
245
|
-
return (0, import_node_crypto.createHmac)("sha256", secret).update(data).digest("base64url");
|
|
246
|
-
}
|
|
247
|
-
var ENCODED_HEADER = base64urlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
|
248
|
-
function encodeJwt(payload, secret) {
|
|
249
|
-
const encodedPayload = base64urlEncode(JSON.stringify(payload));
|
|
250
|
-
const signingInput = `${ENCODED_HEADER}.${encodedPayload}`;
|
|
251
|
-
const signature = hmacSha256Base64url(signingInput, secret);
|
|
252
|
-
return `${signingInput}.${signature}`;
|
|
253
|
-
}
|
|
254
|
-
function decodeJwt(token) {
|
|
255
|
-
const parts = token.split(".");
|
|
256
|
-
if (parts.length !== 3) {
|
|
257
|
-
return null;
|
|
219
|
+
var CryptoUnavailableError = class extends import_core.KoraError {
|
|
220
|
+
constructor() {
|
|
221
|
+
super(
|
|
222
|
+
"Web Crypto API (crypto.subtle) is not available in this environment. Device identity requires crypto.subtle, which is available in modern browsers and Node.js 20+. If running in SSR, ensure your runtime provides the Web Crypto API.",
|
|
223
|
+
"CRYPTO_UNAVAILABLE"
|
|
224
|
+
);
|
|
225
|
+
this.name = "CryptoUnavailableError";
|
|
258
226
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
227
|
+
};
|
|
228
|
+
var DeviceIdentityError = class extends import_core.KoraError {
|
|
229
|
+
constructor(message, context) {
|
|
230
|
+
super(message, "DEVICE_IDENTITY_ERROR", context);
|
|
231
|
+
this.name = "DeviceIdentityError";
|
|
262
232
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
return parsed;
|
|
270
|
-
} catch {
|
|
271
|
-
return null;
|
|
233
|
+
};
|
|
234
|
+
function toBase64Url(buffer) {
|
|
235
|
+
const bytes = new Uint8Array(buffer);
|
|
236
|
+
let binary = "";
|
|
237
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
238
|
+
binary += String.fromCharCode(bytes[i]);
|
|
272
239
|
}
|
|
240
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
273
241
|
}
|
|
274
|
-
function
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
if (headerSegment === void 0 || payloadSegment === void 0 || signatureSegment === void 0) {
|
|
283
|
-
return null;
|
|
242
|
+
function fromBase64Url(str) {
|
|
243
|
+
let base64 = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
244
|
+
const paddingNeeded = (4 - base64.length % 4) % 4;
|
|
245
|
+
base64 += "=".repeat(paddingNeeded);
|
|
246
|
+
const binary = atob(base64);
|
|
247
|
+
const bytes = new Uint8Array(binary.length);
|
|
248
|
+
for (let i = 0; i < binary.length; i++) {
|
|
249
|
+
bytes[i] = binary.charCodeAt(i);
|
|
284
250
|
}
|
|
285
|
-
|
|
286
|
-
|
|
251
|
+
return bytes;
|
|
252
|
+
}
|
|
253
|
+
function assertCryptoAvailable() {
|
|
254
|
+
if (typeof globalThis.crypto === "undefined" || typeof globalThis.crypto.subtle === "undefined") {
|
|
255
|
+
throw new CryptoUnavailableError();
|
|
287
256
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
257
|
+
}
|
|
258
|
+
var ECDSA_ALGORITHM = {
|
|
259
|
+
name: "ECDSA",
|
|
260
|
+
namedCurve: "P-256"
|
|
261
|
+
};
|
|
262
|
+
var ECDSA_SIGN_ALGORITHM = {
|
|
263
|
+
name: "ECDSA",
|
|
264
|
+
hash: { name: "SHA-256" }
|
|
265
|
+
};
|
|
266
|
+
async function verifyChallenge(publicKeyJwk, challenge, signature) {
|
|
267
|
+
assertCryptoAvailable();
|
|
268
|
+
try {
|
|
269
|
+
const publicKey = await globalThis.crypto.subtle.importKey(
|
|
270
|
+
"jwk",
|
|
271
|
+
publicKeyJwk,
|
|
272
|
+
ECDSA_ALGORITHM,
|
|
273
|
+
true,
|
|
274
|
+
["verify"]
|
|
275
|
+
);
|
|
276
|
+
const encoded = new TextEncoder().encode(challenge);
|
|
277
|
+
const signatureBytes = fromBase64Url(signature);
|
|
278
|
+
const isValid = await globalThis.crypto.subtle.verify(
|
|
279
|
+
ECDSA_SIGN_ALGORITHM,
|
|
280
|
+
publicKey,
|
|
281
|
+
signatureBytes,
|
|
282
|
+
encoded
|
|
283
|
+
);
|
|
284
|
+
return isValid;
|
|
285
|
+
} catch (cause) {
|
|
286
|
+
throw new DeviceIdentityError(
|
|
287
|
+
"Failed to verify challenge signature. The public key JWK or signature format may be invalid.",
|
|
288
|
+
{
|
|
289
|
+
cause: cause instanceof Error ? cause.message : String(cause),
|
|
290
|
+
publicKeyKty: publicKeyJwk.kty,
|
|
291
|
+
publicKeyCrv: publicKeyJwk.crv
|
|
292
|
+
}
|
|
293
|
+
);
|
|
292
294
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
295
|
+
}
|
|
296
|
+
async function computePublicKeyThumbprint(publicKeyJwk) {
|
|
297
|
+
assertCryptoAvailable();
|
|
298
|
+
if (publicKeyJwk.kty !== "EC") {
|
|
299
|
+
throw new DeviceIdentityError(
|
|
300
|
+
`Expected JWK key type "EC" but received "${publicKeyJwk.kty ?? "undefined"}". Only ECDSA public keys are supported for device identity.`,
|
|
301
|
+
{ kty: publicKeyJwk.kty }
|
|
302
|
+
);
|
|
296
303
|
}
|
|
297
|
-
if (
|
|
298
|
-
|
|
304
|
+
if (!publicKeyJwk.crv || !publicKeyJwk.x || !publicKeyJwk.y) {
|
|
305
|
+
throw new DeviceIdentityError(
|
|
306
|
+
'JWK is missing required EC fields. An EC public key JWK must include "crv", "x", and "y" members.',
|
|
307
|
+
{
|
|
308
|
+
hasCrv: Boolean(publicKeyJwk.crv),
|
|
309
|
+
hasX: Boolean(publicKeyJwk.x),
|
|
310
|
+
hasY: Boolean(publicKeyJwk.y)
|
|
311
|
+
}
|
|
312
|
+
);
|
|
299
313
|
}
|
|
314
|
+
const canonicalJson = JSON.stringify({
|
|
315
|
+
crv: publicKeyJwk.crv,
|
|
316
|
+
kty: publicKeyJwk.kty,
|
|
317
|
+
x: publicKeyJwk.x,
|
|
318
|
+
y: publicKeyJwk.y
|
|
319
|
+
});
|
|
300
320
|
try {
|
|
301
|
-
const
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
return null;
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
var CLOCK_SKEW_TOLERANCE_SECONDS = 5;
|
|
312
|
-
function isExpired(payload) {
|
|
313
|
-
if (typeof payload.exp !== "number") {
|
|
314
|
-
return false;
|
|
321
|
+
const encoded = new TextEncoder().encode(canonicalJson);
|
|
322
|
+
const hashBuffer = await globalThis.crypto.subtle.digest("SHA-256", encoded);
|
|
323
|
+
return toBase64Url(hashBuffer);
|
|
324
|
+
} catch (cause) {
|
|
325
|
+
throw new DeviceIdentityError("Failed to compute SHA-256 thumbprint of the public key JWK.", {
|
|
326
|
+
cause: cause instanceof Error ? cause.message : String(cause)
|
|
327
|
+
});
|
|
315
328
|
}
|
|
316
|
-
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
317
|
-
return nowSeconds >= payload.exp + CLOCK_SKEW_TOLERANCE_SECONDS;
|
|
318
329
|
}
|
|
319
330
|
|
|
320
|
-
// src/
|
|
321
|
-
|
|
322
|
-
var
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
return this.revokedTokens.has(jti);
|
|
327
|
-
}
|
|
328
|
-
async revoke(jti, expiresAt) {
|
|
329
|
-
this.revokedTokens.set(jti, expiresAt);
|
|
330
|
-
}
|
|
331
|
-
async revokeAllForDevice(deviceId) {
|
|
332
|
-
this.revokedDevices.add(deviceId);
|
|
331
|
+
// src/provider/built-in/auth-routes.ts
|
|
332
|
+
init_password_hash();
|
|
333
|
+
var InMemoryChallengeStore = class {
|
|
334
|
+
challenges = /* @__PURE__ */ new Map();
|
|
335
|
+
async store(challenge, deviceId, expiresAt) {
|
|
336
|
+
this.challenges.set(challenge, { deviceId, expiresAt });
|
|
333
337
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
338
|
+
async consume(challenge) {
|
|
339
|
+
const entry = this.challenges.get(challenge);
|
|
340
|
+
if (entry === void 0) {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
this.challenges.delete(challenge);
|
|
344
|
+
if (Date.now() > entry.expiresAt) {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
return { deviceId: entry.deviceId };
|
|
339
348
|
}
|
|
340
349
|
/**
|
|
341
|
-
* Remove expired
|
|
342
|
-
* Call periodically (e.g., every hour) in long-running servers.
|
|
350
|
+
* Remove expired challenges to prevent unbounded memory growth.
|
|
343
351
|
*/
|
|
344
352
|
cleanup() {
|
|
345
|
-
const
|
|
346
|
-
for (const [
|
|
347
|
-
if (
|
|
348
|
-
this.
|
|
353
|
+
const now = Date.now();
|
|
354
|
+
for (const [challenge, entry] of this.challenges) {
|
|
355
|
+
if (now > entry.expiresAt) {
|
|
356
|
+
this.challenges.delete(challenge);
|
|
349
357
|
}
|
|
350
358
|
}
|
|
351
359
|
}
|
|
352
360
|
};
|
|
353
|
-
var
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
refreshTokenLifetime;
|
|
358
|
-
deviceCredentialLifetime;
|
|
359
|
-
revocationStore;
|
|
360
|
-
constructor(config) {
|
|
361
|
-
const secrets = Array.isArray(config.secret) ? config.secret : [config.secret];
|
|
362
|
-
if (secrets.length === 0) {
|
|
363
|
-
throw new Error("TokenManager requires at least one secret.");
|
|
364
|
-
}
|
|
365
|
-
for (const secret of secrets) {
|
|
366
|
-
if (secret.length < MIN_SECRET_LENGTH) {
|
|
367
|
-
throw new Error(
|
|
368
|
-
`JWT secret must be at least ${MIN_SECRET_LENGTH} characters (256 bits) for HMAC-SHA256 security. Received ${secret.length} characters. Use TokenManager.generateSecret() to generate a secure secret.`
|
|
369
|
-
);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
this.secrets = secrets;
|
|
373
|
-
this.accessTokenLifetime = config.accessTokenLifetime ?? DEFAULT_ACCESS_TOKEN_LIFETIME;
|
|
374
|
-
this.refreshTokenLifetime = config.refreshTokenLifetime ?? DEFAULT_REFRESH_TOKEN_LIFETIME;
|
|
375
|
-
this.deviceCredentialLifetime = config.deviceCredentialLifetime ?? DEFAULT_DEVICE_CREDENTIAL_LIFETIME;
|
|
376
|
-
this.revocationStore = config.revocationStore;
|
|
377
|
-
}
|
|
378
|
-
/**
|
|
379
|
-
* Generate a cryptographically random secret suitable for HMAC-SHA256 signing.
|
|
380
|
-
*
|
|
381
|
-
* Returns a 64-character hex string (32 bytes / 256 bits of entropy).
|
|
382
|
-
* Store this securely (environment variable, secrets manager) — never in source code.
|
|
383
|
-
*
|
|
384
|
-
* @returns A random 256-bit hex-encoded secret
|
|
385
|
-
*/
|
|
386
|
-
static generateSecret() {
|
|
387
|
-
return (0, import_node_crypto2.randomBytes)(32).toString("hex");
|
|
388
|
-
}
|
|
361
|
+
var InMemoryRateLimiter = class {
|
|
362
|
+
attempts = /* @__PURE__ */ new Map();
|
|
363
|
+
maxAttempts;
|
|
364
|
+
windowMs;
|
|
389
365
|
/**
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
* Access tokens are short-lived (default 15 minutes) and used to authorize
|
|
393
|
-
* API requests. When expired, use {@link refreshAccessToken} with a valid
|
|
394
|
-
* refresh token to obtain a new one.
|
|
395
|
-
*
|
|
396
|
-
* @param userId - The subject (user ID) to encode in the token
|
|
397
|
-
* @param deviceId - The device ID of the requesting device
|
|
398
|
-
* @returns A signed JWT string with type 'access'
|
|
366
|
+
* @param maxAttempts - Maximum number of attempts within the time window (default: 10)
|
|
367
|
+
* @param windowMs - Time window in milliseconds (default: 60,000 = 1 minute)
|
|
399
368
|
*/
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
jti: (0, import_node_crypto2.randomUUID)(),
|
|
404
|
-
sub: userId,
|
|
405
|
-
dev: deviceId,
|
|
406
|
-
type: "access",
|
|
407
|
-
iat: nowSeconds,
|
|
408
|
-
exp: nowSeconds + Math.floor(this.accessTokenLifetime / 1e3)
|
|
409
|
-
};
|
|
410
|
-
return encodeJwt(payload, this.secrets[0]);
|
|
369
|
+
constructor(maxAttempts = 10, windowMs = 6e4) {
|
|
370
|
+
this.maxAttempts = maxAttempts;
|
|
371
|
+
this.windowMs = windowMs;
|
|
411
372
|
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
* be stored securely and never sent to resource APIs.
|
|
418
|
-
*
|
|
419
|
-
* @param userId - The subject (user ID) to encode in the token
|
|
420
|
-
* @param deviceId - The device ID of the requesting device
|
|
421
|
-
* @returns A signed JWT string with type 'refresh'
|
|
422
|
-
*/
|
|
423
|
-
issueRefreshToken(userId, deviceId) {
|
|
424
|
-
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
425
|
-
const payload = {
|
|
426
|
-
jti: (0, import_node_crypto2.randomUUID)(),
|
|
427
|
-
sub: userId,
|
|
428
|
-
dev: deviceId,
|
|
429
|
-
type: "refresh",
|
|
430
|
-
iat: nowSeconds,
|
|
431
|
-
exp: nowSeconds + Math.floor(this.refreshTokenLifetime / 1e3)
|
|
432
|
-
};
|
|
433
|
-
return encodeJwt(payload, this.secrets[0]);
|
|
373
|
+
async isAllowed(key) {
|
|
374
|
+
const now = Date.now();
|
|
375
|
+
const attempts = this.attempts.get(key) ?? [];
|
|
376
|
+
const recentAttempts = attempts.filter((t) => now - t < this.windowMs);
|
|
377
|
+
return recentAttempts.length < this.maxAttempts;
|
|
434
378
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
*
|
|
442
|
-
* @param userId - The subject (user ID) to encode in the token
|
|
443
|
-
* @param deviceId - The device ID of the requesting device
|
|
444
|
-
* @param publicKeyThumbprint - SHA-256 thumbprint of the device's public key
|
|
445
|
-
* @returns A signed JWT string with type 'device_credential'
|
|
446
|
-
*/
|
|
447
|
-
issueDeviceCredential(userId, deviceId, publicKeyThumbprint) {
|
|
448
|
-
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
449
|
-
const lifetimeSeconds = Math.floor(this.deviceCredentialLifetime / 1e3);
|
|
450
|
-
const payload = {
|
|
451
|
-
jti: (0, import_node_crypto2.randomUUID)(),
|
|
452
|
-
sub: userId,
|
|
453
|
-
dev: deviceId,
|
|
454
|
-
type: "device_credential",
|
|
455
|
-
iat: nowSeconds,
|
|
456
|
-
exp: nowSeconds + lifetimeSeconds,
|
|
457
|
-
dpk: publicKeyThumbprint,
|
|
458
|
-
mustCheckinBy: nowSeconds + lifetimeSeconds
|
|
459
|
-
};
|
|
460
|
-
return encodeJwt(payload, this.secrets[0]);
|
|
379
|
+
async record(key) {
|
|
380
|
+
const now = Date.now();
|
|
381
|
+
const attempts = this.attempts.get(key) ?? [];
|
|
382
|
+
const recentAttempts = attempts.filter((t) => now - t < this.windowMs);
|
|
383
|
+
recentAttempts.push(now);
|
|
384
|
+
this.attempts.set(key, recentAttempts);
|
|
461
385
|
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
return
|
|
386
|
+
async reset(key) {
|
|
387
|
+
this.attempts.delete(key);
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
var MIN_PASSWORD_LENGTH = 8;
|
|
391
|
+
var MAX_PASSWORD_LENGTH = 128;
|
|
392
|
+
var MAX_NAME_LENGTH = 200;
|
|
393
|
+
var CHALLENGE_TTL_MS = 6e4;
|
|
394
|
+
function isValidEmail(email) {
|
|
395
|
+
if (email.length === 0 || email.length > 254) {
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
const atIndex = email.indexOf("@");
|
|
399
|
+
if (atIndex < 1) {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
const domain = email.slice(atIndex + 1);
|
|
403
|
+
if (domain.length === 0 || !domain.includes(".")) {
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
if (email.indexOf("@", atIndex + 1) !== -1) {
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
if (email.includes(" ")) {
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
function sanitizeName(name) {
|
|
415
|
+
const cleaned = name.replace(/[\x00-\x1f\x7f]/g, "");
|
|
416
|
+
const trimmed = cleaned.trim();
|
|
417
|
+
if (trimmed.length > MAX_NAME_LENGTH) {
|
|
418
|
+
return trimmed.slice(0, MAX_NAME_LENGTH);
|
|
419
|
+
}
|
|
420
|
+
return trimmed;
|
|
421
|
+
}
|
|
422
|
+
var BuiltInAuthRoutes = class {
|
|
423
|
+
userStore;
|
|
424
|
+
tokenManager;
|
|
425
|
+
challengeStore;
|
|
426
|
+
rateLimiter;
|
|
427
|
+
constructor(config) {
|
|
428
|
+
this.userStore = config.userStore;
|
|
429
|
+
this.tokenManager = config.tokenManager;
|
|
430
|
+
this.challengeStore = config.challengeStore ?? new InMemoryChallengeStore();
|
|
431
|
+
this.rateLimiter = config.rateLimiter ?? new InMemoryRateLimiter();
|
|
487
432
|
}
|
|
488
433
|
/**
|
|
489
|
-
*
|
|
434
|
+
* Handle user sign-up (POST /auth/signup).
|
|
490
435
|
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
* Returns null (rather than throwing) for invalid or expired tokens, so callers
|
|
494
|
-
* can handle authentication failure without try/catch.
|
|
436
|
+
* Validates email format and password length, hashes the password,
|
|
437
|
+
* creates the user, optionally registers a device, and issues tokens.
|
|
495
438
|
*
|
|
496
|
-
* @param
|
|
497
|
-
* @
|
|
498
|
-
*
|
|
439
|
+
* @param body - Sign-up request body
|
|
440
|
+
* @param body.email - The user's email address
|
|
441
|
+
* @param body.password - The plaintext password (8-128 characters)
|
|
442
|
+
* @param body.name - Optional display name (defaults to email local part)
|
|
443
|
+
* @param body.deviceId - Optional device ID to register
|
|
444
|
+
* @param body.devicePublicKey - Optional device public key (base64url)
|
|
445
|
+
* @param clientIp - Optional client IP for rate limiting
|
|
446
|
+
* @returns Auth response with the created user and tokens, or an error
|
|
499
447
|
*/
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
}
|
|
448
|
+
async handleSignUp(body, clientIp) {
|
|
449
|
+
const rateLimitKey = clientIp ?? "global";
|
|
450
|
+
if (!await this.rateLimiter.isAllowed(rateLimitKey)) {
|
|
451
|
+
return {
|
|
452
|
+
status: 429,
|
|
453
|
+
body: { error: "Too many requests. Please try again later." }
|
|
454
|
+
};
|
|
507
455
|
}
|
|
508
|
-
|
|
509
|
-
|
|
456
|
+
await this.rateLimiter.record(rateLimitKey);
|
|
457
|
+
if (!isValidEmail(body.email)) {
|
|
458
|
+
return {
|
|
459
|
+
status: 400,
|
|
460
|
+
body: {
|
|
461
|
+
error: "Invalid email address. Please provide a valid email in the format user@domain.com."
|
|
462
|
+
}
|
|
463
|
+
};
|
|
510
464
|
}
|
|
511
|
-
if (
|
|
512
|
-
return
|
|
465
|
+
if (body.password.length < MIN_PASSWORD_LENGTH) {
|
|
466
|
+
return {
|
|
467
|
+
status: 400,
|
|
468
|
+
body: {
|
|
469
|
+
error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long.`
|
|
470
|
+
}
|
|
471
|
+
};
|
|
513
472
|
}
|
|
514
|
-
if (
|
|
515
|
-
return
|
|
473
|
+
if (body.password.length > MAX_PASSWORD_LENGTH) {
|
|
474
|
+
return {
|
|
475
|
+
status: 400,
|
|
476
|
+
body: {
|
|
477
|
+
error: `Password must be at most ${MAX_PASSWORD_LENGTH} characters long.`
|
|
478
|
+
}
|
|
479
|
+
};
|
|
516
480
|
}
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
|
|
481
|
+
const { hash, salt } = await hashPassword(body.password);
|
|
482
|
+
const rawName = body.name ?? body.email.split("@")[0] ?? body.email;
|
|
483
|
+
const name = sanitizeName(rawName);
|
|
484
|
+
let user;
|
|
485
|
+
try {
|
|
486
|
+
user = await this.userStore.createUser({
|
|
487
|
+
email: body.email,
|
|
488
|
+
passwordHash: hash,
|
|
489
|
+
salt,
|
|
490
|
+
name
|
|
491
|
+
});
|
|
492
|
+
} catch (err) {
|
|
493
|
+
if (err instanceof Error && err.name === "DuplicateEmailError") {
|
|
494
|
+
return {
|
|
495
|
+
status: 409,
|
|
496
|
+
body: { error: "An account with this email already exists." }
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
throw err;
|
|
520
500
|
}
|
|
501
|
+
const deviceId = body.deviceId ?? `device-${user.id}`;
|
|
502
|
+
await this.userStore.registerDevice({
|
|
503
|
+
id: deviceId,
|
|
504
|
+
userId: user.id,
|
|
505
|
+
publicKey: body.devicePublicKey ?? "",
|
|
506
|
+
name: body.deviceId ? "Primary Device" : "Browser"
|
|
507
|
+
});
|
|
508
|
+
const tokens = this.tokenManager.issueTokens(user.id, deviceId);
|
|
521
509
|
return {
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
dev: decoded["dev"],
|
|
525
|
-
type,
|
|
526
|
-
iat: decoded["iat"],
|
|
527
|
-
exp: decoded["exp"]
|
|
510
|
+
status: 201,
|
|
511
|
+
body: { data: { user, tokens } }
|
|
528
512
|
};
|
|
529
513
|
}
|
|
530
514
|
/**
|
|
531
|
-
*
|
|
515
|
+
* Handle user sign-in (POST /auth/signin).
|
|
532
516
|
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
517
|
+
* Looks up the user by email, verifies the password, optionally registers
|
|
518
|
+
* a new device, and issues tokens.
|
|
535
519
|
*
|
|
536
|
-
* @param
|
|
537
|
-
* @
|
|
520
|
+
* @param body - Sign-in request body
|
|
521
|
+
* @param body.email - The user's email address
|
|
522
|
+
* @param body.password - The plaintext password
|
|
523
|
+
* @param body.deviceId - Optional device ID to register
|
|
524
|
+
* @param body.devicePublicKey - Optional device public key (base64url)
|
|
525
|
+
* @param clientIp - Optional client IP for rate limiting
|
|
526
|
+
* @returns Auth response with the user and tokens, or an error
|
|
538
527
|
*/
|
|
539
|
-
async
|
|
540
|
-
const
|
|
541
|
-
if (
|
|
542
|
-
return
|
|
528
|
+
async handleSignIn(body, clientIp) {
|
|
529
|
+
const rateLimitKey = clientIp ? `signin:${body.email.toLowerCase()}:${clientIp}` : `signin:${body.email.toLowerCase()}`;
|
|
530
|
+
if (!await this.rateLimiter.isAllowed(rateLimitKey)) {
|
|
531
|
+
return {
|
|
532
|
+
status: 429,
|
|
533
|
+
body: { error: "Too many sign-in attempts. Please try again later." }
|
|
534
|
+
};
|
|
543
535
|
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
536
|
+
await this.rateLimiter.record(rateLimitKey);
|
|
537
|
+
const storedUser = await this.userStore.findByEmail(body.email);
|
|
538
|
+
if (storedUser === null) {
|
|
539
|
+
return {
|
|
540
|
+
status: 401,
|
|
541
|
+
body: { error: "Invalid email or password." }
|
|
542
|
+
};
|
|
549
543
|
}
|
|
550
|
-
|
|
544
|
+
const passwordValid = await verifyPassword(
|
|
545
|
+
body.password,
|
|
546
|
+
storedUser.passwordHash,
|
|
547
|
+
storedUser.salt
|
|
548
|
+
);
|
|
549
|
+
if (!passwordValid) {
|
|
550
|
+
return {
|
|
551
|
+
status: 401,
|
|
552
|
+
body: { error: "Invalid email or password." }
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
await this.rateLimiter.reset(rateLimitKey);
|
|
556
|
+
const deviceId = body.deviceId ?? `device-${storedUser.id}`;
|
|
557
|
+
await this.userStore.registerDevice({
|
|
558
|
+
id: deviceId,
|
|
559
|
+
userId: storedUser.id,
|
|
560
|
+
publicKey: body.devicePublicKey ?? "",
|
|
561
|
+
name: body.deviceId ? "Device" : "Browser"
|
|
562
|
+
});
|
|
563
|
+
const tokens = this.tokenManager.issueTokens(storedUser.id, deviceId);
|
|
564
|
+
const user = {
|
|
565
|
+
id: storedUser.id,
|
|
566
|
+
email: storedUser.email,
|
|
567
|
+
name: storedUser.name,
|
|
568
|
+
emailVerified: storedUser.emailVerified,
|
|
569
|
+
createdAt: storedUser.createdAt
|
|
570
|
+
};
|
|
571
|
+
return {
|
|
572
|
+
status: 200,
|
|
573
|
+
body: { data: { user, tokens } }
|
|
574
|
+
};
|
|
551
575
|
}
|
|
552
576
|
/**
|
|
553
|
-
*
|
|
577
|
+
* Handle token refresh (POST /auth/refresh).
|
|
554
578
|
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
579
|
+
* Validates the provided refresh token and issues a new token pair
|
|
580
|
+
* (refresh token rotation with reuse detection). The old refresh token
|
|
581
|
+
* is marked as consumed in the revocation store.
|
|
557
582
|
*
|
|
558
|
-
* @param
|
|
559
|
-
* @param
|
|
583
|
+
* @param body - Refresh request body
|
|
584
|
+
* @param body.refreshToken - The current refresh token
|
|
585
|
+
* @returns Auth response with new tokens, or an error
|
|
560
586
|
*/
|
|
561
|
-
async
|
|
562
|
-
|
|
563
|
-
|
|
587
|
+
async handleRefresh(body) {
|
|
588
|
+
const result = await this.tokenManager.refreshAccessToken(body.refreshToken);
|
|
589
|
+
if (result === null) {
|
|
590
|
+
return {
|
|
591
|
+
status: 401,
|
|
592
|
+
body: { error: "Invalid or expired refresh token." }
|
|
593
|
+
};
|
|
564
594
|
}
|
|
595
|
+
return {
|
|
596
|
+
status: 200,
|
|
597
|
+
body: { data: result }
|
|
598
|
+
};
|
|
565
599
|
}
|
|
566
600
|
/**
|
|
567
|
-
*
|
|
601
|
+
* Handle sign-out (POST /auth/signout).
|
|
568
602
|
*
|
|
569
|
-
*
|
|
570
|
-
* (
|
|
603
|
+
* Validates the access token and revokes the current refresh token
|
|
604
|
+
* (if a revocation store is configured). This ensures that stolen
|
|
605
|
+
* refresh tokens cannot be used after the user signs out.
|
|
571
606
|
*
|
|
572
|
-
* @param
|
|
607
|
+
* @param accessToken - The JWT access token (without "Bearer " prefix)
|
|
608
|
+
* @param body - Sign-out request body
|
|
609
|
+
* @param body.refreshToken - The current refresh token to revoke
|
|
610
|
+
* @returns Auth response with success flag, or an error
|
|
573
611
|
*/
|
|
574
|
-
async
|
|
575
|
-
|
|
576
|
-
|
|
612
|
+
async handleSignOut(accessToken, body) {
|
|
613
|
+
const payload = this.tokenManager.validateToken(accessToken);
|
|
614
|
+
if (payload === null || payload.type !== "access") {
|
|
615
|
+
return {
|
|
616
|
+
status: 401,
|
|
617
|
+
body: { error: "Invalid or expired access token." }
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
await this.tokenManager.revokeToken(payload.jti, payload.exp);
|
|
621
|
+
if (body.refreshToken) {
|
|
622
|
+
const refreshPayload = this.tokenManager.validateToken(body.refreshToken);
|
|
623
|
+
if (refreshPayload !== null && refreshPayload.type === "refresh") {
|
|
624
|
+
await this.tokenManager.revokeToken(refreshPayload.jti, refreshPayload.exp);
|
|
625
|
+
}
|
|
577
626
|
}
|
|
627
|
+
return {
|
|
628
|
+
status: 200,
|
|
629
|
+
body: { data: { success: true } }
|
|
630
|
+
};
|
|
578
631
|
}
|
|
579
632
|
/**
|
|
580
|
-
*
|
|
581
|
-
*
|
|
582
|
-
* Implements **refresh token rotation with reuse detection**: a new refresh token
|
|
583
|
-
* is issued alongside the new access token. The old refresh token's `jti` is
|
|
584
|
-
* recorded in the revocation store (if configured). If a previously consumed
|
|
585
|
-
* refresh token is presented again, it indicates potential token theft.
|
|
633
|
+
* Handle get-current-user (GET /auth/me).
|
|
586
634
|
*
|
|
587
|
-
*
|
|
635
|
+
* Validates the access token and returns the authenticated user's profile.
|
|
588
636
|
*
|
|
589
|
-
* @param
|
|
590
|
-
* @returns
|
|
637
|
+
* @param accessToken - The JWT access token (without "Bearer " prefix)
|
|
638
|
+
* @returns Auth response with the user profile, or an error
|
|
591
639
|
*/
|
|
592
|
-
async
|
|
593
|
-
const payload = this.validateToken(
|
|
594
|
-
if (payload === null) {
|
|
595
|
-
return
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
640
|
+
async handleGetMe(accessToken) {
|
|
641
|
+
const payload = this.tokenManager.validateToken(accessToken);
|
|
642
|
+
if (payload === null || payload.type !== "access") {
|
|
643
|
+
return {
|
|
644
|
+
status: 401,
|
|
645
|
+
body: { error: "Invalid or expired access token." }
|
|
646
|
+
};
|
|
599
647
|
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
}
|
|
606
|
-
await this.revocationStore.revoke(payload.jti, payload.exp);
|
|
648
|
+
const storedUser = await this.userStore.findById(payload.sub);
|
|
649
|
+
if (storedUser === null) {
|
|
650
|
+
return {
|
|
651
|
+
status: 404,
|
|
652
|
+
body: { error: "User not found." }
|
|
653
|
+
};
|
|
607
654
|
}
|
|
655
|
+
const user = {
|
|
656
|
+
id: storedUser.id,
|
|
657
|
+
email: storedUser.email,
|
|
658
|
+
name: storedUser.name,
|
|
659
|
+
emailVerified: storedUser.emailVerified,
|
|
660
|
+
createdAt: storedUser.createdAt
|
|
661
|
+
};
|
|
608
662
|
return {
|
|
609
|
-
|
|
610
|
-
|
|
663
|
+
status: 200,
|
|
664
|
+
body: { data: user }
|
|
611
665
|
};
|
|
612
666
|
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
)
|
|
624
|
-
|
|
667
|
+
/**
|
|
668
|
+
* Handle list-devices (GET /auth/devices).
|
|
669
|
+
*
|
|
670
|
+
* Validates the access token and returns all devices registered for the user.
|
|
671
|
+
*
|
|
672
|
+
* @param accessToken - The JWT access token (without "Bearer " prefix)
|
|
673
|
+
* @returns Auth response with the device list, or an error
|
|
674
|
+
*/
|
|
675
|
+
async handleListDevices(accessToken) {
|
|
676
|
+
const payload = this.tokenManager.validateToken(accessToken);
|
|
677
|
+
if (payload === null || payload.type !== "access") {
|
|
678
|
+
return {
|
|
679
|
+
status: 401,
|
|
680
|
+
body: { error: "Invalid or expired access token." }
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
const devices = await this.userStore.listDevices(payload.sub);
|
|
684
|
+
return {
|
|
685
|
+
status: 200,
|
|
686
|
+
body: { data: devices }
|
|
687
|
+
};
|
|
625
688
|
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
689
|
+
/**
|
|
690
|
+
* Handle device revocation (DELETE /auth/device/:id).
|
|
691
|
+
*
|
|
692
|
+
* Validates the access token, revokes the specified device, and invalidates
|
|
693
|
+
* all tokens issued to that device. Only the device's owner can revoke it.
|
|
694
|
+
*
|
|
695
|
+
* @param accessToken - The JWT access token (without "Bearer " prefix)
|
|
696
|
+
* @param deviceId - The ID of the device to revoke
|
|
697
|
+
* @returns Auth response with success flag, or an error
|
|
698
|
+
*/
|
|
699
|
+
async handleRevokeDevice(accessToken, deviceId) {
|
|
700
|
+
const payload = this.tokenManager.validateToken(accessToken);
|
|
701
|
+
if (payload === null || payload.type !== "access") {
|
|
702
|
+
return {
|
|
703
|
+
status: 401,
|
|
704
|
+
body: { error: "Invalid or expired access token." }
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
const device = await this.userStore.findDevice(deviceId);
|
|
708
|
+
if (device === null) {
|
|
709
|
+
return {
|
|
710
|
+
status: 404,
|
|
711
|
+
body: { error: "Device not found." }
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
if (device.userId !== payload.sub) {
|
|
715
|
+
return {
|
|
716
|
+
status: 403,
|
|
717
|
+
body: { error: "You can only revoke your own devices." }
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
await this.userStore.revokeDevice(deviceId);
|
|
721
|
+
await this.tokenManager.revokeDeviceTokens(deviceId);
|
|
722
|
+
return {
|
|
723
|
+
status: 200,
|
|
724
|
+
body: { data: { success: true } }
|
|
725
|
+
};
|
|
631
726
|
}
|
|
632
|
-
};
|
|
633
|
-
function toBase64Url(buffer) {
|
|
634
|
-
const bytes = new Uint8Array(buffer);
|
|
635
|
-
let binary = "";
|
|
636
|
-
for (let i = 0; i < bytes.length; i++) {
|
|
637
|
-
binary += String.fromCharCode(bytes[i]);
|
|
638
|
-
}
|
|
639
|
-
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
640
|
-
}
|
|
641
|
-
function fromBase64Url(str) {
|
|
642
|
-
let base64 = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
643
|
-
const paddingNeeded = (4 - base64.length % 4) % 4;
|
|
644
|
-
base64 += "=".repeat(paddingNeeded);
|
|
645
|
-
const binary = atob(base64);
|
|
646
|
-
const bytes = new Uint8Array(binary.length);
|
|
647
|
-
for (let i = 0; i < binary.length; i++) {
|
|
648
|
-
bytes[i] = binary.charCodeAt(i);
|
|
649
|
-
}
|
|
650
|
-
return bytes;
|
|
651
|
-
}
|
|
652
|
-
function assertCryptoAvailable() {
|
|
653
|
-
if (typeof globalThis.crypto === "undefined" || typeof globalThis.crypto.subtle === "undefined") {
|
|
654
|
-
throw new CryptoUnavailableError();
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
var ECDSA_ALGORITHM = {
|
|
658
|
-
name: "ECDSA",
|
|
659
|
-
namedCurve: "P-256"
|
|
660
|
-
};
|
|
661
|
-
var ECDSA_SIGN_ALGORITHM = {
|
|
662
|
-
name: "ECDSA",
|
|
663
|
-
hash: { name: "SHA-256" }
|
|
664
|
-
};
|
|
665
|
-
async function verifyChallenge(publicKeyJwk, challenge, signature) {
|
|
666
|
-
assertCryptoAvailable();
|
|
667
|
-
try {
|
|
668
|
-
const publicKey = await globalThis.crypto.subtle.importKey(
|
|
669
|
-
"jwk",
|
|
670
|
-
publicKeyJwk,
|
|
671
|
-
ECDSA_ALGORITHM,
|
|
672
|
-
true,
|
|
673
|
-
["verify"]
|
|
674
|
-
);
|
|
675
|
-
const encoded = new TextEncoder().encode(challenge);
|
|
676
|
-
const signatureBytes = fromBase64Url(signature);
|
|
677
|
-
const isValid = await globalThis.crypto.subtle.verify(
|
|
678
|
-
ECDSA_SIGN_ALGORITHM,
|
|
679
|
-
publicKey,
|
|
680
|
-
signatureBytes,
|
|
681
|
-
encoded
|
|
682
|
-
);
|
|
683
|
-
return isValid;
|
|
684
|
-
} catch (cause) {
|
|
685
|
-
throw new DeviceIdentityError(
|
|
686
|
-
"Failed to verify challenge signature. The public key JWK or signature format may be invalid.",
|
|
687
|
-
{
|
|
688
|
-
cause: cause instanceof Error ? cause.message : String(cause),
|
|
689
|
-
publicKeyKty: publicKeyJwk.kty,
|
|
690
|
-
publicKeyCrv: publicKeyJwk.crv
|
|
691
|
-
}
|
|
692
|
-
);
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
async function computePublicKeyThumbprint(publicKeyJwk) {
|
|
696
|
-
assertCryptoAvailable();
|
|
697
|
-
if (publicKeyJwk.kty !== "EC") {
|
|
698
|
-
throw new DeviceIdentityError(
|
|
699
|
-
`Expected JWK key type "EC" but received "${publicKeyJwk.kty ?? "undefined"}". Only ECDSA public keys are supported for device identity.`,
|
|
700
|
-
{ kty: publicKeyJwk.kty }
|
|
701
|
-
);
|
|
702
|
-
}
|
|
703
|
-
if (!publicKeyJwk.crv || !publicKeyJwk.x || !publicKeyJwk.y) {
|
|
704
|
-
throw new DeviceIdentityError(
|
|
705
|
-
'JWK is missing required EC fields. An EC public key JWK must include "crv", "x", and "y" members.',
|
|
706
|
-
{
|
|
707
|
-
hasCrv: Boolean(publicKeyJwk.crv),
|
|
708
|
-
hasX: Boolean(publicKeyJwk.x),
|
|
709
|
-
hasY: Boolean(publicKeyJwk.y)
|
|
710
|
-
}
|
|
711
|
-
);
|
|
712
|
-
}
|
|
713
|
-
const canonicalJson = JSON.stringify({
|
|
714
|
-
crv: publicKeyJwk.crv,
|
|
715
|
-
kty: publicKeyJwk.kty,
|
|
716
|
-
x: publicKeyJwk.x,
|
|
717
|
-
y: publicKeyJwk.y
|
|
718
|
-
});
|
|
719
|
-
try {
|
|
720
|
-
const encoded = new TextEncoder().encode(canonicalJson);
|
|
721
|
-
const hashBuffer = await globalThis.crypto.subtle.digest("SHA-256", encoded);
|
|
722
|
-
return toBase64Url(hashBuffer);
|
|
723
|
-
} catch (cause) {
|
|
724
|
-
throw new DeviceIdentityError(
|
|
725
|
-
"Failed to compute SHA-256 thumbprint of the public key JWK.",
|
|
726
|
-
{ cause: cause instanceof Error ? cause.message : String(cause) }
|
|
727
|
-
);
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
// src/provider/built-in/auth-routes.ts
|
|
732
|
-
init_password_hash();
|
|
733
|
-
|
|
734
|
-
// src/provider/built-in/user-store.ts
|
|
735
|
-
init_cjs_shims();
|
|
736
|
-
var import_core3 = require("@korajs/core");
|
|
737
|
-
var import_node_crypto4 = require("crypto");
|
|
738
|
-
var DuplicateEmailError = class extends import_core3.KoraError {
|
|
739
|
-
constructor() {
|
|
740
|
-
super(
|
|
741
|
-
"A user with this email already exists.",
|
|
742
|
-
"DUPLICATE_EMAIL"
|
|
743
|
-
);
|
|
744
|
-
this.name = "DuplicateEmailError";
|
|
745
|
-
}
|
|
746
|
-
};
|
|
747
|
-
var InMemoryUserStore = class {
|
|
748
|
-
/** Users indexed by ID */
|
|
749
|
-
usersById = /* @__PURE__ */ new Map();
|
|
750
|
-
/** Users indexed by email (lowercase) for fast lookup */
|
|
751
|
-
usersByEmail = /* @__PURE__ */ new Map();
|
|
752
|
-
/** Devices indexed by device ID */
|
|
753
|
-
devicesById = /* @__PURE__ */ new Map();
|
|
754
|
-
/** Device IDs indexed by user ID for fast listing */
|
|
755
|
-
devicesByUserId = /* @__PURE__ */ new Map();
|
|
756
727
|
/**
|
|
757
|
-
*
|
|
728
|
+
* Handle device registration (POST /auth/device/register).
|
|
758
729
|
*
|
|
759
|
-
*
|
|
760
|
-
*
|
|
761
|
-
*
|
|
762
|
-
* @param
|
|
763
|
-
* @param
|
|
764
|
-
* @
|
|
765
|
-
* @
|
|
730
|
+
* Requires a valid access token. Registers a new device for the authenticated
|
|
731
|
+
* user and issues a device credential token bound to the device's public key.
|
|
732
|
+
*
|
|
733
|
+
* @param accessToken - The JWT access token (without "Bearer " prefix)
|
|
734
|
+
* @param body - Device registration request body
|
|
735
|
+
* @param body.deviceId - Unique identifier for the device
|
|
736
|
+
* @param body.publicKey - The device's public key as a JWK JSON string
|
|
737
|
+
* @param body.name - Human-readable device name (e.g., "Chrome on MacBook")
|
|
738
|
+
* @returns Auth response with the registered device and device credential, or an error
|
|
766
739
|
*/
|
|
767
|
-
async
|
|
768
|
-
const
|
|
769
|
-
if (
|
|
770
|
-
|
|
740
|
+
async handleDeviceRegister(accessToken, body) {
|
|
741
|
+
const payload = this.tokenManager.validateToken(accessToken);
|
|
742
|
+
if (payload === null || payload.type !== "access") {
|
|
743
|
+
return {
|
|
744
|
+
status: 401,
|
|
745
|
+
body: { error: "Invalid or expired access token." }
|
|
746
|
+
};
|
|
771
747
|
}
|
|
772
|
-
const
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
748
|
+
const deviceName = sanitizeName(body.name);
|
|
749
|
+
if (deviceName.length === 0) {
|
|
750
|
+
return {
|
|
751
|
+
status: 400,
|
|
752
|
+
body: { error: "Device name must not be empty." }
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
let publicKeyJwk;
|
|
756
|
+
try {
|
|
757
|
+
publicKeyJwk = JSON.parse(body.publicKey);
|
|
758
|
+
} catch {
|
|
759
|
+
return {
|
|
760
|
+
status: 400,
|
|
761
|
+
body: { error: "Invalid public key format. Expected a JSON-encoded JWK string." }
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
let thumbprint;
|
|
765
|
+
try {
|
|
766
|
+
thumbprint = await computePublicKeyThumbprint(publicKeyJwk);
|
|
767
|
+
} catch {
|
|
768
|
+
return {
|
|
769
|
+
status: 400,
|
|
770
|
+
body: {
|
|
771
|
+
error: "Failed to compute public key thumbprint. Ensure the key is a valid EC P-256 JWK."
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
const device = await this.userStore.registerDevice({
|
|
776
|
+
id: body.deviceId,
|
|
777
|
+
userId: payload.sub,
|
|
778
|
+
publicKey: body.publicKey,
|
|
779
|
+
name: deviceName
|
|
780
|
+
});
|
|
781
|
+
const deviceCredential = this.tokenManager.issueDeviceCredential(
|
|
782
|
+
payload.sub,
|
|
783
|
+
body.deviceId,
|
|
784
|
+
thumbprint
|
|
785
|
+
);
|
|
786
|
+
return {
|
|
787
|
+
status: 201,
|
|
788
|
+
body: { data: { device, deviceCredential } }
|
|
782
789
|
};
|
|
783
|
-
this.usersById.set(id, storedUser);
|
|
784
|
-
this.usersByEmail.set(normalizedEmail, storedUser);
|
|
785
|
-
return toAuthUser(storedUser);
|
|
786
790
|
}
|
|
787
791
|
/**
|
|
788
|
-
*
|
|
792
|
+
* Generate a challenge for device proof-of-possession verification.
|
|
789
793
|
*
|
|
790
|
-
*
|
|
791
|
-
*
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
return this.usersByEmail.get(email.toLowerCase()) ?? null;
|
|
795
|
-
}
|
|
796
|
-
/**
|
|
797
|
-
* Find a user by ID.
|
|
794
|
+
* Creates a cryptographically random challenge, stores it server-side with
|
|
795
|
+
* a 60-second TTL and the target device ID, and returns the challenge string.
|
|
796
|
+
* The client signs this challenge with its private key and submits it via
|
|
797
|
+
* {@link handleDeviceVerify}.
|
|
798
798
|
*
|
|
799
|
-
* @param
|
|
800
|
-
* @
|
|
799
|
+
* @param accessToken - The JWT access token (without "Bearer " prefix)
|
|
800
|
+
* @param deviceId - The device this challenge is intended for
|
|
801
|
+
* @returns Auth response with the challenge string, or an error
|
|
801
802
|
*/
|
|
802
|
-
async
|
|
803
|
-
|
|
803
|
+
async handleDeviceChallenge(accessToken, deviceId) {
|
|
804
|
+
const payload = this.tokenManager.validateToken(accessToken);
|
|
805
|
+
if (payload === null || payload.type !== "access") {
|
|
806
|
+
return {
|
|
807
|
+
status: 401,
|
|
808
|
+
body: { error: "Invalid or expired access token." }
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
const device = await this.userStore.findDevice(deviceId);
|
|
812
|
+
if (device === null || device.userId !== payload.sub) {
|
|
813
|
+
return {
|
|
814
|
+
status: 404,
|
|
815
|
+
body: { error: "Device not found." }
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
if (device.revoked) {
|
|
819
|
+
return {
|
|
820
|
+
status: 403,
|
|
821
|
+
body: { error: "Device has been revoked." }
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
const challenge = (0, import_node_crypto2.randomBytes)(32).toString("hex");
|
|
825
|
+
const expiresAt = Date.now() + CHALLENGE_TTL_MS;
|
|
826
|
+
await this.challengeStore.store(challenge, deviceId, expiresAt);
|
|
827
|
+
return {
|
|
828
|
+
status: 200,
|
|
829
|
+
body: { data: { challenge } }
|
|
830
|
+
};
|
|
804
831
|
}
|
|
805
832
|
/**
|
|
806
|
-
*
|
|
833
|
+
* Handle device proof-of-possession verification (POST /auth/device/verify).
|
|
807
834
|
*
|
|
808
|
-
*
|
|
809
|
-
*
|
|
810
|
-
*
|
|
835
|
+
* Verifies that the device holds the private key corresponding to its registered
|
|
836
|
+
* public key by checking a signed challenge. The challenge must have been previously
|
|
837
|
+
* issued via {@link handleDeviceChallenge} and is single-use.
|
|
811
838
|
*
|
|
812
|
-
*
|
|
813
|
-
*
|
|
814
|
-
* @param
|
|
815
|
-
* @param
|
|
816
|
-
* @param
|
|
817
|
-
* @
|
|
839
|
+
* On success, issues fresh tokens for the device.
|
|
840
|
+
*
|
|
841
|
+
* @param body - Device verification request body
|
|
842
|
+
* @param body.deviceId - The ID of the device to verify
|
|
843
|
+
* @param body.challenge - The challenge string (from handleDeviceChallenge)
|
|
844
|
+
* @param body.signature - The base64url-encoded ECDSA signature of the challenge
|
|
845
|
+
* @returns Auth response with fresh tokens on success, or an error
|
|
818
846
|
*/
|
|
819
|
-
async
|
|
820
|
-
const
|
|
821
|
-
if (
|
|
822
|
-
return
|
|
847
|
+
async handleDeviceVerify(body) {
|
|
848
|
+
const challengeEntry = await this.challengeStore.consume(body.challenge);
|
|
849
|
+
if (challengeEntry === null) {
|
|
850
|
+
return {
|
|
851
|
+
status: 401,
|
|
852
|
+
body: { error: "Invalid or expired challenge. Request a new challenge and try again." }
|
|
853
|
+
};
|
|
823
854
|
}
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
855
|
+
if (challengeEntry.deviceId !== body.deviceId) {
|
|
856
|
+
return {
|
|
857
|
+
status: 401,
|
|
858
|
+
body: { error: "Challenge was not issued for this device." }
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
const device = await this.userStore.findDevice(body.deviceId);
|
|
862
|
+
if (device === null) {
|
|
863
|
+
return {
|
|
864
|
+
status: 404,
|
|
865
|
+
body: { error: "Device not found." }
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
if (device.revoked) {
|
|
869
|
+
return {
|
|
870
|
+
status: 403,
|
|
871
|
+
body: { error: "Device has been revoked and cannot authenticate." }
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
let publicKeyJwk;
|
|
875
|
+
try {
|
|
876
|
+
publicKeyJwk = JSON.parse(device.publicKey);
|
|
877
|
+
} catch {
|
|
878
|
+
return {
|
|
879
|
+
status: 500,
|
|
880
|
+
body: { error: "Device has an invalid stored public key." }
|
|
881
|
+
};
|
|
839
882
|
}
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
return this.devicesById.get(deviceId) ?? null;
|
|
851
|
-
}
|
|
852
|
-
/**
|
|
853
|
-
* List all devices registered for a user.
|
|
854
|
-
*
|
|
855
|
-
* @param userId - The user ID whose devices to list
|
|
856
|
-
* @returns Array of device records (includes revoked devices)
|
|
857
|
-
*/
|
|
858
|
-
async listDevices(userId) {
|
|
859
|
-
const deviceIds = this.devicesByUserId.get(userId);
|
|
860
|
-
if (deviceIds === void 0) {
|
|
861
|
-
return [];
|
|
883
|
+
let isValid;
|
|
884
|
+
try {
|
|
885
|
+
isValid = await verifyChallenge(publicKeyJwk, body.challenge, body.signature);
|
|
886
|
+
} catch {
|
|
887
|
+
return {
|
|
888
|
+
status: 400,
|
|
889
|
+
body: {
|
|
890
|
+
error: "Signature verification failed. The signature or public key format may be invalid."
|
|
891
|
+
}
|
|
892
|
+
};
|
|
862
893
|
}
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
}
|
|
894
|
+
if (!isValid) {
|
|
895
|
+
return {
|
|
896
|
+
status: 401,
|
|
897
|
+
body: { error: "Invalid signature. Proof-of-possession verification failed." }
|
|
898
|
+
};
|
|
869
899
|
}
|
|
870
|
-
|
|
900
|
+
let thumbprint;
|
|
901
|
+
try {
|
|
902
|
+
thumbprint = await computePublicKeyThumbprint(publicKeyJwk);
|
|
903
|
+
} catch {
|
|
904
|
+
return {
|
|
905
|
+
status: 500,
|
|
906
|
+
body: { error: "Failed to compute public key thumbprint." }
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
const tokens = this.tokenManager.issueTokens(device.userId, device.id, thumbprint);
|
|
910
|
+
return {
|
|
911
|
+
status: 200,
|
|
912
|
+
body: { data: { tokens } }
|
|
913
|
+
};
|
|
871
914
|
}
|
|
872
915
|
/**
|
|
873
|
-
*
|
|
916
|
+
* Generates a random challenge string for proof-of-possession verification.
|
|
874
917
|
*
|
|
875
|
-
*
|
|
876
|
-
*
|
|
918
|
+
* **Deprecated:** Use {@link handleDeviceChallenge} instead, which stores
|
|
919
|
+
* the challenge server-side with expiry and single-use semantics.
|
|
877
920
|
*
|
|
878
|
-
* @
|
|
921
|
+
* @returns A 64-character hex string (32 random bytes)
|
|
879
922
|
*/
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
if (device !== void 0) {
|
|
883
|
-
device.revoked = true;
|
|
884
|
-
}
|
|
923
|
+
static generateChallenge() {
|
|
924
|
+
return (0, import_node_crypto2.randomBytes)(32).toString("hex");
|
|
885
925
|
}
|
|
886
926
|
/**
|
|
887
|
-
*
|
|
927
|
+
* Creates a sync server auth provider compatible with `@korajs/server`.
|
|
888
928
|
*
|
|
889
|
-
*
|
|
890
|
-
*
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
const user = this.usersById.get(userId);
|
|
894
|
-
if (!user) return;
|
|
895
|
-
const updated = { ...user, emailVerified: verified };
|
|
896
|
-
this.usersById.set(userId, updated);
|
|
897
|
-
this.usersByEmail.set(user.email, updated);
|
|
898
|
-
}
|
|
899
|
-
/**
|
|
900
|
-
* Update a user's password hash and salt.
|
|
929
|
+
* The returned object implements the `AuthProvider` interface from
|
|
930
|
+
* `@korajs/server`, validating access tokens and returning an auth
|
|
931
|
+
* context containing the user ID and device metadata. This bridges
|
|
932
|
+
* the built-in auth system with the sync server's authentication layer.
|
|
901
933
|
*
|
|
902
|
-
*
|
|
903
|
-
*
|
|
904
|
-
* @param salt - New hex-encoded salt
|
|
905
|
-
*/
|
|
906
|
-
async updatePassword(userId, passwordHash, salt) {
|
|
907
|
-
const user = this.usersById.get(userId);
|
|
908
|
-
if (!user) return;
|
|
909
|
-
const updated = { ...user, passwordHash, salt };
|
|
910
|
-
this.usersById.set(userId, updated);
|
|
911
|
-
this.usersByEmail.set(user.email, updated);
|
|
912
|
-
}
|
|
913
|
-
/**
|
|
914
|
-
* List all users. For admin/development use.
|
|
915
|
-
*/
|
|
916
|
-
async listAll() {
|
|
917
|
-
return [...this.usersById.values()];
|
|
918
|
-
}
|
|
919
|
-
/**
|
|
920
|
-
* Update a stored user record.
|
|
921
|
-
*/
|
|
922
|
-
async update(user) {
|
|
923
|
-
const existing = this.usersById.get(user.id);
|
|
924
|
-
if (!existing) return;
|
|
925
|
-
if (existing.email !== user.email) {
|
|
926
|
-
this.usersByEmail.delete(existing.email);
|
|
927
|
-
this.usersByEmail.set(user.email, user);
|
|
928
|
-
} else {
|
|
929
|
-
this.usersByEmail.set(user.email, user);
|
|
930
|
-
}
|
|
931
|
-
this.usersById.set(user.id, user);
|
|
932
|
-
}
|
|
933
|
-
/**
|
|
934
|
-
* Delete a user and all associated devices.
|
|
935
|
-
*/
|
|
936
|
-
async delete(userId) {
|
|
937
|
-
const user = this.usersById.get(userId);
|
|
938
|
-
if (!user) return;
|
|
939
|
-
this.usersById.delete(userId);
|
|
940
|
-
this.usersByEmail.delete(user.email);
|
|
941
|
-
const deviceIds = this.devicesByUserId.get(userId);
|
|
942
|
-
if (deviceIds) {
|
|
943
|
-
for (const deviceId of deviceIds) {
|
|
944
|
-
this.devicesById.delete(deviceId);
|
|
945
|
-
}
|
|
946
|
-
this.devicesByUserId.delete(userId);
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
/**
|
|
950
|
-
* Update the last-seen timestamp for a device.
|
|
934
|
+
* Also checks device revocation status during authentication, ensuring
|
|
935
|
+
* that revoked devices are rejected even if their tokens haven't expired.
|
|
951
936
|
*
|
|
952
|
-
*
|
|
953
|
-
* If the device does not exist, this is a no-op.
|
|
937
|
+
* @returns An object with an `authenticate` method suitable for KoraSyncServer's `auth` config
|
|
954
938
|
*
|
|
955
|
-
* @
|
|
939
|
+
* @example
|
|
940
|
+
* ```typescript
|
|
941
|
+
* const routes = new BuiltInAuthRoutes({ userStore, tokenManager })
|
|
942
|
+
* const syncServer = new KoraSyncServer({
|
|
943
|
+
* store,
|
|
944
|
+
* auth: routes.toSyncAuthProvider(),
|
|
945
|
+
* })
|
|
946
|
+
* ```
|
|
956
947
|
*/
|
|
957
|
-
|
|
958
|
-
const
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
948
|
+
toSyncAuthProvider() {
|
|
949
|
+
const tokenManager = this.tokenManager;
|
|
950
|
+
const userStore = this.userStore;
|
|
951
|
+
return {
|
|
952
|
+
async authenticate(token) {
|
|
953
|
+
const payload = tokenManager.validateToken(token);
|
|
954
|
+
if (payload === null || payload.type !== "access") {
|
|
955
|
+
return null;
|
|
956
|
+
}
|
|
957
|
+
const user = await userStore.findById(payload.sub);
|
|
958
|
+
if (user === null) {
|
|
959
|
+
return null;
|
|
960
|
+
}
|
|
961
|
+
const device = await userStore.findDevice(payload.dev);
|
|
962
|
+
if (device?.revoked) {
|
|
963
|
+
return null;
|
|
964
|
+
}
|
|
965
|
+
await userStore.touchDevice(payload.dev);
|
|
966
|
+
return {
|
|
967
|
+
userId: payload.sub,
|
|
968
|
+
metadata: {
|
|
969
|
+
deviceId: payload.dev,
|
|
970
|
+
email: user.email,
|
|
971
|
+
name: user.name
|
|
972
|
+
}
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
};
|
|
962
976
|
}
|
|
963
977
|
};
|
|
964
|
-
function toAuthUser(stored) {
|
|
965
|
-
return {
|
|
966
|
-
id: stored.id,
|
|
967
|
-
email: stored.email,
|
|
968
|
-
name: stored.name,
|
|
969
|
-
emailVerified: stored.emailVerified,
|
|
970
|
-
createdAt: stored.createdAt
|
|
971
|
-
};
|
|
972
|
-
}
|
|
973
978
|
|
|
974
|
-
// src/
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
+
// src/tokens/token-manager.ts
|
|
980
|
+
init_cjs_shims();
|
|
981
|
+
var import_node_crypto4 = require("crypto");
|
|
982
|
+
|
|
983
|
+
// src/types.ts
|
|
984
|
+
init_cjs_shims();
|
|
985
|
+
var import_core2 = require("@korajs/core");
|
|
986
|
+
var DEFAULT_ACCESS_TOKEN_LIFETIME = 15 * 60 * 1e3;
|
|
987
|
+
var DEFAULT_REFRESH_TOKEN_LIFETIME = 90 * 24 * 60 * 60 * 1e3;
|
|
988
|
+
var DEFAULT_DEVICE_CREDENTIAL_LIFETIME = 90 * 24 * 60 * 60 * 1e3;
|
|
989
|
+
var DEFAULT_MAX_OFFLINE_DURATION = 30 * 24 * 60 * 60 * 1e3;
|
|
990
|
+
var DEFAULT_AUTO_LOCK_TIMEOUT = 15 * 60 * 1e3;
|
|
991
|
+
|
|
992
|
+
// src/tokens/jwt.ts
|
|
993
|
+
init_cjs_shims();
|
|
994
|
+
var import_node_crypto3 = require("crypto");
|
|
995
|
+
function base64urlEncode(input) {
|
|
996
|
+
return Buffer.from(input, "utf-8").toString("base64url");
|
|
997
|
+
}
|
|
998
|
+
function base64urlDecode(input) {
|
|
999
|
+
return Buffer.from(input, "base64url").toString("utf-8");
|
|
1000
|
+
}
|
|
1001
|
+
function hmacSha256Base64url(data, secret) {
|
|
1002
|
+
return (0, import_node_crypto3.createHmac)("sha256", secret).update(data).digest("base64url");
|
|
1003
|
+
}
|
|
1004
|
+
var ENCODED_HEADER = base64urlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
|
1005
|
+
function encodeJwt(payload, secret) {
|
|
1006
|
+
const encodedPayload = base64urlEncode(JSON.stringify(payload));
|
|
1007
|
+
const signingInput = `${ENCODED_HEADER}.${encodedPayload}`;
|
|
1008
|
+
const signature = hmacSha256Base64url(signingInput, secret);
|
|
1009
|
+
return `${signingInput}.${signature}`;
|
|
1010
|
+
}
|
|
1011
|
+
function decodeJwt(token) {
|
|
1012
|
+
const parts = token.split(".");
|
|
1013
|
+
if (parts.length !== 3) {
|
|
1014
|
+
return null;
|
|
979
1015
|
}
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
return null;
|
|
984
|
-
}
|
|
985
|
-
this.challenges.delete(challenge);
|
|
986
|
-
if (Date.now() > entry.expiresAt) {
|
|
987
|
-
return null;
|
|
988
|
-
}
|
|
989
|
-
return { deviceId: entry.deviceId };
|
|
1016
|
+
const payloadSegment = parts[1];
|
|
1017
|
+
if (payloadSegment === void 0) {
|
|
1018
|
+
return null;
|
|
990
1019
|
}
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
for (const [challenge, entry] of this.challenges) {
|
|
997
|
-
if (now > entry.expiresAt) {
|
|
998
|
-
this.challenges.delete(challenge);
|
|
999
|
-
}
|
|
1020
|
+
try {
|
|
1021
|
+
const decoded = base64urlDecode(payloadSegment);
|
|
1022
|
+
const parsed = JSON.parse(decoded);
|
|
1023
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
1024
|
+
return null;
|
|
1000
1025
|
}
|
|
1026
|
+
return parsed;
|
|
1027
|
+
} catch {
|
|
1028
|
+
return null;
|
|
1001
1029
|
}
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
/**
|
|
1008
|
-
* @param maxAttempts - Maximum number of attempts within the time window (default: 10)
|
|
1009
|
-
* @param windowMs - Time window in milliseconds (default: 60,000 = 1 minute)
|
|
1010
|
-
*/
|
|
1011
|
-
constructor(maxAttempts = 10, windowMs = 6e4) {
|
|
1012
|
-
this.maxAttempts = maxAttempts;
|
|
1013
|
-
this.windowMs = windowMs;
|
|
1014
|
-
}
|
|
1015
|
-
async isAllowed(key) {
|
|
1016
|
-
const now = Date.now();
|
|
1017
|
-
const attempts = this.attempts.get(key) ?? [];
|
|
1018
|
-
const recentAttempts = attempts.filter((t) => now - t < this.windowMs);
|
|
1019
|
-
return recentAttempts.length < this.maxAttempts;
|
|
1030
|
+
}
|
|
1031
|
+
function verifyJwt(token, secret) {
|
|
1032
|
+
const parts = token.split(".");
|
|
1033
|
+
if (parts.length !== 3) {
|
|
1034
|
+
return null;
|
|
1020
1035
|
}
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
this.attempts.set(key, recentAttempts);
|
|
1036
|
+
const headerSegment = parts[0];
|
|
1037
|
+
const payloadSegment = parts[1];
|
|
1038
|
+
const signatureSegment = parts[2];
|
|
1039
|
+
if (headerSegment === void 0 || payloadSegment === void 0 || signatureSegment === void 0) {
|
|
1040
|
+
return null;
|
|
1027
1041
|
}
|
|
1028
|
-
|
|
1029
|
-
|
|
1042
|
+
if (headerSegment !== ENCODED_HEADER) {
|
|
1043
|
+
return null;
|
|
1030
1044
|
}
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
var CHALLENGE_TTL_MS = 6e4;
|
|
1036
|
-
function isValidEmail(email) {
|
|
1037
|
-
if (email.length === 0 || email.length > 254) {
|
|
1038
|
-
return false;
|
|
1045
|
+
const signingInput = `${headerSegment}.${payloadSegment}`;
|
|
1046
|
+
const expectedSignature = hmacSha256Base64url(signingInput, secret);
|
|
1047
|
+
if (expectedSignature.length !== signatureSegment.length) {
|
|
1048
|
+
return null;
|
|
1039
1049
|
}
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1050
|
+
let mismatch = 0;
|
|
1051
|
+
for (let i = 0; i < expectedSignature.length; i++) {
|
|
1052
|
+
mismatch |= expectedSignature.charCodeAt(i) ^ signatureSegment.charCodeAt(i);
|
|
1043
1053
|
}
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
return false;
|
|
1054
|
+
if (mismatch !== 0) {
|
|
1055
|
+
return null;
|
|
1047
1056
|
}
|
|
1048
|
-
|
|
1049
|
-
|
|
1057
|
+
try {
|
|
1058
|
+
const decoded = base64urlDecode(payloadSegment);
|
|
1059
|
+
const parsed = JSON.parse(decoded);
|
|
1060
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
1061
|
+
return null;
|
|
1062
|
+
}
|
|
1063
|
+
return parsed;
|
|
1064
|
+
} catch {
|
|
1065
|
+
return null;
|
|
1050
1066
|
}
|
|
1051
|
-
|
|
1067
|
+
}
|
|
1068
|
+
var CLOCK_SKEW_TOLERANCE_SECONDS = 5;
|
|
1069
|
+
function isExpired(payload) {
|
|
1070
|
+
if (typeof payload.exp !== "number") {
|
|
1052
1071
|
return false;
|
|
1053
1072
|
}
|
|
1054
|
-
|
|
1073
|
+
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
1074
|
+
return nowSeconds >= payload.exp + CLOCK_SKEW_TOLERANCE_SECONDS;
|
|
1055
1075
|
}
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1076
|
+
|
|
1077
|
+
// src/tokens/token-manager.ts
|
|
1078
|
+
var MIN_SECRET_LENGTH = 32;
|
|
1079
|
+
var InMemoryTokenRevocationStore = class {
|
|
1080
|
+
revokedTokens = /* @__PURE__ */ new Map();
|
|
1081
|
+
revokedDevices = /* @__PURE__ */ new Set();
|
|
1082
|
+
async isRevoked(jti) {
|
|
1083
|
+
return this.revokedTokens.has(jti);
|
|
1061
1084
|
}
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
var BuiltInAuthRoutes = class {
|
|
1065
|
-
userStore;
|
|
1066
|
-
tokenManager;
|
|
1067
|
-
challengeStore;
|
|
1068
|
-
rateLimiter;
|
|
1069
|
-
constructor(config) {
|
|
1070
|
-
this.userStore = config.userStore;
|
|
1071
|
-
this.tokenManager = config.tokenManager;
|
|
1072
|
-
this.challengeStore = config.challengeStore ?? new InMemoryChallengeStore();
|
|
1073
|
-
this.rateLimiter = config.rateLimiter ?? new InMemoryRateLimiter();
|
|
1085
|
+
async revoke(jti, expiresAt) {
|
|
1086
|
+
this.revokedTokens.set(jti, expiresAt);
|
|
1074
1087
|
}
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
*
|
|
1078
|
-
* Validates email format and password length, hashes the password,
|
|
1079
|
-
* creates the user, optionally registers a device, and issues tokens.
|
|
1080
|
-
*
|
|
1081
|
-
* @param body - Sign-up request body
|
|
1082
|
-
* @param body.email - The user's email address
|
|
1083
|
-
* @param body.password - The plaintext password (8-128 characters)
|
|
1084
|
-
* @param body.name - Optional display name (defaults to email local part)
|
|
1085
|
-
* @param body.deviceId - Optional device ID to register
|
|
1086
|
-
* @param body.devicePublicKey - Optional device public key (base64url)
|
|
1087
|
-
* @param clientIp - Optional client IP for rate limiting
|
|
1088
|
-
* @returns Auth response with the created user and tokens, or an error
|
|
1089
|
-
*/
|
|
1090
|
-
async handleSignUp(body, clientIp) {
|
|
1091
|
-
const rateLimitKey = clientIp ?? "global";
|
|
1092
|
-
if (!await this.rateLimiter.isAllowed(rateLimitKey)) {
|
|
1093
|
-
return {
|
|
1094
|
-
status: 429,
|
|
1095
|
-
body: { error: "Too many requests. Please try again later." }
|
|
1096
|
-
};
|
|
1097
|
-
}
|
|
1098
|
-
await this.rateLimiter.record(rateLimitKey);
|
|
1099
|
-
if (!isValidEmail(body.email)) {
|
|
1100
|
-
return {
|
|
1101
|
-
status: 400,
|
|
1102
|
-
body: {
|
|
1103
|
-
error: "Invalid email address. Please provide a valid email in the format user@domain.com."
|
|
1104
|
-
}
|
|
1105
|
-
};
|
|
1106
|
-
}
|
|
1107
|
-
if (body.password.length < MIN_PASSWORD_LENGTH) {
|
|
1108
|
-
return {
|
|
1109
|
-
status: 400,
|
|
1110
|
-
body: {
|
|
1111
|
-
error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long.`
|
|
1112
|
-
}
|
|
1113
|
-
};
|
|
1114
|
-
}
|
|
1115
|
-
if (body.password.length > MAX_PASSWORD_LENGTH) {
|
|
1116
|
-
return {
|
|
1117
|
-
status: 400,
|
|
1118
|
-
body: {
|
|
1119
|
-
error: `Password must be at most ${MAX_PASSWORD_LENGTH} characters long.`
|
|
1120
|
-
}
|
|
1121
|
-
};
|
|
1122
|
-
}
|
|
1123
|
-
const { hash, salt } = await hashPassword(body.password);
|
|
1124
|
-
const rawName = body.name ?? body.email.split("@")[0] ?? body.email;
|
|
1125
|
-
const name = sanitizeName(rawName);
|
|
1126
|
-
let user;
|
|
1127
|
-
try {
|
|
1128
|
-
user = await this.userStore.createUser({
|
|
1129
|
-
email: body.email,
|
|
1130
|
-
passwordHash: hash,
|
|
1131
|
-
salt,
|
|
1132
|
-
name
|
|
1133
|
-
});
|
|
1134
|
-
} catch (err) {
|
|
1135
|
-
if (err instanceof Error && err.name === "DuplicateEmailError") {
|
|
1136
|
-
return {
|
|
1137
|
-
status: 409,
|
|
1138
|
-
body: { error: "An account with this email already exists." }
|
|
1139
|
-
};
|
|
1140
|
-
}
|
|
1141
|
-
throw err;
|
|
1142
|
-
}
|
|
1143
|
-
const deviceId = body.deviceId ?? `device-${user.id}`;
|
|
1144
|
-
await this.userStore.registerDevice({
|
|
1145
|
-
id: deviceId,
|
|
1146
|
-
userId: user.id,
|
|
1147
|
-
publicKey: body.devicePublicKey ?? "",
|
|
1148
|
-
name: body.deviceId ? "Primary Device" : "Browser"
|
|
1149
|
-
});
|
|
1150
|
-
const tokens = this.tokenManager.issueTokens(user.id, deviceId);
|
|
1151
|
-
return {
|
|
1152
|
-
status: 201,
|
|
1153
|
-
body: { data: { user, tokens } }
|
|
1154
|
-
};
|
|
1088
|
+
async revokeAllForDevice(deviceId) {
|
|
1089
|
+
this.revokedDevices.add(deviceId);
|
|
1155
1090
|
}
|
|
1156
1091
|
/**
|
|
1157
|
-
*
|
|
1158
|
-
*
|
|
1159
|
-
* Looks up the user by email, verifies the password, optionally registers
|
|
1160
|
-
* a new device, and issues tokens.
|
|
1161
|
-
*
|
|
1162
|
-
* @param body - Sign-in request body
|
|
1163
|
-
* @param body.email - The user's email address
|
|
1164
|
-
* @param body.password - The plaintext password
|
|
1165
|
-
* @param body.deviceId - Optional device ID to register
|
|
1166
|
-
* @param body.devicePublicKey - Optional device public key (base64url)
|
|
1167
|
-
* @param clientIp - Optional client IP for rate limiting
|
|
1168
|
-
* @returns Auth response with the user and tokens, or an error
|
|
1092
|
+
* Check if a device has been revoked.
|
|
1169
1093
|
*/
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
if (!await this.rateLimiter.isAllowed(rateLimitKey)) {
|
|
1173
|
-
return {
|
|
1174
|
-
status: 429,
|
|
1175
|
-
body: { error: "Too many sign-in attempts. Please try again later." }
|
|
1176
|
-
};
|
|
1177
|
-
}
|
|
1178
|
-
await this.rateLimiter.record(rateLimitKey);
|
|
1179
|
-
const storedUser = await this.userStore.findByEmail(body.email);
|
|
1180
|
-
if (storedUser === null) {
|
|
1181
|
-
return {
|
|
1182
|
-
status: 401,
|
|
1183
|
-
body: { error: "Invalid email or password." }
|
|
1184
|
-
};
|
|
1185
|
-
}
|
|
1186
|
-
const passwordValid = await verifyPassword(
|
|
1187
|
-
body.password,
|
|
1188
|
-
storedUser.passwordHash,
|
|
1189
|
-
storedUser.salt
|
|
1190
|
-
);
|
|
1191
|
-
if (!passwordValid) {
|
|
1192
|
-
return {
|
|
1193
|
-
status: 401,
|
|
1194
|
-
body: { error: "Invalid email or password." }
|
|
1195
|
-
};
|
|
1196
|
-
}
|
|
1197
|
-
await this.rateLimiter.reset(rateLimitKey);
|
|
1198
|
-
const deviceId = body.deviceId ?? `device-${storedUser.id}`;
|
|
1199
|
-
await this.userStore.registerDevice({
|
|
1200
|
-
id: deviceId,
|
|
1201
|
-
userId: storedUser.id,
|
|
1202
|
-
publicKey: body.devicePublicKey ?? "",
|
|
1203
|
-
name: body.deviceId ? "Device" : "Browser"
|
|
1204
|
-
});
|
|
1205
|
-
const tokens = this.tokenManager.issueTokens(storedUser.id, deviceId);
|
|
1206
|
-
const user = {
|
|
1207
|
-
id: storedUser.id,
|
|
1208
|
-
email: storedUser.email,
|
|
1209
|
-
name: storedUser.name,
|
|
1210
|
-
emailVerified: storedUser.emailVerified,
|
|
1211
|
-
createdAt: storedUser.createdAt
|
|
1212
|
-
};
|
|
1213
|
-
return {
|
|
1214
|
-
status: 200,
|
|
1215
|
-
body: { data: { user, tokens } }
|
|
1216
|
-
};
|
|
1094
|
+
isDeviceRevoked(deviceId) {
|
|
1095
|
+
return this.revokedDevices.has(deviceId);
|
|
1217
1096
|
}
|
|
1218
1097
|
/**
|
|
1219
|
-
*
|
|
1220
|
-
*
|
|
1221
|
-
* Validates the provided refresh token and issues a new token pair
|
|
1222
|
-
* (refresh token rotation with reuse detection). The old refresh token
|
|
1223
|
-
* is marked as consumed in the revocation store.
|
|
1224
|
-
*
|
|
1225
|
-
* @param body - Refresh request body
|
|
1226
|
-
* @param body.refreshToken - The current refresh token
|
|
1227
|
-
* @returns Auth response with new tokens, or an error
|
|
1098
|
+
* Remove expired revocations to prevent unbounded memory growth.
|
|
1099
|
+
* Call periodically (e.g., every hour) in long-running servers.
|
|
1228
1100
|
*/
|
|
1229
|
-
|
|
1230
|
-
const
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
};
|
|
1101
|
+
cleanup() {
|
|
1102
|
+
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
1103
|
+
for (const [jti, expiresAt] of this.revokedTokens) {
|
|
1104
|
+
if (nowSeconds > expiresAt) {
|
|
1105
|
+
this.revokedTokens.delete(jti);
|
|
1106
|
+
}
|
|
1236
1107
|
}
|
|
1237
|
-
return {
|
|
1238
|
-
status: 200,
|
|
1239
|
-
body: { data: result }
|
|
1240
|
-
};
|
|
1241
1108
|
}
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
async handleSignOut(accessToken, body) {
|
|
1255
|
-
const payload = this.tokenManager.validateToken(accessToken);
|
|
1256
|
-
if (payload === null || payload.type !== "access") {
|
|
1257
|
-
return {
|
|
1258
|
-
status: 401,
|
|
1259
|
-
body: { error: "Invalid or expired access token." }
|
|
1260
|
-
};
|
|
1109
|
+
};
|
|
1110
|
+
var TokenManager = class {
|
|
1111
|
+
/** All signing/verification secrets (index 0 = current signing key) */
|
|
1112
|
+
secrets;
|
|
1113
|
+
accessTokenLifetime;
|
|
1114
|
+
refreshTokenLifetime;
|
|
1115
|
+
deviceCredentialLifetime;
|
|
1116
|
+
revocationStore;
|
|
1117
|
+
constructor(config) {
|
|
1118
|
+
const secrets = Array.isArray(config.secret) ? config.secret : [config.secret];
|
|
1119
|
+
if (secrets.length === 0) {
|
|
1120
|
+
throw new Error("TokenManager requires at least one secret.");
|
|
1261
1121
|
}
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1122
|
+
for (const secret of secrets) {
|
|
1123
|
+
if (secret.length < MIN_SECRET_LENGTH) {
|
|
1124
|
+
throw new Error(
|
|
1125
|
+
`JWT secret must be at least ${MIN_SECRET_LENGTH} characters (256 bits) for HMAC-SHA256 security. Received ${secret.length} characters. Use TokenManager.generateSecret() to generate a secure secret.`
|
|
1126
|
+
);
|
|
1267
1127
|
}
|
|
1268
1128
|
}
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1129
|
+
this.secrets = secrets;
|
|
1130
|
+
this.accessTokenLifetime = config.accessTokenLifetime ?? DEFAULT_ACCESS_TOKEN_LIFETIME;
|
|
1131
|
+
this.refreshTokenLifetime = config.refreshTokenLifetime ?? DEFAULT_REFRESH_TOKEN_LIFETIME;
|
|
1132
|
+
this.deviceCredentialLifetime = config.deviceCredentialLifetime ?? DEFAULT_DEVICE_CREDENTIAL_LIFETIME;
|
|
1133
|
+
this.revocationStore = config.revocationStore;
|
|
1273
1134
|
}
|
|
1274
1135
|
/**
|
|
1275
|
-
*
|
|
1136
|
+
* Generate a cryptographically random secret suitable for HMAC-SHA256 signing.
|
|
1276
1137
|
*
|
|
1277
|
-
*
|
|
1138
|
+
* Returns a 64-character hex string (32 bytes / 256 bits of entropy).
|
|
1139
|
+
* Store this securely (environment variable, secrets manager) — never in source code.
|
|
1278
1140
|
*
|
|
1279
|
-
* @
|
|
1280
|
-
* @returns Auth response with the user profile, or an error
|
|
1141
|
+
* @returns A random 256-bit hex-encoded secret
|
|
1281
1142
|
*/
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
if (payload === null || payload.type !== "access") {
|
|
1285
|
-
return {
|
|
1286
|
-
status: 401,
|
|
1287
|
-
body: { error: "Invalid or expired access token." }
|
|
1288
|
-
};
|
|
1289
|
-
}
|
|
1290
|
-
const storedUser = await this.userStore.findById(payload.sub);
|
|
1291
|
-
if (storedUser === null) {
|
|
1292
|
-
return {
|
|
1293
|
-
status: 404,
|
|
1294
|
-
body: { error: "User not found." }
|
|
1295
|
-
};
|
|
1296
|
-
}
|
|
1297
|
-
const user = {
|
|
1298
|
-
id: storedUser.id,
|
|
1299
|
-
email: storedUser.email,
|
|
1300
|
-
name: storedUser.name,
|
|
1301
|
-
emailVerified: storedUser.emailVerified,
|
|
1302
|
-
createdAt: storedUser.createdAt
|
|
1303
|
-
};
|
|
1304
|
-
return {
|
|
1305
|
-
status: 200,
|
|
1306
|
-
body: { data: user }
|
|
1307
|
-
};
|
|
1143
|
+
static generateSecret() {
|
|
1144
|
+
return (0, import_node_crypto4.randomBytes)(32).toString("hex");
|
|
1308
1145
|
}
|
|
1309
1146
|
/**
|
|
1310
|
-
*
|
|
1147
|
+
* Issue a signed JWT access token.
|
|
1311
1148
|
*
|
|
1312
|
-
*
|
|
1149
|
+
* Access tokens are short-lived (default 15 minutes) and used to authorize
|
|
1150
|
+
* API requests. When expired, use {@link refreshAccessToken} with a valid
|
|
1151
|
+
* refresh token to obtain a new one.
|
|
1313
1152
|
*
|
|
1314
|
-
* @param
|
|
1315
|
-
* @
|
|
1153
|
+
* @param userId - The subject (user ID) to encode in the token
|
|
1154
|
+
* @param deviceId - The device ID of the requesting device
|
|
1155
|
+
* @returns A signed JWT string with type 'access'
|
|
1316
1156
|
*/
|
|
1317
|
-
|
|
1318
|
-
const
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
return {
|
|
1327
|
-
status: 200,
|
|
1328
|
-
body: { data: devices }
|
|
1157
|
+
issueAccessToken(userId, deviceId) {
|
|
1158
|
+
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
1159
|
+
const payload = {
|
|
1160
|
+
jti: (0, import_node_crypto4.randomUUID)(),
|
|
1161
|
+
sub: userId,
|
|
1162
|
+
dev: deviceId,
|
|
1163
|
+
type: "access",
|
|
1164
|
+
iat: nowSeconds,
|
|
1165
|
+
exp: nowSeconds + Math.floor(this.accessTokenLifetime / 1e3)
|
|
1329
1166
|
};
|
|
1167
|
+
return encodeJwt(payload, this.secrets[0]);
|
|
1330
1168
|
}
|
|
1331
1169
|
/**
|
|
1332
|
-
*
|
|
1170
|
+
* Issue a signed JWT refresh token.
|
|
1333
1171
|
*
|
|
1334
|
-
*
|
|
1335
|
-
*
|
|
1172
|
+
* Refresh tokens are longer-lived (default 90 days) and used exclusively
|
|
1173
|
+
* to obtain new access tokens via {@link refreshAccessToken}. They should
|
|
1174
|
+
* be stored securely and never sent to resource APIs.
|
|
1336
1175
|
*
|
|
1337
|
-
* @param
|
|
1338
|
-
* @param deviceId - The ID of the device
|
|
1339
|
-
* @returns
|
|
1176
|
+
* @param userId - The subject (user ID) to encode in the token
|
|
1177
|
+
* @param deviceId - The device ID of the requesting device
|
|
1178
|
+
* @returns A signed JWT string with type 'refresh'
|
|
1340
1179
|
*/
|
|
1341
|
-
|
|
1342
|
-
const
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
if (device === null) {
|
|
1351
|
-
return {
|
|
1352
|
-
status: 404,
|
|
1353
|
-
body: { error: "Device not found." }
|
|
1354
|
-
};
|
|
1355
|
-
}
|
|
1356
|
-
if (device.userId !== payload.sub) {
|
|
1357
|
-
return {
|
|
1358
|
-
status: 403,
|
|
1359
|
-
body: { error: "You can only revoke your own devices." }
|
|
1360
|
-
};
|
|
1361
|
-
}
|
|
1362
|
-
await this.userStore.revokeDevice(deviceId);
|
|
1363
|
-
await this.tokenManager.revokeDeviceTokens(deviceId);
|
|
1364
|
-
return {
|
|
1365
|
-
status: 200,
|
|
1366
|
-
body: { data: { success: true } }
|
|
1180
|
+
issueRefreshToken(userId, deviceId) {
|
|
1181
|
+
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
1182
|
+
const payload = {
|
|
1183
|
+
jti: (0, import_node_crypto4.randomUUID)(),
|
|
1184
|
+
sub: userId,
|
|
1185
|
+
dev: deviceId,
|
|
1186
|
+
type: "refresh",
|
|
1187
|
+
iat: nowSeconds,
|
|
1188
|
+
exp: nowSeconds + Math.floor(this.refreshTokenLifetime / 1e3)
|
|
1367
1189
|
};
|
|
1190
|
+
return encodeJwt(payload, this.secrets[0]);
|
|
1368
1191
|
}
|
|
1369
1192
|
/**
|
|
1370
|
-
*
|
|
1193
|
+
* Issue a signed device credential token.
|
|
1371
1194
|
*
|
|
1372
|
-
*
|
|
1373
|
-
*
|
|
1195
|
+
* Device credentials are long-lived tokens bound to a device's public key.
|
|
1196
|
+
* They include a `mustCheckinBy` deadline; if the device does not check in
|
|
1197
|
+
* before this deadline, the credential should be treated as revoked.
|
|
1374
1198
|
*
|
|
1375
|
-
* @param
|
|
1376
|
-
* @param
|
|
1377
|
-
* @param
|
|
1378
|
-
* @
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
const payload =
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
return {
|
|
1393
|
-
status: 400,
|
|
1394
|
-
body: { error: "Device name must not be empty." }
|
|
1395
|
-
};
|
|
1396
|
-
}
|
|
1397
|
-
let publicKeyJwk;
|
|
1398
|
-
try {
|
|
1399
|
-
publicKeyJwk = JSON.parse(body.publicKey);
|
|
1400
|
-
} catch {
|
|
1401
|
-
return {
|
|
1402
|
-
status: 400,
|
|
1403
|
-
body: { error: "Invalid public key format. Expected a JSON-encoded JWK string." }
|
|
1404
|
-
};
|
|
1405
|
-
}
|
|
1406
|
-
let thumbprint;
|
|
1407
|
-
try {
|
|
1408
|
-
thumbprint = await computePublicKeyThumbprint(publicKeyJwk);
|
|
1409
|
-
} catch {
|
|
1410
|
-
return {
|
|
1411
|
-
status: 400,
|
|
1412
|
-
body: { error: "Failed to compute public key thumbprint. Ensure the key is a valid EC P-256 JWK." }
|
|
1413
|
-
};
|
|
1414
|
-
}
|
|
1415
|
-
const device = await this.userStore.registerDevice({
|
|
1416
|
-
id: body.deviceId,
|
|
1417
|
-
userId: payload.sub,
|
|
1418
|
-
publicKey: body.publicKey,
|
|
1419
|
-
name: deviceName
|
|
1420
|
-
});
|
|
1421
|
-
const deviceCredential = this.tokenManager.issueDeviceCredential(
|
|
1422
|
-
payload.sub,
|
|
1423
|
-
body.deviceId,
|
|
1424
|
-
thumbprint
|
|
1425
|
-
);
|
|
1426
|
-
return {
|
|
1427
|
-
status: 201,
|
|
1428
|
-
body: { data: { device, deviceCredential } }
|
|
1199
|
+
* @param userId - The subject (user ID) to encode in the token
|
|
1200
|
+
* @param deviceId - The device ID of the requesting device
|
|
1201
|
+
* @param publicKeyThumbprint - SHA-256 thumbprint of the device's public key
|
|
1202
|
+
* @returns A signed JWT string with type 'device_credential'
|
|
1203
|
+
*/
|
|
1204
|
+
issueDeviceCredential(userId, deviceId, publicKeyThumbprint) {
|
|
1205
|
+
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
1206
|
+
const lifetimeSeconds = Math.floor(this.deviceCredentialLifetime / 1e3);
|
|
1207
|
+
const payload = {
|
|
1208
|
+
jti: (0, import_node_crypto4.randomUUID)(),
|
|
1209
|
+
sub: userId,
|
|
1210
|
+
dev: deviceId,
|
|
1211
|
+
type: "device_credential",
|
|
1212
|
+
iat: nowSeconds,
|
|
1213
|
+
exp: nowSeconds + lifetimeSeconds,
|
|
1214
|
+
dpk: publicKeyThumbprint,
|
|
1215
|
+
mustCheckinBy: nowSeconds + lifetimeSeconds
|
|
1429
1216
|
};
|
|
1217
|
+
return encodeJwt(payload, this.secrets[0]);
|
|
1430
1218
|
}
|
|
1431
1219
|
/**
|
|
1432
|
-
*
|
|
1220
|
+
* Issue a complete set of authentication tokens.
|
|
1433
1221
|
*
|
|
1434
|
-
*
|
|
1435
|
-
*
|
|
1436
|
-
* The client signs this challenge with its private key and submits it via
|
|
1437
|
-
* {@link handleDeviceVerify}.
|
|
1222
|
+
* Always issues an access token and refresh token. If a `publicKeyThumbprint`
|
|
1223
|
+
* is provided, also issues a device credential.
|
|
1438
1224
|
*
|
|
1439
|
-
* @param
|
|
1440
|
-
* @param deviceId - The device
|
|
1441
|
-
* @
|
|
1225
|
+
* @param userId - The subject (user ID) to encode in the tokens
|
|
1226
|
+
* @param deviceId - The device ID of the requesting device
|
|
1227
|
+
* @param publicKeyThumbprint - Optional SHA-256 thumbprint of the device's public key.
|
|
1228
|
+
* When provided, a device credential is included in the returned tokens.
|
|
1229
|
+
* @returns An {@link AuthTokens} object containing the issued tokens
|
|
1442
1230
|
*/
|
|
1443
|
-
|
|
1444
|
-
const
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
status: 401,
|
|
1448
|
-
body: { error: "Invalid or expired access token." }
|
|
1449
|
-
};
|
|
1450
|
-
}
|
|
1451
|
-
const device = await this.userStore.findDevice(deviceId);
|
|
1452
|
-
if (device === null || device.userId !== payload.sub) {
|
|
1453
|
-
return {
|
|
1454
|
-
status: 404,
|
|
1455
|
-
body: { error: "Device not found." }
|
|
1456
|
-
};
|
|
1457
|
-
}
|
|
1458
|
-
if (device.revoked) {
|
|
1459
|
-
return {
|
|
1460
|
-
status: 403,
|
|
1461
|
-
body: { error: "Device has been revoked." }
|
|
1462
|
-
};
|
|
1463
|
-
}
|
|
1464
|
-
const challenge = (0, import_node_crypto5.randomBytes)(32).toString("hex");
|
|
1465
|
-
const expiresAt = Date.now() + CHALLENGE_TTL_MS;
|
|
1466
|
-
await this.challengeStore.store(challenge, deviceId, expiresAt);
|
|
1467
|
-
return {
|
|
1468
|
-
status: 200,
|
|
1469
|
-
body: { data: { challenge } }
|
|
1231
|
+
issueTokens(userId, deviceId, publicKeyThumbprint) {
|
|
1232
|
+
const tokens = {
|
|
1233
|
+
accessToken: this.issueAccessToken(userId, deviceId),
|
|
1234
|
+
refreshToken: this.issueRefreshToken(userId, deviceId)
|
|
1470
1235
|
};
|
|
1236
|
+
if (publicKeyThumbprint !== void 0) {
|
|
1237
|
+
tokens.deviceCredential = this.issueDeviceCredential(userId, deviceId, publicKeyThumbprint);
|
|
1238
|
+
}
|
|
1239
|
+
return tokens;
|
|
1471
1240
|
}
|
|
1472
1241
|
/**
|
|
1473
|
-
*
|
|
1474
|
-
*
|
|
1475
|
-
* Verifies that the device holds the private key corresponding to its registered
|
|
1476
|
-
* public key by checking a signed challenge. The challenge must have been previously
|
|
1477
|
-
* issued via {@link handleDeviceChallenge} and is single-use.
|
|
1242
|
+
* Validate and decode a token.
|
|
1478
1243
|
*
|
|
1479
|
-
*
|
|
1244
|
+
* Verifies the HMAC-SHA256 signature (trying all configured secrets for key rotation),
|
|
1245
|
+
* checks that the token has not expired, and validates all required claims.
|
|
1246
|
+
* Returns null (rather than throwing) for invalid or expired tokens, so callers
|
|
1247
|
+
* can handle authentication failure without try/catch.
|
|
1480
1248
|
*
|
|
1481
|
-
* @param
|
|
1482
|
-
* @
|
|
1483
|
-
*
|
|
1484
|
-
* @param body.signature - The base64url-encoded ECDSA signature of the challenge
|
|
1485
|
-
* @returns Auth response with fresh tokens on success, or an error
|
|
1249
|
+
* @param token - The JWT string to validate
|
|
1250
|
+
* @returns The decoded {@link TokenPayload} if valid, or null if the token is
|
|
1251
|
+
* invalid, expired, or missing required claims
|
|
1486
1252
|
*/
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
}
|
|
1494
|
-
}
|
|
1495
|
-
if (challengeEntry.deviceId !== body.deviceId) {
|
|
1496
|
-
return {
|
|
1497
|
-
status: 401,
|
|
1498
|
-
body: { error: "Challenge was not issued for this device." }
|
|
1499
|
-
};
|
|
1500
|
-
}
|
|
1501
|
-
const device = await this.userStore.findDevice(body.deviceId);
|
|
1502
|
-
if (device === null) {
|
|
1503
|
-
return {
|
|
1504
|
-
status: 404,
|
|
1505
|
-
body: { error: "Device not found." }
|
|
1506
|
-
};
|
|
1507
|
-
}
|
|
1508
|
-
if (device.revoked) {
|
|
1509
|
-
return {
|
|
1510
|
-
status: 403,
|
|
1511
|
-
body: { error: "Device has been revoked and cannot authenticate." }
|
|
1512
|
-
};
|
|
1253
|
+
validateToken(token) {
|
|
1254
|
+
let decoded = null;
|
|
1255
|
+
for (const secret of this.secrets) {
|
|
1256
|
+
decoded = verifyJwt(token, secret);
|
|
1257
|
+
if (decoded !== null) {
|
|
1258
|
+
break;
|
|
1259
|
+
}
|
|
1513
1260
|
}
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
publicKeyJwk = JSON.parse(device.publicKey);
|
|
1517
|
-
} catch {
|
|
1518
|
-
return {
|
|
1519
|
-
status: 500,
|
|
1520
|
-
body: { error: "Device has an invalid stored public key." }
|
|
1521
|
-
};
|
|
1261
|
+
if (decoded === null) {
|
|
1262
|
+
return null;
|
|
1522
1263
|
}
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
isValid = await verifyChallenge(publicKeyJwk, body.challenge, body.signature);
|
|
1526
|
-
} catch {
|
|
1527
|
-
return {
|
|
1528
|
-
status: 400,
|
|
1529
|
-
body: { error: "Signature verification failed. The signature or public key format may be invalid." }
|
|
1530
|
-
};
|
|
1264
|
+
if (isExpired(decoded)) {
|
|
1265
|
+
return null;
|
|
1531
1266
|
}
|
|
1532
|
-
if (
|
|
1533
|
-
return
|
|
1534
|
-
status: 401,
|
|
1535
|
-
body: { error: "Invalid signature. Proof-of-possession verification failed." }
|
|
1536
|
-
};
|
|
1267
|
+
if (typeof decoded.jti !== "string" || typeof decoded.sub !== "string" || typeof decoded.dev !== "string" || typeof decoded.type !== "string" || typeof decoded.iat !== "number" || typeof decoded.exp !== "number") {
|
|
1268
|
+
return null;
|
|
1537
1269
|
}
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
} catch {
|
|
1542
|
-
return {
|
|
1543
|
-
status: 500,
|
|
1544
|
-
body: { error: "Failed to compute public key thumbprint." }
|
|
1545
|
-
};
|
|
1270
|
+
const type = decoded.type;
|
|
1271
|
+
if (type !== "access" && type !== "refresh" && type !== "device_credential") {
|
|
1272
|
+
return null;
|
|
1546
1273
|
}
|
|
1547
|
-
const tokens = this.tokenManager.issueTokens(device.userId, device.id, thumbprint);
|
|
1548
1274
|
return {
|
|
1549
|
-
|
|
1550
|
-
|
|
1275
|
+
jti: decoded.jti,
|
|
1276
|
+
sub: decoded.sub,
|
|
1277
|
+
dev: decoded.dev,
|
|
1278
|
+
type,
|
|
1279
|
+
iat: decoded.iat,
|
|
1280
|
+
exp: decoded.exp
|
|
1551
1281
|
};
|
|
1552
1282
|
}
|
|
1553
1283
|
/**
|
|
1554
|
-
*
|
|
1284
|
+
* Validate a token and check it against the revocation store.
|
|
1555
1285
|
*
|
|
1556
|
-
*
|
|
1557
|
-
*
|
|
1286
|
+
* Like {@link validateToken}, but also checks whether the token's `jti` has been
|
|
1287
|
+
* revoked. Requires a revocation store to be configured.
|
|
1558
1288
|
*
|
|
1559
|
-
* @
|
|
1289
|
+
* @param token - The JWT string to validate
|
|
1290
|
+
* @returns The decoded {@link TokenPayload} if valid and not revoked, or null otherwise
|
|
1560
1291
|
*/
|
|
1561
|
-
|
|
1562
|
-
|
|
1292
|
+
async validateTokenWithRevocation(token) {
|
|
1293
|
+
const payload = this.validateToken(token);
|
|
1294
|
+
if (payload === null) {
|
|
1295
|
+
return null;
|
|
1296
|
+
}
|
|
1297
|
+
if (this.revocationStore) {
|
|
1298
|
+
const revoked = await this.revocationStore.isRevoked(payload.jti);
|
|
1299
|
+
if (revoked) {
|
|
1300
|
+
return null;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
return payload;
|
|
1563
1304
|
}
|
|
1564
1305
|
/**
|
|
1565
|
-
*
|
|
1306
|
+
* Revoke a specific token by its JWT ID.
|
|
1566
1307
|
*
|
|
1567
|
-
*
|
|
1568
|
-
*
|
|
1569
|
-
* context containing the user ID and device metadata. This bridges
|
|
1570
|
-
* the built-in auth system with the sync server's authentication layer.
|
|
1308
|
+
* Requires a revocation store to be configured. After revocation, the token
|
|
1309
|
+
* will be rejected by {@link validateTokenWithRevocation}.
|
|
1571
1310
|
*
|
|
1572
|
-
*
|
|
1573
|
-
*
|
|
1311
|
+
* @param jti - The JWT ID of the token to revoke
|
|
1312
|
+
* @param expiresAt - The token's expiration time (seconds since epoch)
|
|
1313
|
+
*/
|
|
1314
|
+
async revokeToken(jti, expiresAt) {
|
|
1315
|
+
if (this.revocationStore) {
|
|
1316
|
+
await this.revocationStore.revoke(jti, expiresAt);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
/**
|
|
1320
|
+
* Revoke all tokens for a specific device.
|
|
1574
1321
|
*
|
|
1575
|
-
*
|
|
1322
|
+
* Called when a device is revoked to ensure all its existing tokens
|
|
1323
|
+
* (access, refresh, and device credentials) are invalidated.
|
|
1576
1324
|
*
|
|
1577
|
-
* @
|
|
1578
|
-
* ```typescript
|
|
1579
|
-
* const routes = new BuiltInAuthRoutes({ userStore, tokenManager })
|
|
1580
|
-
* const syncServer = new KoraSyncServer({
|
|
1581
|
-
* store,
|
|
1582
|
-
* auth: routes.toSyncAuthProvider(),
|
|
1583
|
-
* })
|
|
1584
|
-
* ```
|
|
1325
|
+
* @param deviceId - The device ID whose tokens should be revoked
|
|
1585
1326
|
*/
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1327
|
+
async revokeDeviceTokens(deviceId) {
|
|
1328
|
+
if (this.revocationStore) {
|
|
1329
|
+
await this.revocationStore.revokeAllForDevice(deviceId);
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1333
|
+
* Refresh an access token using a valid refresh token.
|
|
1334
|
+
*
|
|
1335
|
+
* Implements **refresh token rotation with reuse detection**: a new refresh token
|
|
1336
|
+
* is issued alongside the new access token. The old refresh token's `jti` is
|
|
1337
|
+
* recorded in the revocation store (if configured). If a previously consumed
|
|
1338
|
+
* refresh token is presented again, it indicates potential token theft.
|
|
1339
|
+
*
|
|
1340
|
+
* Returns null if the provided token is invalid, expired, or not a refresh token.
|
|
1341
|
+
*
|
|
1342
|
+
* @param refreshToken - The refresh token JWT string
|
|
1343
|
+
* @returns A new access/refresh token pair, or null if the refresh token is invalid
|
|
1344
|
+
*/
|
|
1345
|
+
async refreshAccessToken(refreshToken) {
|
|
1346
|
+
const payload = this.validateToken(refreshToken);
|
|
1347
|
+
if (payload === null) {
|
|
1348
|
+
return null;
|
|
1349
|
+
}
|
|
1350
|
+
if (payload.type !== "refresh") {
|
|
1351
|
+
return null;
|
|
1352
|
+
}
|
|
1353
|
+
if (this.revocationStore) {
|
|
1354
|
+
const wasRevoked = await this.revocationStore.isRevoked(payload.jti);
|
|
1355
|
+
if (wasRevoked) {
|
|
1356
|
+
await this.revocationStore.revokeAllForDevice(payload.dev);
|
|
1357
|
+
return null;
|
|
1612
1358
|
}
|
|
1359
|
+
await this.revocationStore.revoke(payload.jti, payload.exp);
|
|
1360
|
+
}
|
|
1361
|
+
return {
|
|
1362
|
+
accessToken: this.issueAccessToken(payload.sub, payload.dev),
|
|
1363
|
+
refreshToken: this.issueRefreshToken(payload.sub, payload.dev)
|
|
1613
1364
|
};
|
|
1614
1365
|
}
|
|
1615
1366
|
};
|
|
@@ -1619,9 +1370,9 @@ init_password_hash();
|
|
|
1619
1370
|
|
|
1620
1371
|
// src/provider/built-in/password-reset.ts
|
|
1621
1372
|
init_cjs_shims();
|
|
1622
|
-
var
|
|
1373
|
+
var import_core3 = require("@korajs/core");
|
|
1623
1374
|
init_password_hash();
|
|
1624
|
-
var PasswordResetError = class extends
|
|
1375
|
+
var PasswordResetError = class extends import_core3.KoraError {
|
|
1625
1376
|
constructor(message, code, context) {
|
|
1626
1377
|
super(message, code, context);
|
|
1627
1378
|
this.name = "PasswordResetError";
|
|
@@ -1706,7 +1457,11 @@ var PasswordResetManager = class {
|
|
|
1706
1457
|
const normalizedEmail = email.toLowerCase().trim();
|
|
1707
1458
|
const successResponse = {
|
|
1708
1459
|
status: 200,
|
|
1709
|
-
body: {
|
|
1460
|
+
body: {
|
|
1461
|
+
data: {
|
|
1462
|
+
message: "If an account with that email exists, a password reset link has been sent."
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1710
1465
|
};
|
|
1711
1466
|
const user = await this.userStore.findByEmail(normalizedEmail);
|
|
1712
1467
|
if (!user) {
|
|
@@ -1809,8 +1564,8 @@ function generateSecureToken() {
|
|
|
1809
1564
|
|
|
1810
1565
|
// src/provider/built-in/email-verification.ts
|
|
1811
1566
|
init_cjs_shims();
|
|
1812
|
-
var
|
|
1813
|
-
var EmailVerificationError = class extends
|
|
1567
|
+
var import_core4 = require("@korajs/core");
|
|
1568
|
+
var EmailVerificationError = class extends import_core4.KoraError {
|
|
1814
1569
|
constructor(message, code, context) {
|
|
1815
1570
|
super(message, code, context);
|
|
1816
1571
|
this.name = "EmailVerificationError";
|
|
@@ -1848,115 +1603,355 @@ var InMemoryEmailVerificationStore = class {
|
|
|
1848
1603
|
count++;
|
|
1849
1604
|
}
|
|
1850
1605
|
}
|
|
1851
|
-
return count;
|
|
1606
|
+
return count;
|
|
1607
|
+
}
|
|
1608
|
+
async cleanExpired() {
|
|
1609
|
+
const now = Date.now();
|
|
1610
|
+
let count = 0;
|
|
1611
|
+
for (const [key, token] of this.tokens) {
|
|
1612
|
+
if (now > token.expiresAt) {
|
|
1613
|
+
this.tokens.delete(key);
|
|
1614
|
+
count++;
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
return count;
|
|
1618
|
+
}
|
|
1619
|
+
};
|
|
1620
|
+
var DEFAULT_TOKEN_TTL_MS2 = 24 * 60 * 60 * 1e3;
|
|
1621
|
+
var DEFAULT_MAX_REQUESTS2 = 3;
|
|
1622
|
+
var EmailVerificationManager = class {
|
|
1623
|
+
userStore;
|
|
1624
|
+
verificationStore;
|
|
1625
|
+
tokenTtlMs;
|
|
1626
|
+
maxRequestsPerUser;
|
|
1627
|
+
onVerificationRequired;
|
|
1628
|
+
constructor(config) {
|
|
1629
|
+
this.userStore = config.userStore;
|
|
1630
|
+
this.verificationStore = config.verificationStore ?? new InMemoryEmailVerificationStore();
|
|
1631
|
+
this.tokenTtlMs = config.tokenTtlMs ?? DEFAULT_TOKEN_TTL_MS2;
|
|
1632
|
+
this.maxRequestsPerUser = config.maxRequestsPerUser ?? DEFAULT_MAX_REQUESTS2;
|
|
1633
|
+
this.onVerificationRequired = config.onVerificationRequired;
|
|
1634
|
+
}
|
|
1635
|
+
/**
|
|
1636
|
+
* Send a verification email for a user.
|
|
1637
|
+
* Rate-limited to prevent abuse.
|
|
1638
|
+
*/
|
|
1639
|
+
async sendVerification(userId, email) {
|
|
1640
|
+
const normalizedEmail = email.toLowerCase().trim();
|
|
1641
|
+
const activeCount = await this.verificationStore.countActiveForUser(userId);
|
|
1642
|
+
if (activeCount >= this.maxRequestsPerUser) {
|
|
1643
|
+
return {
|
|
1644
|
+
status: 429,
|
|
1645
|
+
body: { error: "Too many verification requests. Please try again later." }
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1648
|
+
const token = generateSecureToken2();
|
|
1649
|
+
const now = Date.now();
|
|
1650
|
+
const verificationToken = {
|
|
1651
|
+
token,
|
|
1652
|
+
userId,
|
|
1653
|
+
email: normalizedEmail,
|
|
1654
|
+
createdAt: now,
|
|
1655
|
+
expiresAt: now + this.tokenTtlMs,
|
|
1656
|
+
consumed: false
|
|
1657
|
+
};
|
|
1658
|
+
await this.verificationStore.store(verificationToken);
|
|
1659
|
+
if (this.onVerificationRequired) {
|
|
1660
|
+
try {
|
|
1661
|
+
await this.onVerificationRequired(normalizedEmail, token, verificationToken.expiresAt);
|
|
1662
|
+
} catch {
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
const responseData = {
|
|
1666
|
+
message: "Verification email sent."
|
|
1667
|
+
};
|
|
1668
|
+
if (!this.onVerificationRequired) {
|
|
1669
|
+
responseData.token = token;
|
|
1670
|
+
}
|
|
1671
|
+
return { status: 200, body: { data: responseData } };
|
|
1672
|
+
}
|
|
1673
|
+
/**
|
|
1674
|
+
* Verify an email using a verification token.
|
|
1675
|
+
*/
|
|
1676
|
+
async verifyEmail(token) {
|
|
1677
|
+
const verificationToken = await this.verificationStore.get(token);
|
|
1678
|
+
if (!verificationToken || verificationToken.consumed) {
|
|
1679
|
+
return { status: 404, body: { error: "Verification token not found or already used." } };
|
|
1680
|
+
}
|
|
1681
|
+
if (Date.now() > verificationToken.expiresAt) {
|
|
1682
|
+
await this.verificationStore.consume(token);
|
|
1683
|
+
return { status: 410, body: { error: "Verification token has expired." } };
|
|
1684
|
+
}
|
|
1685
|
+
await this.verificationStore.consume(token);
|
|
1686
|
+
await this.userStore.setEmailVerified(verificationToken.userId, true);
|
|
1687
|
+
return {
|
|
1688
|
+
status: 200,
|
|
1689
|
+
body: {
|
|
1690
|
+
data: {
|
|
1691
|
+
message: "Email verified successfully.",
|
|
1692
|
+
userId: verificationToken.userId,
|
|
1693
|
+
email: verificationToken.email
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1698
|
+
/**
|
|
1699
|
+
* Resend verification email for a user.
|
|
1700
|
+
* Delegates to sendVerification with rate limiting.
|
|
1701
|
+
*/
|
|
1702
|
+
async resendVerification(userId) {
|
|
1703
|
+
const user = await this.userStore.findById(userId);
|
|
1704
|
+
if (!user) {
|
|
1705
|
+
return { status: 404, body: { error: "User not found." } };
|
|
1706
|
+
}
|
|
1707
|
+
return this.sendVerification(userId, user.email);
|
|
1708
|
+
}
|
|
1709
|
+
};
|
|
1710
|
+
function generateSecureToken2() {
|
|
1711
|
+
const bytes = new Uint8Array(32);
|
|
1712
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
1713
|
+
let binary = "";
|
|
1714
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
1715
|
+
binary += String.fromCharCode(bytes[i]);
|
|
1716
|
+
}
|
|
1717
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
// src/provider/built-in/user-store.ts
|
|
1721
|
+
init_cjs_shims();
|
|
1722
|
+
var import_node_crypto5 = require("crypto");
|
|
1723
|
+
var import_core5 = require("@korajs/core");
|
|
1724
|
+
var DuplicateEmailError = class extends import_core5.KoraError {
|
|
1725
|
+
constructor() {
|
|
1726
|
+
super("A user with this email already exists.", "DUPLICATE_EMAIL");
|
|
1727
|
+
this.name = "DuplicateEmailError";
|
|
1728
|
+
}
|
|
1729
|
+
};
|
|
1730
|
+
var InMemoryUserStore = class {
|
|
1731
|
+
/** Users indexed by ID */
|
|
1732
|
+
usersById = /* @__PURE__ */ new Map();
|
|
1733
|
+
/** Users indexed by email (lowercase) for fast lookup */
|
|
1734
|
+
usersByEmail = /* @__PURE__ */ new Map();
|
|
1735
|
+
/** Devices indexed by device ID */
|
|
1736
|
+
devicesById = /* @__PURE__ */ new Map();
|
|
1737
|
+
/** Device IDs indexed by user ID for fast listing */
|
|
1738
|
+
devicesByUserId = /* @__PURE__ */ new Map();
|
|
1739
|
+
/**
|
|
1740
|
+
* Create a new user account.
|
|
1741
|
+
*
|
|
1742
|
+
* @param params - User creation parameters
|
|
1743
|
+
* @param params.email - The user's email address (must be unique, case-insensitive)
|
|
1744
|
+
* @param params.passwordHash - Hex-encoded PBKDF2 derived key
|
|
1745
|
+
* @param params.salt - Hex-encoded salt used during hashing
|
|
1746
|
+
* @param params.name - The user's display name
|
|
1747
|
+
* @returns The created user (without sensitive credential fields)
|
|
1748
|
+
* @throws {DuplicateEmailError} If a user with the same email already exists
|
|
1749
|
+
*/
|
|
1750
|
+
async createUser(params) {
|
|
1751
|
+
const normalizedEmail = params.email.toLowerCase();
|
|
1752
|
+
if (this.usersByEmail.has(normalizedEmail)) {
|
|
1753
|
+
throw new DuplicateEmailError();
|
|
1754
|
+
}
|
|
1755
|
+
const now = Date.now();
|
|
1756
|
+
const id = (0, import_node_crypto5.randomUUID)();
|
|
1757
|
+
const storedUser = {
|
|
1758
|
+
id,
|
|
1759
|
+
email: normalizedEmail,
|
|
1760
|
+
name: params.name,
|
|
1761
|
+
emailVerified: false,
|
|
1762
|
+
createdAt: now,
|
|
1763
|
+
passwordHash: params.passwordHash,
|
|
1764
|
+
salt: params.salt
|
|
1765
|
+
};
|
|
1766
|
+
this.usersById.set(id, storedUser);
|
|
1767
|
+
this.usersByEmail.set(normalizedEmail, storedUser);
|
|
1768
|
+
return toAuthUser(storedUser);
|
|
1769
|
+
}
|
|
1770
|
+
/**
|
|
1771
|
+
* Find a user by email address.
|
|
1772
|
+
*
|
|
1773
|
+
* @param email - The email to search for (case-insensitive)
|
|
1774
|
+
* @returns The stored user record including credentials, or null if not found
|
|
1775
|
+
*/
|
|
1776
|
+
async findByEmail(email) {
|
|
1777
|
+
return this.usersByEmail.get(email.toLowerCase()) ?? null;
|
|
1778
|
+
}
|
|
1779
|
+
/**
|
|
1780
|
+
* Find a user by ID.
|
|
1781
|
+
*
|
|
1782
|
+
* @param id - The user ID to search for
|
|
1783
|
+
* @returns The stored user record including credentials, or null if not found
|
|
1784
|
+
*/
|
|
1785
|
+
async findById(id) {
|
|
1786
|
+
return this.usersById.get(id) ?? null;
|
|
1787
|
+
}
|
|
1788
|
+
/**
|
|
1789
|
+
* Register a device for a user.
|
|
1790
|
+
*
|
|
1791
|
+
* If a device with the same ID already exists and is not revoked, it is
|
|
1792
|
+
* returned as-is (idempotent registration). If it was previously revoked,
|
|
1793
|
+
* it is re-activated with updated details.
|
|
1794
|
+
*
|
|
1795
|
+
* @param params - Device registration parameters
|
|
1796
|
+
* @param params.id - Unique device identifier
|
|
1797
|
+
* @param params.userId - ID of the user who owns the device
|
|
1798
|
+
* @param params.publicKey - Base64url-encoded device public key or thumbprint
|
|
1799
|
+
* @param params.name - Human-readable device name
|
|
1800
|
+
* @returns The registered device record
|
|
1801
|
+
*/
|
|
1802
|
+
async registerDevice(params) {
|
|
1803
|
+
const existing = this.devicesById.get(params.id);
|
|
1804
|
+
if (existing !== void 0 && !existing.revoked) {
|
|
1805
|
+
return existing;
|
|
1806
|
+
}
|
|
1807
|
+
const now = Date.now();
|
|
1808
|
+
const device = {
|
|
1809
|
+
id: params.id,
|
|
1810
|
+
userId: params.userId,
|
|
1811
|
+
publicKey: params.publicKey,
|
|
1812
|
+
name: params.name,
|
|
1813
|
+
revoked: false,
|
|
1814
|
+
createdAt: now,
|
|
1815
|
+
lastSeenAt: now
|
|
1816
|
+
};
|
|
1817
|
+
this.devicesById.set(params.id, device);
|
|
1818
|
+
let userDevices = this.devicesByUserId.get(params.userId);
|
|
1819
|
+
if (userDevices === void 0) {
|
|
1820
|
+
userDevices = /* @__PURE__ */ new Set();
|
|
1821
|
+
this.devicesByUserId.set(params.userId, userDevices);
|
|
1822
|
+
}
|
|
1823
|
+
userDevices.add(params.id);
|
|
1824
|
+
return device;
|
|
1825
|
+
}
|
|
1826
|
+
/**
|
|
1827
|
+
* Find a device by its ID.
|
|
1828
|
+
*
|
|
1829
|
+
* @param deviceId - The device ID to search for
|
|
1830
|
+
* @returns The device record, or null if not found
|
|
1831
|
+
*/
|
|
1832
|
+
async findDevice(deviceId) {
|
|
1833
|
+
return this.devicesById.get(deviceId) ?? null;
|
|
1834
|
+
}
|
|
1835
|
+
/**
|
|
1836
|
+
* List all devices registered for a user.
|
|
1837
|
+
*
|
|
1838
|
+
* @param userId - The user ID whose devices to list
|
|
1839
|
+
* @returns Array of device records (includes revoked devices)
|
|
1840
|
+
*/
|
|
1841
|
+
async listDevices(userId) {
|
|
1842
|
+
const deviceIds = this.devicesByUserId.get(userId);
|
|
1843
|
+
if (deviceIds === void 0) {
|
|
1844
|
+
return [];
|
|
1845
|
+
}
|
|
1846
|
+
const devices = [];
|
|
1847
|
+
for (const deviceId of deviceIds) {
|
|
1848
|
+
const device = this.devicesById.get(deviceId);
|
|
1849
|
+
if (device !== void 0) {
|
|
1850
|
+
devices.push(device);
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
return devices;
|
|
1852
1854
|
}
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1855
|
+
/**
|
|
1856
|
+
* Revoke a device, preventing it from being used for authentication.
|
|
1857
|
+
*
|
|
1858
|
+
* This is a soft revoke — the device record remains but is marked as revoked.
|
|
1859
|
+
* If the device does not exist, this is a no-op.
|
|
1860
|
+
*
|
|
1861
|
+
* @param deviceId - The ID of the device to revoke
|
|
1862
|
+
*/
|
|
1863
|
+
async revokeDevice(deviceId) {
|
|
1864
|
+
const device = this.devicesById.get(deviceId);
|
|
1865
|
+
if (device !== void 0) {
|
|
1866
|
+
device.revoked = true;
|
|
1861
1867
|
}
|
|
1862
|
-
return count;
|
|
1863
1868
|
}
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
this.
|
|
1875
|
-
this.
|
|
1876
|
-
this.tokenTtlMs = config.tokenTtlMs ?? DEFAULT_TOKEN_TTL_MS2;
|
|
1877
|
-
this.maxRequestsPerUser = config.maxRequestsPerUser ?? DEFAULT_MAX_REQUESTS2;
|
|
1878
|
-
this.onVerificationRequired = config.onVerificationRequired;
|
|
1869
|
+
/**
|
|
1870
|
+
* Set a user's email verification status.
|
|
1871
|
+
*
|
|
1872
|
+
* @param userId - The user whose email to verify
|
|
1873
|
+
* @param verified - Whether the email is verified
|
|
1874
|
+
*/
|
|
1875
|
+
async setEmailVerified(userId, verified) {
|
|
1876
|
+
const user = this.usersById.get(userId);
|
|
1877
|
+
if (!user) return;
|
|
1878
|
+
const updated = { ...user, emailVerified: verified };
|
|
1879
|
+
this.usersById.set(userId, updated);
|
|
1880
|
+
this.usersByEmail.set(user.email, updated);
|
|
1879
1881
|
}
|
|
1880
1882
|
/**
|
|
1881
|
-
*
|
|
1882
|
-
*
|
|
1883
|
+
* Update a user's password hash and salt.
|
|
1884
|
+
*
|
|
1885
|
+
* @param userId - The user whose password to update
|
|
1886
|
+
* @param passwordHash - New hex-encoded PBKDF2 derived key
|
|
1887
|
+
* @param salt - New hex-encoded salt
|
|
1883
1888
|
*/
|
|
1884
|
-
async
|
|
1885
|
-
const
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
const token = generateSecureToken2();
|
|
1891
|
-
const now = Date.now();
|
|
1892
|
-
const verificationToken = {
|
|
1893
|
-
token,
|
|
1894
|
-
userId,
|
|
1895
|
-
email: normalizedEmail,
|
|
1896
|
-
createdAt: now,
|
|
1897
|
-
expiresAt: now + this.tokenTtlMs,
|
|
1898
|
-
consumed: false
|
|
1899
|
-
};
|
|
1900
|
-
await this.verificationStore.store(verificationToken);
|
|
1901
|
-
if (this.onVerificationRequired) {
|
|
1902
|
-
try {
|
|
1903
|
-
await this.onVerificationRequired(normalizedEmail, token, verificationToken.expiresAt);
|
|
1904
|
-
} catch {
|
|
1905
|
-
}
|
|
1906
|
-
}
|
|
1907
|
-
const responseData = {
|
|
1908
|
-
message: "Verification email sent."
|
|
1909
|
-
};
|
|
1910
|
-
if (!this.onVerificationRequired) {
|
|
1911
|
-
responseData.token = token;
|
|
1912
|
-
}
|
|
1913
|
-
return { status: 200, body: { data: responseData } };
|
|
1889
|
+
async updatePassword(userId, passwordHash, salt) {
|
|
1890
|
+
const user = this.usersById.get(userId);
|
|
1891
|
+
if (!user) return;
|
|
1892
|
+
const updated = { ...user, passwordHash, salt };
|
|
1893
|
+
this.usersById.set(userId, updated);
|
|
1894
|
+
this.usersByEmail.set(user.email, updated);
|
|
1914
1895
|
}
|
|
1915
1896
|
/**
|
|
1916
|
-
*
|
|
1897
|
+
* List all users. For admin/development use.
|
|
1917
1898
|
*/
|
|
1918
|
-
async
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1899
|
+
async listAll() {
|
|
1900
|
+
return [...this.usersById.values()];
|
|
1901
|
+
}
|
|
1902
|
+
/**
|
|
1903
|
+
* Update a stored user record.
|
|
1904
|
+
*/
|
|
1905
|
+
async update(user) {
|
|
1906
|
+
const existing = this.usersById.get(user.id);
|
|
1907
|
+
if (!existing) return;
|
|
1908
|
+
if (existing.email !== user.email) {
|
|
1909
|
+
this.usersByEmail.delete(existing.email);
|
|
1910
|
+
this.usersByEmail.set(user.email, user);
|
|
1911
|
+
} else {
|
|
1912
|
+
this.usersByEmail.set(user.email, user);
|
|
1926
1913
|
}
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1914
|
+
this.usersById.set(user.id, user);
|
|
1915
|
+
}
|
|
1916
|
+
/**
|
|
1917
|
+
* Delete a user and all associated devices.
|
|
1918
|
+
*/
|
|
1919
|
+
async delete(userId) {
|
|
1920
|
+
const user = this.usersById.get(userId);
|
|
1921
|
+
if (!user) return;
|
|
1922
|
+
this.usersById.delete(userId);
|
|
1923
|
+
this.usersByEmail.delete(user.email);
|
|
1924
|
+
const deviceIds = this.devicesByUserId.get(userId);
|
|
1925
|
+
if (deviceIds) {
|
|
1926
|
+
for (const deviceId of deviceIds) {
|
|
1927
|
+
this.devicesById.delete(deviceId);
|
|
1937
1928
|
}
|
|
1938
|
-
|
|
1929
|
+
this.devicesByUserId.delete(userId);
|
|
1930
|
+
}
|
|
1939
1931
|
}
|
|
1940
1932
|
/**
|
|
1941
|
-
*
|
|
1942
|
-
*
|
|
1933
|
+
* Update the last-seen timestamp for a device.
|
|
1934
|
+
*
|
|
1935
|
+
* Called when a device authenticates or syncs to track activity.
|
|
1936
|
+
* If the device does not exist, this is a no-op.
|
|
1937
|
+
*
|
|
1938
|
+
* @param deviceId - The ID of the device to update
|
|
1943
1939
|
*/
|
|
1944
|
-
async
|
|
1945
|
-
const
|
|
1946
|
-
if (
|
|
1947
|
-
|
|
1940
|
+
async touchDevice(deviceId) {
|
|
1941
|
+
const device = this.devicesById.get(deviceId);
|
|
1942
|
+
if (device !== void 0) {
|
|
1943
|
+
device.lastSeenAt = Date.now();
|
|
1948
1944
|
}
|
|
1949
|
-
return this.sendVerification(userId, user.email);
|
|
1950
1945
|
}
|
|
1951
1946
|
};
|
|
1952
|
-
function
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1947
|
+
function toAuthUser(stored) {
|
|
1948
|
+
return {
|
|
1949
|
+
id: stored.id,
|
|
1950
|
+
email: stored.email,
|
|
1951
|
+
name: stored.name,
|
|
1952
|
+
emailVerified: stored.emailVerified,
|
|
1953
|
+
createdAt: stored.createdAt
|
|
1954
|
+
};
|
|
1960
1955
|
}
|
|
1961
1956
|
|
|
1962
1957
|
// src/provider/built-in/sqlite-user-store.ts
|
|
@@ -2013,21 +2008,15 @@ var SqliteUserStore = class {
|
|
|
2013
2008
|
return { id, email: normalizedEmail, name: params.name, emailVerified: false, createdAt: now };
|
|
2014
2009
|
}
|
|
2015
2010
|
async findByEmail(email) {
|
|
2016
|
-
const row = this.db.prepare(
|
|
2017
|
-
"SELECT * FROM auth_users WHERE email = ?"
|
|
2018
|
-
).get(email.toLowerCase());
|
|
2011
|
+
const row = this.db.prepare("SELECT * FROM auth_users WHERE email = ?").get(email.toLowerCase());
|
|
2019
2012
|
return row ? rowToStoredUser(row) : null;
|
|
2020
2013
|
}
|
|
2021
2014
|
async findById(id) {
|
|
2022
|
-
const row = this.db.prepare(
|
|
2023
|
-
"SELECT * FROM auth_users WHERE id = ?"
|
|
2024
|
-
).get(id);
|
|
2015
|
+
const row = this.db.prepare("SELECT * FROM auth_users WHERE id = ?").get(id);
|
|
2025
2016
|
return row ? rowToStoredUser(row) : null;
|
|
2026
2017
|
}
|
|
2027
2018
|
async registerDevice(params) {
|
|
2028
|
-
const existing = this.db.prepare(
|
|
2029
|
-
"SELECT * FROM auth_devices WHERE id = ?"
|
|
2030
|
-
).get(params.id);
|
|
2019
|
+
const existing = this.db.prepare("SELECT * FROM auth_devices WHERE id = ?").get(params.id);
|
|
2031
2020
|
if (existing && !existing.revoked) {
|
|
2032
2021
|
return rowToDevice(existing);
|
|
2033
2022
|
}
|
|
@@ -2054,29 +2043,21 @@ var SqliteUserStore = class {
|
|
|
2054
2043
|
};
|
|
2055
2044
|
}
|
|
2056
2045
|
async findDevice(deviceId) {
|
|
2057
|
-
const row = this.db.prepare(
|
|
2058
|
-
"SELECT * FROM auth_devices WHERE id = ?"
|
|
2059
|
-
).get(deviceId);
|
|
2046
|
+
const row = this.db.prepare("SELECT * FROM auth_devices WHERE id = ?").get(deviceId);
|
|
2060
2047
|
return row ? rowToDevice(row) : null;
|
|
2061
2048
|
}
|
|
2062
2049
|
async listDevices(userId) {
|
|
2063
|
-
const rows = this.db.prepare(
|
|
2064
|
-
"SELECT * FROM auth_devices WHERE user_id = ?"
|
|
2065
|
-
).all(userId);
|
|
2050
|
+
const rows = this.db.prepare("SELECT * FROM auth_devices WHERE user_id = ?").all(userId);
|
|
2066
2051
|
return rows.map(rowToDevice);
|
|
2067
2052
|
}
|
|
2068
2053
|
async revokeDevice(deviceId) {
|
|
2069
2054
|
this.db.prepare("UPDATE auth_devices SET revoked = 1 WHERE id = ?").run(deviceId);
|
|
2070
2055
|
}
|
|
2071
2056
|
async setEmailVerified(userId, verified) {
|
|
2072
|
-
this.db.prepare(
|
|
2073
|
-
"UPDATE auth_users SET email_verified = ? WHERE id = ?"
|
|
2074
|
-
).run(verified ? 1 : 0, userId);
|
|
2057
|
+
this.db.prepare("UPDATE auth_users SET email_verified = ? WHERE id = ?").run(verified ? 1 : 0, userId);
|
|
2075
2058
|
}
|
|
2076
2059
|
async updatePassword(userId, passwordHash, salt) {
|
|
2077
|
-
this.db.prepare(
|
|
2078
|
-
"UPDATE auth_users SET password_hash = ?, salt = ? WHERE id = ?"
|
|
2079
|
-
).run(passwordHash, salt, userId);
|
|
2060
|
+
this.db.prepare("UPDATE auth_users SET password_hash = ?, salt = ? WHERE id = ?").run(passwordHash, salt, userId);
|
|
2080
2061
|
}
|
|
2081
2062
|
async listAll() {
|
|
2082
2063
|
const rows = this.db.prepare("SELECT * FROM auth_users").all();
|
|
@@ -2097,9 +2078,7 @@ var SqliteUserStore = class {
|
|
|
2097
2078
|
deleteInTransaction();
|
|
2098
2079
|
}
|
|
2099
2080
|
async touchDevice(deviceId) {
|
|
2100
|
-
this.db.prepare(
|
|
2101
|
-
"UPDATE auth_devices SET last_seen_at = ? WHERE id = ?"
|
|
2102
|
-
).run(Date.now(), deviceId);
|
|
2081
|
+
this.db.prepare("UPDATE auth_devices SET last_seen_at = ? WHERE id = ?").run(Date.now(), deviceId);
|
|
2103
2082
|
}
|
|
2104
2083
|
};
|
|
2105
2084
|
async function createSqliteUserStore(options) {
|
|
@@ -2218,7 +2197,7 @@ var PostgresUserStore = class {
|
|
|
2218
2197
|
const existingRows = await this.sql`
|
|
2219
2198
|
SELECT * FROM auth_devices WHERE id = ${params.id}
|
|
2220
2199
|
`;
|
|
2221
|
-
if (existingRows.length > 0 && !existingRows[0]
|
|
2200
|
+
if (existingRows.length > 0 && !existingRows[0]?.revoked) {
|
|
2222
2201
|
return rowToDevice2(existingRows[0]);
|
|
2223
2202
|
}
|
|
2224
2203
|
const now = Date.now();
|
|
@@ -2239,7 +2218,7 @@ var PostgresUserStore = class {
|
|
|
2239
2218
|
publicKey: params.publicKey,
|
|
2240
2219
|
name: params.name,
|
|
2241
2220
|
revoked: false,
|
|
2242
|
-
createdAt: existingRows.length > 0 ? Number(existingRows[0]
|
|
2221
|
+
createdAt: existingRows.length > 0 ? Number(existingRows[0]?.created_at) : now,
|
|
2243
2222
|
lastSeenAt: now
|
|
2244
2223
|
};
|
|
2245
2224
|
}
|
|
@@ -2436,16 +2415,12 @@ var ExternalAuthOperationNotSupportedError = class extends import_core6.KoraErro
|
|
|
2436
2415
|
};
|
|
2437
2416
|
var ExternalTokenValidationError = class extends import_core6.KoraError {
|
|
2438
2417
|
constructor(reason, context) {
|
|
2439
|
-
super(
|
|
2440
|
-
`External token validation failed: ${reason}`,
|
|
2441
|
-
"AUTH_EXTERNAL_TOKEN_INVALID",
|
|
2442
|
-
context
|
|
2443
|
-
);
|
|
2418
|
+
super(`External token validation failed: ${reason}`, "AUTH_EXTERNAL_TOKEN_INVALID", context);
|
|
2444
2419
|
this.name = "ExternalTokenValidationError";
|
|
2445
2420
|
}
|
|
2446
2421
|
};
|
|
2447
2422
|
function defaultMapClaims(claims) {
|
|
2448
|
-
const sub = claims
|
|
2423
|
+
const sub = claims.sub;
|
|
2449
2424
|
if (typeof sub !== "string" || sub.length === 0) {
|
|
2450
2425
|
throw new ExternalTokenValidationError(
|
|
2451
2426
|
'JWT is missing a valid "sub" (subject) claim. The "sub" claim must be a non-empty string identifying the user.',
|
|
@@ -2454,8 +2429,8 @@ function defaultMapClaims(claims) {
|
|
|
2454
2429
|
}
|
|
2455
2430
|
return {
|
|
2456
2431
|
userId: sub,
|
|
2457
|
-
email: typeof claims
|
|
2458
|
-
name: typeof claims
|
|
2432
|
+
email: typeof claims.email === "string" ? claims.email : void 0,
|
|
2433
|
+
name: typeof claims.name === "string" ? claims.name : void 0,
|
|
2459
2434
|
metadata: void 0
|
|
2460
2435
|
};
|
|
2461
2436
|
}
|
|
@@ -2655,23 +2630,23 @@ var ExternalJwtProvider = class {
|
|
|
2655
2630
|
// src/provider/external/clerk-adapter.ts
|
|
2656
2631
|
init_cjs_shims();
|
|
2657
2632
|
function defaultClerkClaimMapping(claims) {
|
|
2658
|
-
const sub = claims
|
|
2633
|
+
const sub = claims.sub;
|
|
2659
2634
|
if (typeof sub !== "string" || sub.length === 0) {
|
|
2660
2635
|
return { userId: "" };
|
|
2661
2636
|
}
|
|
2662
|
-
const firstName = typeof claims
|
|
2663
|
-
const lastName = typeof claims
|
|
2637
|
+
const firstName = typeof claims.first_name === "string" ? claims.first_name : "";
|
|
2638
|
+
const lastName = typeof claims.last_name === "string" ? claims.last_name : "";
|
|
2664
2639
|
const fullName = [firstName, lastName].filter(Boolean).join(" ");
|
|
2665
|
-
const email = typeof claims
|
|
2640
|
+
const email = typeof claims.email === "string" ? claims.email : void 0;
|
|
2666
2641
|
const metadata = {};
|
|
2667
|
-
if (typeof claims
|
|
2668
|
-
metadata
|
|
2642
|
+
if (typeof claims.org_id === "string") {
|
|
2643
|
+
metadata.orgId = claims.org_id;
|
|
2669
2644
|
}
|
|
2670
|
-
if (typeof claims
|
|
2671
|
-
metadata
|
|
2645
|
+
if (typeof claims.org_slug === "string") {
|
|
2646
|
+
metadata.orgSlug = claims.org_slug;
|
|
2672
2647
|
}
|
|
2673
|
-
if (typeof claims
|
|
2674
|
-
metadata
|
|
2648
|
+
if (typeof claims.org_role === "string") {
|
|
2649
|
+
metadata.orgRole = claims.org_role;
|
|
2675
2650
|
}
|
|
2676
2651
|
return {
|
|
2677
2652
|
userId: sub,
|
|
@@ -2691,30 +2666,30 @@ function createClerkAdapter(config) {
|
|
|
2691
2666
|
// src/provider/external/supabase-adapter.ts
|
|
2692
2667
|
init_cjs_shims();
|
|
2693
2668
|
function defaultSupabaseClaimMapping(claims) {
|
|
2694
|
-
const sub = claims
|
|
2669
|
+
const sub = claims.sub;
|
|
2695
2670
|
if (typeof sub !== "string" || sub.length === 0) {
|
|
2696
2671
|
return { userId: "" };
|
|
2697
2672
|
}
|
|
2698
|
-
const email = typeof claims
|
|
2673
|
+
const email = typeof claims.email === "string" ? claims.email : void 0;
|
|
2699
2674
|
let name;
|
|
2700
|
-
const userMetadata = claims
|
|
2675
|
+
const userMetadata = claims.user_metadata;
|
|
2701
2676
|
if (typeof userMetadata === "object" && userMetadata !== null && !Array.isArray(userMetadata)) {
|
|
2702
2677
|
const meta = userMetadata;
|
|
2703
|
-
if (typeof meta
|
|
2704
|
-
name = meta
|
|
2705
|
-
} else if (typeof meta
|
|
2706
|
-
name = meta
|
|
2678
|
+
if (typeof meta.full_name === "string" && meta.full_name.length > 0) {
|
|
2679
|
+
name = meta.full_name;
|
|
2680
|
+
} else if (typeof meta.name === "string" && meta.name.length > 0) {
|
|
2681
|
+
name = meta.name;
|
|
2707
2682
|
}
|
|
2708
2683
|
}
|
|
2709
2684
|
const metadata = {};
|
|
2710
|
-
if (typeof claims
|
|
2711
|
-
metadata
|
|
2685
|
+
if (typeof claims.role === "string") {
|
|
2686
|
+
metadata.role = claims.role;
|
|
2712
2687
|
}
|
|
2713
|
-
if (typeof claims
|
|
2714
|
-
metadata
|
|
2688
|
+
if (typeof claims.aud === "string") {
|
|
2689
|
+
metadata.aud = claims.aud;
|
|
2715
2690
|
}
|
|
2716
|
-
if (typeof claims
|
|
2717
|
-
metadata
|
|
2691
|
+
if (typeof claims.app_metadata === "object" && claims.app_metadata !== null && !Array.isArray(claims.app_metadata)) {
|
|
2692
|
+
metadata.appMetadata = claims.app_metadata;
|
|
2718
2693
|
}
|
|
2719
2694
|
return {
|
|
2720
2695
|
userId: sub,
|
|
@@ -2746,53 +2721,54 @@ var PasskeyError = class extends import_core7.KoraError {
|
|
|
2746
2721
|
}
|
|
2747
2722
|
};
|
|
2748
2723
|
function decodeCbor(data, offset) {
|
|
2749
|
-
|
|
2724
|
+
let pos = offset;
|
|
2725
|
+
if (pos >= data.length) {
|
|
2750
2726
|
throw new PasskeyError("CBOR decode error: unexpected end of data.");
|
|
2751
2727
|
}
|
|
2752
|
-
const initialByte = data[
|
|
2728
|
+
const initialByte = data[pos];
|
|
2753
2729
|
const majorType = initialByte >> 5;
|
|
2754
2730
|
const additionalInfo = initialByte & 31;
|
|
2755
|
-
|
|
2731
|
+
pos += 1;
|
|
2756
2732
|
let argument;
|
|
2757
2733
|
if (additionalInfo < 24) {
|
|
2758
2734
|
argument = additionalInfo;
|
|
2759
2735
|
} else if (additionalInfo === 24) {
|
|
2760
|
-
argument = data[
|
|
2761
|
-
|
|
2736
|
+
argument = data[pos];
|
|
2737
|
+
pos += 1;
|
|
2762
2738
|
} else if (additionalInfo === 25) {
|
|
2763
|
-
argument = data[
|
|
2764
|
-
|
|
2739
|
+
argument = data[pos] << 8 | data[pos + 1];
|
|
2740
|
+
pos += 2;
|
|
2765
2741
|
} else if (additionalInfo === 26) {
|
|
2766
|
-
argument = data[
|
|
2742
|
+
argument = data[pos] << 24 | data[pos + 1] << 16 | data[pos + 2] << 8 | data[pos + 3];
|
|
2767
2743
|
argument = argument >>> 0;
|
|
2768
|
-
|
|
2744
|
+
pos += 4;
|
|
2769
2745
|
} else {
|
|
2770
2746
|
throw new PasskeyError(
|
|
2771
|
-
`CBOR decode error: unsupported additional info ${additionalInfo} at byte ${
|
|
2747
|
+
`CBOR decode error: unsupported additional info ${additionalInfo} at byte ${pos - 1}. This CBOR decoder only supports definite-length encodings.`
|
|
2772
2748
|
);
|
|
2773
2749
|
}
|
|
2774
2750
|
switch (majorType) {
|
|
2775
2751
|
// Major type 0: Unsigned integer
|
|
2776
2752
|
case 0:
|
|
2777
|
-
return { value: argument, offset };
|
|
2753
|
+
return { value: argument, offset: pos };
|
|
2778
2754
|
// Major type 1: Negative integer (-1 - argument)
|
|
2779
2755
|
case 1:
|
|
2780
|
-
return { value: -1 - argument, offset };
|
|
2756
|
+
return { value: -1 - argument, offset: pos };
|
|
2781
2757
|
// Major type 2: Byte string
|
|
2782
2758
|
case 2: {
|
|
2783
|
-
const bytes = data.slice(
|
|
2784
|
-
return { value: bytes, offset:
|
|
2759
|
+
const bytes = data.slice(pos, pos + argument);
|
|
2760
|
+
return { value: bytes, offset: pos + argument };
|
|
2785
2761
|
}
|
|
2786
2762
|
// Major type 3: Text string (UTF-8)
|
|
2787
2763
|
case 3: {
|
|
2788
|
-
const textBytes = data.slice(
|
|
2764
|
+
const textBytes = data.slice(pos, pos + argument);
|
|
2789
2765
|
const text = new TextDecoder().decode(textBytes);
|
|
2790
|
-
return { value: text, offset:
|
|
2766
|
+
return { value: text, offset: pos + argument };
|
|
2791
2767
|
}
|
|
2792
2768
|
// Major type 4: Array
|
|
2793
2769
|
case 4: {
|
|
2794
2770
|
const arr = [];
|
|
2795
|
-
let currentOffset =
|
|
2771
|
+
let currentOffset = pos;
|
|
2796
2772
|
for (let i = 0; i < argument; i++) {
|
|
2797
2773
|
const item = decodeCbor(data, currentOffset);
|
|
2798
2774
|
arr.push(item.value);
|
|
@@ -2803,7 +2779,7 @@ function decodeCbor(data, offset) {
|
|
|
2803
2779
|
// Major type 5: Map
|
|
2804
2780
|
case 5: {
|
|
2805
2781
|
const map = /* @__PURE__ */ new Map();
|
|
2806
|
-
let currentOffset =
|
|
2782
|
+
let currentOffset = pos;
|
|
2807
2783
|
for (let i = 0; i < argument; i++) {
|
|
2808
2784
|
const keyResult = decodeCbor(data, currentOffset);
|
|
2809
2785
|
const valResult = decodeCbor(data, keyResult.offset);
|
|
@@ -2814,7 +2790,7 @@ function decodeCbor(data, offset) {
|
|
|
2814
2790
|
}
|
|
2815
2791
|
default:
|
|
2816
2792
|
throw new PasskeyError(
|
|
2817
|
-
`CBOR decode error: unsupported major type ${majorType} at byte ${
|
|
2793
|
+
`CBOR decode error: unsupported major type ${majorType} at byte ${pos - 1}. This CBOR decoder only supports types 0-5 (integers, byte/text strings, arrays, maps).`
|
|
2818
2794
|
);
|
|
2819
2795
|
}
|
|
2820
2796
|
}
|
|
@@ -2828,10 +2804,12 @@ var PasskeyVerificationError = class extends import_core8.KoraError {
|
|
|
2828
2804
|
};
|
|
2829
2805
|
function generateRegistrationOptions(params) {
|
|
2830
2806
|
const challengeBytes = (0, import_node_crypto8.randomBytes)(32);
|
|
2831
|
-
const challenge = toBase64Url(
|
|
2832
|
-
challengeBytes.
|
|
2833
|
-
|
|
2834
|
-
|
|
2807
|
+
const challenge = toBase64Url(
|
|
2808
|
+
challengeBytes.buffer.slice(
|
|
2809
|
+
challengeBytes.byteOffset,
|
|
2810
|
+
challengeBytes.byteOffset + challengeBytes.byteLength
|
|
2811
|
+
)
|
|
2812
|
+
);
|
|
2835
2813
|
return {
|
|
2836
2814
|
challenge,
|
|
2837
2815
|
rpId: params.rpId,
|
|
@@ -2941,10 +2919,12 @@ async function verifyRegistrationResponse(params) {
|
|
|
2941
2919
|
}
|
|
2942
2920
|
function generateAuthenticationOptions(params) {
|
|
2943
2921
|
const challengeBytes = (0, import_node_crypto8.randomBytes)(32);
|
|
2944
|
-
const challenge = toBase64Url(
|
|
2945
|
-
challengeBytes.
|
|
2946
|
-
|
|
2947
|
-
|
|
2922
|
+
const challenge = toBase64Url(
|
|
2923
|
+
challengeBytes.buffer.slice(
|
|
2924
|
+
challengeBytes.byteOffset,
|
|
2925
|
+
challengeBytes.byteOffset + challengeBytes.byteLength
|
|
2926
|
+
)
|
|
2927
|
+
);
|
|
2948
2928
|
return {
|
|
2949
2929
|
challenge,
|
|
2950
2930
|
rpId: params.rpId,
|
|
@@ -2999,9 +2979,7 @@ async function verifyAuthenticationResponse(params) {
|
|
|
2999
2979
|
}
|
|
3000
2980
|
const flags = authDataBytes[32];
|
|
3001
2981
|
if ((flags & 1) === 0) {
|
|
3002
|
-
throw new PasskeyVerificationError(
|
|
3003
|
-
"User Present flag is not set in authenticator data."
|
|
3004
|
-
);
|
|
2982
|
+
throw new PasskeyVerificationError("User Present flag is not set in authenticator data.");
|
|
3005
2983
|
}
|
|
3006
2984
|
const signCount = (authDataBytes[33] << 24 | authDataBytes[34] << 16 | authDataBytes[35] << 8 | authDataBytes[36]) >>> 0;
|
|
3007
2985
|
if (previousSignCount > 0 || signCount > 0) {
|
|
@@ -3016,9 +2994,7 @@ async function verifyAuthenticationResponse(params) {
|
|
|
3016
2994
|
}
|
|
3017
2995
|
}
|
|
3018
2996
|
const clientDataHash = await sha256(clientDataBytes);
|
|
3019
|
-
const signedData = new Uint8Array(
|
|
3020
|
-
authDataBytes.length + clientDataHash.byteLength
|
|
3021
|
-
);
|
|
2997
|
+
const signedData = new Uint8Array(authDataBytes.length + clientDataHash.byteLength);
|
|
3022
2998
|
signedData.set(authDataBytes, 0);
|
|
3023
2999
|
signedData.set(new Uint8Array(clientDataHash), authDataBytes.length);
|
|
3024
3000
|
const coseKeyBytes = fromBase64Url(publicKey);
|
|
@@ -3075,10 +3051,9 @@ async function verifyAuthenticationResponse(params) {
|
|
|
3075
3051
|
signedData.buffer
|
|
3076
3052
|
);
|
|
3077
3053
|
} catch (error) {
|
|
3078
|
-
throw new PasskeyVerificationError(
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
);
|
|
3054
|
+
throw new PasskeyVerificationError("Signature verification operation failed.", {
|
|
3055
|
+
cause: error instanceof Error ? error.message : String(error)
|
|
3056
|
+
});
|
|
3082
3057
|
}
|
|
3083
3058
|
if (!verified) {
|
|
3084
3059
|
return { verified: false, newSignCount: signCount };
|
|
@@ -3101,9 +3076,7 @@ function constantTimeEqual(a, b) {
|
|
|
3101
3076
|
function derToP1363(derSignature, componentLength) {
|
|
3102
3077
|
let offset = 0;
|
|
3103
3078
|
if (derSignature[offset] !== 48) {
|
|
3104
|
-
throw new PasskeyVerificationError(
|
|
3105
|
-
"Invalid DER signature: expected SEQUENCE tag (0x30)."
|
|
3106
|
-
);
|
|
3079
|
+
throw new PasskeyVerificationError("Invalid DER signature: expected SEQUENCE tag (0x30).");
|
|
3107
3080
|
}
|
|
3108
3081
|
offset += 1;
|
|
3109
3082
|
if (derSignature[offset] & 128) {
|
|
@@ -3352,14 +3325,11 @@ var OperationEncryptor = class {
|
|
|
3352
3325
|
if (cause instanceof OperationEncryptionError) {
|
|
3353
3326
|
throw cause;
|
|
3354
3327
|
}
|
|
3355
|
-
throw new OperationEncryptionError(
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
cause: cause instanceof Error ? cause.message : String(cause)
|
|
3361
|
-
}
|
|
3362
|
-
);
|
|
3328
|
+
throw new OperationEncryptionError(`Failed to encrypt operation ${fieldName} field.`, {
|
|
3329
|
+
operationId,
|
|
3330
|
+
fieldName,
|
|
3331
|
+
cause: cause instanceof Error ? cause.message : String(cause)
|
|
3332
|
+
});
|
|
3363
3333
|
}
|
|
3364
3334
|
}
|
|
3365
3335
|
async decryptField(field, operationId, fieldName) {
|
|
@@ -3383,10 +3353,10 @@ var OperationEncryptor = class {
|
|
|
3383
3353
|
const json = new TextDecoder().decode(plaintextBytes);
|
|
3384
3354
|
const parsed = JSON.parse(json);
|
|
3385
3355
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
3386
|
-
throw new OperationEncryptionError(
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
);
|
|
3356
|
+
throw new OperationEncryptionError(`Decrypted ${fieldName} is not a valid record object.`, {
|
|
3357
|
+
operationId,
|
|
3358
|
+
fieldName
|
|
3359
|
+
});
|
|
3390
3360
|
}
|
|
3391
3361
|
return parsed;
|
|
3392
3362
|
} catch (cause) {
|
|
@@ -3411,7 +3381,7 @@ function isEncryptedEnvelope(field) {
|
|
|
3411
3381
|
if (field === null || typeof field !== "object") {
|
|
3412
3382
|
return false;
|
|
3413
3383
|
}
|
|
3414
|
-
return field[ENCRYPTED_MARKER] === true && typeof field
|
|
3384
|
+
return field[ENCRYPTED_MARKER] === true && typeof field.ciphertext === "string" && typeof field.iv === "string" && field.algorithm === "AES-256-GCM";
|
|
3415
3385
|
}
|
|
3416
3386
|
|
|
3417
3387
|
// src/org/org-routes.ts
|
|
@@ -3460,11 +3430,9 @@ var MemberAlreadyExistsError = class extends OrgError {
|
|
|
3460
3430
|
};
|
|
3461
3431
|
var InsufficientRoleError = class extends OrgError {
|
|
3462
3432
|
constructor(required) {
|
|
3463
|
-
super(
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
{ requiredRole: required }
|
|
3467
|
-
);
|
|
3433
|
+
super(`This action requires at least the "${required}" role.`, "INSUFFICIENT_ROLE", {
|
|
3434
|
+
requiredRole: required
|
|
3435
|
+
});
|
|
3468
3436
|
}
|
|
3469
3437
|
};
|
|
3470
3438
|
var CannotRemoveOwnerError = class extends OrgError {
|
|
@@ -3674,17 +3642,28 @@ var OrgRoutes = class {
|
|
|
3674
3642
|
return { status: 400, body: { error: "Target user ID is required." } };
|
|
3675
3643
|
}
|
|
3676
3644
|
if (typeof params.role !== "string" || !isValidRole(params.role)) {
|
|
3677
|
-
return {
|
|
3645
|
+
return {
|
|
3646
|
+
status: 400,
|
|
3647
|
+
body: { error: "A valid role is required (admin, member, viewer, billing)." }
|
|
3648
|
+
};
|
|
3678
3649
|
}
|
|
3679
3650
|
if (params.role === "owner") {
|
|
3680
|
-
return {
|
|
3651
|
+
return {
|
|
3652
|
+
status: 400,
|
|
3653
|
+
body: { error: "Cannot assign owner role directly. Use ownership transfer." }
|
|
3654
|
+
};
|
|
3681
3655
|
}
|
|
3682
3656
|
const callerMembership = await this.store.getMembership(orgId, userId);
|
|
3683
3657
|
if (callerMembership && callerMembership.role !== "owner" && params.role === "admin") {
|
|
3684
3658
|
return { status: 403, body: { error: "Only the owner can add admin members." } };
|
|
3685
3659
|
}
|
|
3686
3660
|
try {
|
|
3687
|
-
const membership = await this.store.addMember(
|
|
3661
|
+
const membership = await this.store.addMember(
|
|
3662
|
+
orgId,
|
|
3663
|
+
params.targetUserId,
|
|
3664
|
+
params.role,
|
|
3665
|
+
userId
|
|
3666
|
+
);
|
|
3688
3667
|
return { status: 201, body: { data: membership } };
|
|
3689
3668
|
} catch (err) {
|
|
3690
3669
|
if (err instanceof OrgNotFoundError) {
|
|
@@ -3737,17 +3716,27 @@ var OrgRoutes = class {
|
|
|
3737
3716
|
return { status: 400, body: { error: "Target user ID is required." } };
|
|
3738
3717
|
}
|
|
3739
3718
|
if (typeof params.role !== "string" || !isValidRole(params.role)) {
|
|
3740
|
-
return {
|
|
3719
|
+
return {
|
|
3720
|
+
status: 400,
|
|
3721
|
+
body: { error: "A valid role is required (admin, member, viewer, billing)." }
|
|
3722
|
+
};
|
|
3741
3723
|
}
|
|
3742
3724
|
if (params.role === "owner") {
|
|
3743
|
-
return {
|
|
3725
|
+
return {
|
|
3726
|
+
status: 400,
|
|
3727
|
+
body: { error: "Cannot assign owner role directly. Use ownership transfer." }
|
|
3728
|
+
};
|
|
3744
3729
|
}
|
|
3745
3730
|
const callerMembership = await this.store.getMembership(orgId, userId);
|
|
3746
3731
|
if (callerMembership && callerMembership.role !== "owner" && params.role === "admin") {
|
|
3747
3732
|
return { status: 403, body: { error: "Only the owner can assign admin role." } };
|
|
3748
3733
|
}
|
|
3749
3734
|
try {
|
|
3750
|
-
const membership = await this.store.updateMemberRole(
|
|
3735
|
+
const membership = await this.store.updateMemberRole(
|
|
3736
|
+
orgId,
|
|
3737
|
+
params.targetUserId,
|
|
3738
|
+
params.role
|
|
3739
|
+
);
|
|
3751
3740
|
return { status: 200, body: { data: membership } };
|
|
3752
3741
|
} catch (err) {
|
|
3753
3742
|
if (err instanceof MembershipNotFoundError) {
|
|
@@ -3810,10 +3799,16 @@ var OrgRoutes = class {
|
|
|
3810
3799
|
return { status: 400, body: { error: "A valid email address is required." } };
|
|
3811
3800
|
}
|
|
3812
3801
|
if (typeof params.role !== "string" || !isValidRole(params.role)) {
|
|
3813
|
-
return {
|
|
3802
|
+
return {
|
|
3803
|
+
status: 400,
|
|
3804
|
+
body: { error: "A valid role is required (admin, member, viewer, billing)." }
|
|
3805
|
+
};
|
|
3814
3806
|
}
|
|
3815
3807
|
if (params.role === "owner") {
|
|
3816
|
-
return {
|
|
3808
|
+
return {
|
|
3809
|
+
status: 400,
|
|
3810
|
+
body: { error: "Cannot invite with owner role. Use ownership transfer." }
|
|
3811
|
+
};
|
|
3817
3812
|
}
|
|
3818
3813
|
const callerMembership = await this.store.getMembership(orgId, userId);
|
|
3819
3814
|
if (callerMembership && callerMembership.role !== "owner" && params.role === "admin") {
|
|
@@ -4265,20 +4260,14 @@ var InvalidPermissionError = class extends RbacError {
|
|
|
4265
4260
|
};
|
|
4266
4261
|
var RoleNotFoundError = class extends RbacError {
|
|
4267
4262
|
constructor(role) {
|
|
4268
|
-
super(
|
|
4269
|
-
`Role "${role}" is not defined.`,
|
|
4270
|
-
"ROLE_NOT_FOUND",
|
|
4271
|
-
{ role }
|
|
4272
|
-
);
|
|
4263
|
+
super(`Role "${role}" is not defined.`, "ROLE_NOT_FOUND", { role });
|
|
4273
4264
|
}
|
|
4274
4265
|
};
|
|
4275
4266
|
var CircularInheritanceError = class extends RbacError {
|
|
4276
4267
|
constructor(chain) {
|
|
4277
|
-
super(
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
{ chain }
|
|
4281
|
-
);
|
|
4268
|
+
super(`Circular role inheritance detected: ${chain.join(" \u2192 ")}.`, "CIRCULAR_INHERITANCE", {
|
|
4269
|
+
chain
|
|
4270
|
+
});
|
|
4282
4271
|
}
|
|
4283
4272
|
};
|
|
4284
4273
|
|
|
@@ -4379,15 +4368,11 @@ var RbacEngine = class {
|
|
|
4379
4368
|
permissions
|
|
4380
4369
|
};
|
|
4381
4370
|
const scopes = {};
|
|
4382
|
-
const hasAnyRead = permissions.some(
|
|
4383
|
-
(p) => permissionCovers(p, "*:read")
|
|
4384
|
-
);
|
|
4371
|
+
const hasAnyRead = permissions.some((p) => permissionCovers(p, "*:read"));
|
|
4385
4372
|
if (!hasAnyRead) {
|
|
4386
4373
|
return scopes;
|
|
4387
4374
|
}
|
|
4388
|
-
const isReadOnly = !permissions.some(
|
|
4389
|
-
(p) => permissionCovers(p, "*:write")
|
|
4390
|
-
);
|
|
4375
|
+
const isReadOnly = !permissions.some((p) => permissionCovers(p, "*:write"));
|
|
4391
4376
|
const collectionsToResolve = collections ?? [...this.collectionResolvers.keys()];
|
|
4392
4377
|
for (const collection of collectionsToResolve) {
|
|
4393
4378
|
const customResolver = this.collectionResolvers.get(collection);
|
|
@@ -4549,21 +4534,13 @@ var OrgScopeResolver = class {
|
|
|
4549
4534
|
* Check if a user can write to a specific collection in an org.
|
|
4550
4535
|
*/
|
|
4551
4536
|
async canWrite(userId, orgId, collection) {
|
|
4552
|
-
return this.rbac.hasPermission(
|
|
4553
|
-
userId,
|
|
4554
|
-
orgId,
|
|
4555
|
-
`${collection}:write`
|
|
4556
|
-
);
|
|
4537
|
+
return this.rbac.hasPermission(userId, orgId, `${collection}:write`);
|
|
4557
4538
|
}
|
|
4558
4539
|
/**
|
|
4559
4540
|
* Check if a user can read from a specific collection in an org.
|
|
4560
4541
|
*/
|
|
4561
4542
|
async canRead(userId, orgId, collection) {
|
|
4562
|
-
return this.rbac.hasPermission(
|
|
4563
|
-
userId,
|
|
4564
|
-
orgId,
|
|
4565
|
-
`${collection}:read`
|
|
4566
|
-
);
|
|
4543
|
+
return this.rbac.hasPermission(userId, orgId, `${collection}:read`);
|
|
4567
4544
|
}
|
|
4568
4545
|
// --- Private ---
|
|
4569
4546
|
resolveCollectionScope(ctx, collection) {
|
|
@@ -4623,7 +4600,9 @@ var OAuthUserInfoError = class extends OAuthError {
|
|
|
4623
4600
|
};
|
|
4624
4601
|
var OAuthProviderNotFoundError = class extends OAuthError {
|
|
4625
4602
|
constructor(provider) {
|
|
4626
|
-
super(`OAuth provider "${provider}" is not configured.`, "OAUTH_PROVIDER_NOT_FOUND", {
|
|
4603
|
+
super(`OAuth provider "${provider}" is not configured.`, "OAUTH_PROVIDER_NOT_FOUND", {
|
|
4604
|
+
provider
|
|
4605
|
+
});
|
|
4627
4606
|
}
|
|
4628
4607
|
};
|
|
4629
4608
|
|
|
@@ -4749,9 +4728,7 @@ var OAuthManager = class {
|
|
|
4749
4728
|
body: body.toString()
|
|
4750
4729
|
});
|
|
4751
4730
|
} catch (err) {
|
|
4752
|
-
throw new OAuthCodeExchangeError(
|
|
4753
|
-
err instanceof Error ? err.message : "Network error"
|
|
4754
|
-
);
|
|
4731
|
+
throw new OAuthCodeExchangeError(err instanceof Error ? err.message : "Network error");
|
|
4755
4732
|
}
|
|
4756
4733
|
if (!response.ok) {
|
|
4757
4734
|
let details = `HTTP ${response.status}`;
|
|
@@ -4764,12 +4741,12 @@ var OAuthManager = class {
|
|
|
4764
4741
|
}
|
|
4765
4742
|
const data = await response.json();
|
|
4766
4743
|
return {
|
|
4767
|
-
accessToken: data
|
|
4768
|
-
tokenType: data
|
|
4769
|
-
expiresIn: data
|
|
4770
|
-
refreshToken: data
|
|
4771
|
-
idToken: data
|
|
4772
|
-
scope: data
|
|
4744
|
+
accessToken: data.access_token,
|
|
4745
|
+
tokenType: data.token_type ?? "Bearer",
|
|
4746
|
+
expiresIn: data.expires_in,
|
|
4747
|
+
refreshToken: data.refresh_token,
|
|
4748
|
+
idToken: data.id_token,
|
|
4749
|
+
scope: data.scope
|
|
4773
4750
|
};
|
|
4774
4751
|
}
|
|
4775
4752
|
async fetchUserInfo(provider, accessToken) {
|
|
@@ -4782,9 +4759,7 @@ var OAuthManager = class {
|
|
|
4782
4759
|
}
|
|
4783
4760
|
});
|
|
4784
4761
|
} catch (err) {
|
|
4785
|
-
throw new OAuthUserInfoError(
|
|
4786
|
-
err instanceof Error ? err.message : "Network error"
|
|
4787
|
-
);
|
|
4762
|
+
throw new OAuthUserInfoError(err instanceof Error ? err.message : "Network error");
|
|
4788
4763
|
}
|
|
4789
4764
|
if (!response.ok) {
|
|
4790
4765
|
throw new OAuthUserInfoError(`HTTP ${response.status}`);
|
|
@@ -4797,43 +4772,43 @@ function normalizeUserInfo(providerId, profile) {
|
|
|
4797
4772
|
switch (providerId) {
|
|
4798
4773
|
case "google":
|
|
4799
4774
|
return {
|
|
4800
|
-
providerId: profile
|
|
4775
|
+
providerId: profile.sub,
|
|
4801
4776
|
provider: "google",
|
|
4802
|
-
email: profile
|
|
4803
|
-
emailVerified: profile
|
|
4804
|
-
name: profile
|
|
4805
|
-
avatarUrl: profile
|
|
4777
|
+
email: profile.email ?? null,
|
|
4778
|
+
emailVerified: profile.email_verified ?? false,
|
|
4779
|
+
name: profile.name ?? null,
|
|
4780
|
+
avatarUrl: profile.picture ?? null,
|
|
4806
4781
|
rawProfile: profile
|
|
4807
4782
|
};
|
|
4808
4783
|
case "github":
|
|
4809
4784
|
return {
|
|
4810
|
-
providerId: String(profile
|
|
4785
|
+
providerId: String(profile.id),
|
|
4811
4786
|
provider: "github",
|
|
4812
|
-
email: profile
|
|
4787
|
+
email: profile.email ?? null,
|
|
4813
4788
|
emailVerified: false,
|
|
4814
4789
|
// GitHub doesn't confirm in the profile response
|
|
4815
|
-
name: profile
|
|
4816
|
-
avatarUrl: profile
|
|
4790
|
+
name: profile.name ?? profile.login ?? null,
|
|
4791
|
+
avatarUrl: profile.avatar_url ?? null,
|
|
4817
4792
|
rawProfile: profile
|
|
4818
4793
|
};
|
|
4819
4794
|
case "microsoft":
|
|
4820
4795
|
return {
|
|
4821
|
-
providerId: profile
|
|
4796
|
+
providerId: profile.id,
|
|
4822
4797
|
provider: "microsoft",
|
|
4823
|
-
email: profile
|
|
4798
|
+
email: profile.mail ?? profile.userPrincipalName ?? null,
|
|
4824
4799
|
emailVerified: false,
|
|
4825
|
-
name: profile
|
|
4800
|
+
name: profile.displayName ?? null,
|
|
4826
4801
|
avatarUrl: null,
|
|
4827
4802
|
rawProfile: profile
|
|
4828
4803
|
};
|
|
4829
4804
|
default:
|
|
4830
4805
|
return {
|
|
4831
|
-
providerId: String(profile
|
|
4806
|
+
providerId: String(profile.id ?? profile.sub ?? ""),
|
|
4832
4807
|
provider: providerId,
|
|
4833
|
-
email: profile
|
|
4834
|
-
emailVerified: profile
|
|
4835
|
-
name: profile
|
|
4836
|
-
avatarUrl: profile
|
|
4808
|
+
email: profile.email ?? null,
|
|
4809
|
+
emailVerified: profile.email_verified ?? false,
|
|
4810
|
+
name: profile.name ?? null,
|
|
4811
|
+
avatarUrl: profile.picture ?? profile.avatar_url ?? null,
|
|
4837
4812
|
rawProfile: profile
|
|
4838
4813
|
};
|
|
4839
4814
|
}
|
|
@@ -5111,7 +5086,7 @@ function generateSessionId() {
|
|
|
5111
5086
|
globalThis.crypto.getRandomValues(bytes);
|
|
5112
5087
|
let hex = "";
|
|
5113
5088
|
for (let i = 0; i < bytes.length; i++) {
|
|
5114
|
-
hex += bytes[i]
|
|
5089
|
+
hex += bytes[i]?.toString(16).padStart(2, "0");
|
|
5115
5090
|
}
|
|
5116
5091
|
return hex;
|
|
5117
5092
|
}
|
|
@@ -5327,7 +5302,7 @@ var TotpManager = class {
|
|
|
5327
5302
|
*/
|
|
5328
5303
|
async isEnabled(userId) {
|
|
5329
5304
|
const stored = await this.store.getByUserId(userId);
|
|
5330
|
-
return stored
|
|
5305
|
+
return stored?.verified ?? false;
|
|
5331
5306
|
}
|
|
5332
5307
|
/**
|
|
5333
5308
|
* Get the number of remaining recovery codes for a user.
|
|
@@ -5361,7 +5336,7 @@ function generateTotpCode(secret, counter, digits, algorithm) {
|
|
|
5361
5336
|
const hash = hmacSha(algorithm, secret, counterBytes);
|
|
5362
5337
|
const offset = hash[hash.length - 1] & 15;
|
|
5363
5338
|
const binary = (hash[offset] & 127) << 24 | (hash[offset + 1] & 255) << 16 | (hash[offset + 2] & 255) << 8 | hash[offset + 3] & 255;
|
|
5364
|
-
const otp = binary %
|
|
5339
|
+
const otp = binary % 10 ** digits;
|
|
5365
5340
|
return otp.toString().padStart(digits, "0");
|
|
5366
5341
|
}
|
|
5367
5342
|
function hmacSha(algorithm, key, message) {
|
|
@@ -5405,7 +5380,10 @@ function sha1(data) {
|
|
|
5405
5380
|
w[i] = view.getInt32(offset + i * 4, false);
|
|
5406
5381
|
}
|
|
5407
5382
|
for (let i = 16; i < 80; i++) {
|
|
5408
|
-
w[i] = rotl32(
|
|
5383
|
+
w[i] = rotl32(
|
|
5384
|
+
w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16] | 0,
|
|
5385
|
+
1
|
|
5386
|
+
);
|
|
5409
5387
|
}
|
|
5410
5388
|
let a = h0;
|
|
5411
5389
|
let b = h1;
|
|
@@ -5514,7 +5492,7 @@ async function hashRecoveryCode(code) {
|
|
|
5514
5492
|
const bytes = new Uint8Array(hash);
|
|
5515
5493
|
let hex = "";
|
|
5516
5494
|
for (let i = 0; i < bytes.length; i++) {
|
|
5517
|
-
hex += bytes[i]
|
|
5495
|
+
hex += bytes[i]?.toString(16).padStart(2, "0");
|
|
5518
5496
|
}
|
|
5519
5497
|
return hex;
|
|
5520
5498
|
}
|
|
@@ -5719,8 +5697,9 @@ var InMemoryAuditLogStore = class {
|
|
|
5719
5697
|
const initialLength = this.entries.length;
|
|
5720
5698
|
let writeIndex = 0;
|
|
5721
5699
|
for (let i = 0; i < this.entries.length; i++) {
|
|
5722
|
-
|
|
5723
|
-
|
|
5700
|
+
const entry = this.entries[i];
|
|
5701
|
+
if (entry.timestamp >= timestamp) {
|
|
5702
|
+
this.entries[writeIndex] = entry;
|
|
5724
5703
|
writeIndex++;
|
|
5725
5704
|
}
|
|
5726
5705
|
}
|
|
@@ -5811,7 +5790,7 @@ function generateAuditId() {
|
|
|
5811
5790
|
globalThis.crypto.getRandomValues(bytes);
|
|
5812
5791
|
let hex = "";
|
|
5813
5792
|
for (let i = 0; i < bytes.length; i++) {
|
|
5814
|
-
hex += bytes[i]
|
|
5793
|
+
hex += bytes[i]?.toString(16).padStart(2, "0");
|
|
5815
5794
|
}
|
|
5816
5795
|
return hex;
|
|
5817
5796
|
}
|
|
@@ -5931,9 +5910,7 @@ var WebhookManager = class {
|
|
|
5931
5910
|
*/
|
|
5932
5911
|
async dispatch(event, data) {
|
|
5933
5912
|
const endpoints = await this.store.listEndpoints();
|
|
5934
|
-
const matching = endpoints.filter(
|
|
5935
|
-
(ep) => ep.active && ep.events.includes(event)
|
|
5936
|
-
);
|
|
5913
|
+
const matching = endpoints.filter((ep) => ep.active && ep.events.includes(event));
|
|
5937
5914
|
const payload = {
|
|
5938
5915
|
id: generateId2(),
|
|
5939
5916
|
event,
|
|
@@ -5941,9 +5918,7 @@ var WebhookManager = class {
|
|
|
5941
5918
|
data
|
|
5942
5919
|
};
|
|
5943
5920
|
const payloadJson = JSON.stringify(payload);
|
|
5944
|
-
await Promise.allSettled(
|
|
5945
|
-
matching.map((ep) => this.deliverToEndpoint(ep, payloadJson, event))
|
|
5946
|
-
);
|
|
5921
|
+
await Promise.allSettled(matching.map((ep) => this.deliverToEndpoint(ep, payloadJson, event)));
|
|
5947
5922
|
}
|
|
5948
5923
|
// --- Private ---
|
|
5949
5924
|
async deliverToEndpoint(endpoint, payloadJson, event) {
|
|
@@ -5996,7 +5971,7 @@ function generateId2() {
|
|
|
5996
5971
|
globalThis.crypto.getRandomValues(bytes);
|
|
5997
5972
|
let hex = "";
|
|
5998
5973
|
for (let i = 0; i < bytes.length; i++) {
|
|
5999
|
-
hex += bytes[i]
|
|
5974
|
+
hex += bytes[i]?.toString(16).padStart(2, "0");
|
|
6000
5975
|
}
|
|
6001
5976
|
return hex;
|
|
6002
5977
|
}
|
|
@@ -6005,7 +5980,7 @@ function generateSecret2() {
|
|
|
6005
5980
|
globalThis.crypto.getRandomValues(bytes);
|
|
6006
5981
|
let hex = "";
|
|
6007
5982
|
for (let i = 0; i < bytes.length; i++) {
|
|
6008
|
-
hex += bytes[i]
|
|
5983
|
+
hex += bytes[i]?.toString(16).padStart(2, "0");
|
|
6009
5984
|
}
|
|
6010
5985
|
return `whsec_${hex}`;
|
|
6011
5986
|
}
|
|
@@ -6018,15 +5993,11 @@ async function signPayload(payload, secret) {
|
|
|
6018
5993
|
false,
|
|
6019
5994
|
["sign"]
|
|
6020
5995
|
);
|
|
6021
|
-
const signature = await globalThis.crypto.subtle.sign(
|
|
6022
|
-
"HMAC",
|
|
6023
|
-
key,
|
|
6024
|
-
encoder.encode(payload)
|
|
6025
|
-
);
|
|
5996
|
+
const signature = await globalThis.crypto.subtle.sign("HMAC", key, encoder.encode(payload));
|
|
6026
5997
|
const bytes = new Uint8Array(signature);
|
|
6027
5998
|
let hex = "";
|
|
6028
5999
|
for (let i = 0; i < bytes.length; i++) {
|
|
6029
|
-
hex += bytes[i]
|
|
6000
|
+
hex += bytes[i]?.toString(16).padStart(2, "0");
|
|
6030
6001
|
}
|
|
6031
6002
|
return `sha256=${hex}`;
|
|
6032
6003
|
}
|