@korajs/auth 0.1.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.
@@ -0,0 +1,1040 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/server.ts
21
+ var server_exports = {};
22
+ __export(server_exports, {
23
+ AuthProviderError: () => AuthProviderError,
24
+ BuiltInAuthRoutes: () => BuiltInAuthRoutes,
25
+ BuiltInProvider: () => BuiltInProvider,
26
+ DuplicateEmailError: () => DuplicateEmailError,
27
+ InMemoryUserStore: () => InMemoryUserStore,
28
+ TokenManager: () => TokenManager,
29
+ computePublicKeyThumbprint: () => computePublicKeyThumbprint,
30
+ decodeJwt: () => decodeJwt,
31
+ encodeJwt: () => encodeJwt,
32
+ hashPassword: () => hashPassword,
33
+ isExpired: () => isExpired,
34
+ verifyChallenge: () => verifyChallenge,
35
+ verifyJwt: () => verifyJwt,
36
+ verifyPassword: () => verifyPassword
37
+ });
38
+ module.exports = __toCommonJS(server_exports);
39
+
40
+ // src/types.ts
41
+ var import_core = require("@korajs/core");
42
+ var DEFAULT_ACCESS_TOKEN_LIFETIME = 15 * 60 * 1e3;
43
+ var DEFAULT_REFRESH_TOKEN_LIFETIME = 90 * 24 * 60 * 60 * 1e3;
44
+ var DEFAULT_DEVICE_CREDENTIAL_LIFETIME = 90 * 24 * 60 * 60 * 1e3;
45
+ var DEFAULT_MAX_OFFLINE_DURATION = 30 * 24 * 60 * 60 * 1e3;
46
+ var DEFAULT_AUTO_LOCK_TIMEOUT = 15 * 60 * 1e3;
47
+
48
+ // src/tokens/jwt.ts
49
+ var import_node_crypto = require("crypto");
50
+ function base64urlEncode(input) {
51
+ return Buffer.from(input, "utf-8").toString("base64url");
52
+ }
53
+ function base64urlDecode(input) {
54
+ return Buffer.from(input, "base64url").toString("utf-8");
55
+ }
56
+ function hmacSha256Base64url(data, secret) {
57
+ return (0, import_node_crypto.createHmac)("sha256", secret).update(data).digest("base64url");
58
+ }
59
+ var ENCODED_HEADER = base64urlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" }));
60
+ function encodeJwt(payload, secret) {
61
+ const encodedPayload = base64urlEncode(JSON.stringify(payload));
62
+ const signingInput = `${ENCODED_HEADER}.${encodedPayload}`;
63
+ const signature = hmacSha256Base64url(signingInput, secret);
64
+ return `${signingInput}.${signature}`;
65
+ }
66
+ function decodeJwt(token) {
67
+ const parts = token.split(".");
68
+ if (parts.length !== 3) {
69
+ return null;
70
+ }
71
+ const payloadSegment = parts[1];
72
+ if (payloadSegment === void 0) {
73
+ return null;
74
+ }
75
+ try {
76
+ const decoded = base64urlDecode(payloadSegment);
77
+ const parsed = JSON.parse(decoded);
78
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
79
+ return null;
80
+ }
81
+ return parsed;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+ function verifyJwt(token, secret) {
87
+ const parts = token.split(".");
88
+ if (parts.length !== 3) {
89
+ return null;
90
+ }
91
+ const headerSegment = parts[0];
92
+ const payloadSegment = parts[1];
93
+ const signatureSegment = parts[2];
94
+ if (headerSegment === void 0 || payloadSegment === void 0 || signatureSegment === void 0) {
95
+ return null;
96
+ }
97
+ const signingInput = `${headerSegment}.${payloadSegment}`;
98
+ const expectedSignature = hmacSha256Base64url(signingInput, secret);
99
+ if (expectedSignature.length !== signatureSegment.length) {
100
+ return null;
101
+ }
102
+ let mismatch = 0;
103
+ for (let i = 0; i < expectedSignature.length; i++) {
104
+ mismatch |= expectedSignature.charCodeAt(i) ^ signatureSegment.charCodeAt(i);
105
+ }
106
+ if (mismatch !== 0) {
107
+ return null;
108
+ }
109
+ try {
110
+ const decoded = base64urlDecode(payloadSegment);
111
+ const parsed = JSON.parse(decoded);
112
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
113
+ return null;
114
+ }
115
+ return parsed;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+ function isExpired(payload) {
121
+ if (typeof payload.exp !== "number") {
122
+ return false;
123
+ }
124
+ const nowSeconds = Math.floor(Date.now() / 1e3);
125
+ return nowSeconds >= payload.exp;
126
+ }
127
+
128
+ // src/tokens/token-manager.ts
129
+ var TokenManager = class {
130
+ secret;
131
+ accessTokenLifetime;
132
+ refreshTokenLifetime;
133
+ deviceCredentialLifetime;
134
+ constructor(config) {
135
+ this.secret = config.secret;
136
+ this.accessTokenLifetime = config.accessTokenLifetime ?? DEFAULT_ACCESS_TOKEN_LIFETIME;
137
+ this.refreshTokenLifetime = config.refreshTokenLifetime ?? DEFAULT_REFRESH_TOKEN_LIFETIME;
138
+ this.deviceCredentialLifetime = config.deviceCredentialLifetime ?? DEFAULT_DEVICE_CREDENTIAL_LIFETIME;
139
+ }
140
+ /**
141
+ * Issue a signed JWT access token.
142
+ *
143
+ * Access tokens are short-lived (default 15 minutes) and used to authorize
144
+ * API requests. When expired, use {@link refreshAccessToken} with a valid
145
+ * refresh token to obtain a new one.
146
+ *
147
+ * @param userId - The subject (user ID) to encode in the token
148
+ * @param deviceId - The device ID of the requesting device
149
+ * @returns A signed JWT string with type 'access'
150
+ */
151
+ issueAccessToken(userId, deviceId) {
152
+ const nowSeconds = Math.floor(Date.now() / 1e3);
153
+ const payload = {
154
+ sub: userId,
155
+ dev: deviceId,
156
+ type: "access",
157
+ iat: nowSeconds,
158
+ exp: nowSeconds + Math.floor(this.accessTokenLifetime / 1e3)
159
+ };
160
+ return encodeJwt(payload, this.secret);
161
+ }
162
+ /**
163
+ * Issue a signed JWT refresh token.
164
+ *
165
+ * Refresh tokens are longer-lived (default 90 days) and used exclusively
166
+ * to obtain new access tokens via {@link refreshAccessToken}. They should
167
+ * be stored securely and never sent to resource APIs.
168
+ *
169
+ * @param userId - The subject (user ID) to encode in the token
170
+ * @param deviceId - The device ID of the requesting device
171
+ * @returns A signed JWT string with type 'refresh'
172
+ */
173
+ issueRefreshToken(userId, deviceId) {
174
+ const nowSeconds = Math.floor(Date.now() / 1e3);
175
+ const payload = {
176
+ sub: userId,
177
+ dev: deviceId,
178
+ type: "refresh",
179
+ iat: nowSeconds,
180
+ exp: nowSeconds + Math.floor(this.refreshTokenLifetime / 1e3)
181
+ };
182
+ return encodeJwt(payload, this.secret);
183
+ }
184
+ /**
185
+ * Issue a signed device credential token.
186
+ *
187
+ * Device credentials are long-lived tokens bound to a device's public key.
188
+ * They include a `mustCheckinBy` deadline; if the device does not check in
189
+ * before this deadline, the credential should be treated as revoked.
190
+ *
191
+ * @param userId - The subject (user ID) to encode in the token
192
+ * @param deviceId - The device ID of the requesting device
193
+ * @param publicKeyThumbprint - SHA-256 thumbprint of the device's public key
194
+ * @returns A signed JWT string with type 'device_credential'
195
+ */
196
+ issueDeviceCredential(userId, deviceId, publicKeyThumbprint) {
197
+ const nowSeconds = Math.floor(Date.now() / 1e3);
198
+ const lifetimeSeconds = Math.floor(this.deviceCredentialLifetime / 1e3);
199
+ const payload = {
200
+ sub: userId,
201
+ dev: deviceId,
202
+ type: "device_credential",
203
+ iat: nowSeconds,
204
+ exp: nowSeconds + lifetimeSeconds,
205
+ dpk: publicKeyThumbprint,
206
+ mustCheckinBy: nowSeconds + lifetimeSeconds
207
+ };
208
+ return encodeJwt(payload, this.secret);
209
+ }
210
+ /**
211
+ * Issue a complete set of authentication tokens.
212
+ *
213
+ * Always issues an access token and refresh token. If a `publicKeyThumbprint`
214
+ * is provided, also issues a device credential.
215
+ *
216
+ * @param userId - The subject (user ID) to encode in the tokens
217
+ * @param deviceId - The device ID of the requesting device
218
+ * @param publicKeyThumbprint - Optional SHA-256 thumbprint of the device's public key.
219
+ * When provided, a device credential is included in the returned tokens.
220
+ * @returns An {@link AuthTokens} object containing the issued tokens
221
+ */
222
+ issueTokens(userId, deviceId, publicKeyThumbprint) {
223
+ const tokens = {
224
+ accessToken: this.issueAccessToken(userId, deviceId),
225
+ refreshToken: this.issueRefreshToken(userId, deviceId)
226
+ };
227
+ if (publicKeyThumbprint !== void 0) {
228
+ tokens.deviceCredential = this.issueDeviceCredential(
229
+ userId,
230
+ deviceId,
231
+ publicKeyThumbprint
232
+ );
233
+ }
234
+ return tokens;
235
+ }
236
+ /**
237
+ * Validate and decode a token.
238
+ *
239
+ * Verifies the HMAC-SHA256 signature and checks that the token has not expired.
240
+ * Returns null (rather than throwing) for invalid or expired tokens, so callers
241
+ * can handle authentication failure without try/catch.
242
+ *
243
+ * @param token - The JWT string to validate
244
+ * @returns The decoded {@link TokenPayload} if valid, or null if the token is
245
+ * invalid, expired, or missing required claims
246
+ */
247
+ validateToken(token) {
248
+ const decoded = verifyJwt(token, this.secret);
249
+ if (decoded === null) {
250
+ return null;
251
+ }
252
+ if (isExpired(decoded)) {
253
+ return null;
254
+ }
255
+ if (typeof decoded["sub"] !== "string" || typeof decoded["dev"] !== "string" || typeof decoded["type"] !== "string" || typeof decoded["iat"] !== "number" || typeof decoded["exp"] !== "number") {
256
+ return null;
257
+ }
258
+ const type = decoded["type"];
259
+ if (type !== "access" && type !== "refresh" && type !== "device_credential") {
260
+ return null;
261
+ }
262
+ return {
263
+ sub: decoded["sub"],
264
+ dev: decoded["dev"],
265
+ type,
266
+ iat: decoded["iat"],
267
+ exp: decoded["exp"]
268
+ };
269
+ }
270
+ /**
271
+ * Refresh an access token using a valid refresh token.
272
+ *
273
+ * Implements **refresh token rotation**: a new refresh token is issued alongside
274
+ * the new access token, and the old refresh token should be considered consumed.
275
+ * This limits the window of vulnerability if a refresh token is compromised.
276
+ *
277
+ * Returns null if the provided token is invalid, expired, or not a refresh token.
278
+ *
279
+ * @param refreshToken - The refresh token JWT string
280
+ * @returns A new access/refresh token pair, or null if the refresh token is invalid
281
+ */
282
+ refreshAccessToken(refreshToken) {
283
+ const payload = this.validateToken(refreshToken);
284
+ if (payload === null) {
285
+ return null;
286
+ }
287
+ if (payload.type !== "refresh") {
288
+ return null;
289
+ }
290
+ return {
291
+ accessToken: this.issueAccessToken(payload.sub, payload.dev),
292
+ refreshToken: this.issueRefreshToken(payload.sub, payload.dev)
293
+ };
294
+ }
295
+ };
296
+
297
+ // src/provider/built-in/password-hash.ts
298
+ var import_node_crypto2 = require("crypto");
299
+ var PBKDF2_ITERATIONS = 6e5;
300
+ var PBKDF2_DIGEST = "sha512";
301
+ var KEY_LENGTH = 64;
302
+ var SALT_LENGTH = 32;
303
+ async function hashPassword(password) {
304
+ const salt = (0, import_node_crypto2.randomBytes)(SALT_LENGTH);
305
+ const derivedKey = await pbkdf2Async(
306
+ password,
307
+ salt,
308
+ PBKDF2_ITERATIONS,
309
+ KEY_LENGTH,
310
+ PBKDF2_DIGEST
311
+ );
312
+ return {
313
+ hash: derivedKey.toString("hex"),
314
+ salt: salt.toString("hex")
315
+ };
316
+ }
317
+ async function verifyPassword(password, hash, salt) {
318
+ const saltBuffer = Buffer.from(salt, "hex");
319
+ const derivedKey = await pbkdf2Async(
320
+ password,
321
+ saltBuffer,
322
+ PBKDF2_ITERATIONS,
323
+ KEY_LENGTH,
324
+ PBKDF2_DIGEST
325
+ );
326
+ const hashBuffer = Buffer.from(hash, "hex");
327
+ if (derivedKey.length !== hashBuffer.length) {
328
+ return false;
329
+ }
330
+ return (0, import_node_crypto2.timingSafeEqual)(derivedKey, hashBuffer);
331
+ }
332
+ function pbkdf2Async(password, salt, iterations, keyLength, digest) {
333
+ return new Promise((resolve, reject) => {
334
+ (0, import_node_crypto2.pbkdf2)(password, salt, iterations, keyLength, digest, (err, derivedKey) => {
335
+ if (err) {
336
+ reject(err);
337
+ } else {
338
+ resolve(derivedKey);
339
+ }
340
+ });
341
+ });
342
+ }
343
+
344
+ // src/provider/built-in/user-store.ts
345
+ var import_core2 = require("@korajs/core");
346
+ var import_node_crypto3 = require("crypto");
347
+ var DuplicateEmailError = class extends import_core2.KoraError {
348
+ constructor(email) {
349
+ super(
350
+ `A user with email "${email}" already exists. Use a different email address or sign in to the existing account.`,
351
+ "DUPLICATE_EMAIL",
352
+ { email }
353
+ );
354
+ this.name = "DuplicateEmailError";
355
+ }
356
+ };
357
+ var InMemoryUserStore = class {
358
+ /** Users indexed by ID */
359
+ usersById = /* @__PURE__ */ new Map();
360
+ /** Users indexed by email (lowercase) for fast lookup */
361
+ usersByEmail = /* @__PURE__ */ new Map();
362
+ /** Devices indexed by device ID */
363
+ devicesById = /* @__PURE__ */ new Map();
364
+ /** Device IDs indexed by user ID for fast listing */
365
+ devicesByUserId = /* @__PURE__ */ new Map();
366
+ /**
367
+ * Create a new user account.
368
+ *
369
+ * @param params - User creation parameters
370
+ * @param params.email - The user's email address (must be unique, case-insensitive)
371
+ * @param params.passwordHash - Hex-encoded PBKDF2 derived key
372
+ * @param params.salt - Hex-encoded salt used during hashing
373
+ * @param params.name - The user's display name
374
+ * @returns The created user (without sensitive credential fields)
375
+ * @throws {DuplicateEmailError} If a user with the same email already exists
376
+ */
377
+ async createUser(params) {
378
+ const normalizedEmail = params.email.toLowerCase();
379
+ if (this.usersByEmail.has(normalizedEmail)) {
380
+ throw new DuplicateEmailError(params.email);
381
+ }
382
+ const now = Date.now();
383
+ const id = (0, import_node_crypto3.randomUUID)();
384
+ const storedUser = {
385
+ id,
386
+ email: normalizedEmail,
387
+ name: params.name,
388
+ createdAt: now,
389
+ passwordHash: params.passwordHash,
390
+ salt: params.salt
391
+ };
392
+ this.usersById.set(id, storedUser);
393
+ this.usersByEmail.set(normalizedEmail, storedUser);
394
+ return toAuthUser(storedUser);
395
+ }
396
+ /**
397
+ * Find a user by email address.
398
+ *
399
+ * @param email - The email to search for (case-insensitive)
400
+ * @returns The stored user record including credentials, or null if not found
401
+ */
402
+ async findByEmail(email) {
403
+ return this.usersByEmail.get(email.toLowerCase()) ?? null;
404
+ }
405
+ /**
406
+ * Find a user by ID.
407
+ *
408
+ * @param id - The user ID to search for
409
+ * @returns The stored user record including credentials, or null if not found
410
+ */
411
+ async findById(id) {
412
+ return this.usersById.get(id) ?? null;
413
+ }
414
+ /**
415
+ * Register a device for a user.
416
+ *
417
+ * If a device with the same ID already exists and is not revoked, it is
418
+ * returned as-is (idempotent registration). If it was previously revoked,
419
+ * it is re-activated with updated details.
420
+ *
421
+ * @param params - Device registration parameters
422
+ * @param params.id - Unique device identifier
423
+ * @param params.userId - ID of the user who owns the device
424
+ * @param params.publicKey - Base64url-encoded device public key or thumbprint
425
+ * @param params.name - Human-readable device name
426
+ * @returns The registered device record
427
+ */
428
+ async registerDevice(params) {
429
+ const existing = this.devicesById.get(params.id);
430
+ if (existing !== void 0 && !existing.revoked) {
431
+ return existing;
432
+ }
433
+ const now = Date.now();
434
+ const device = {
435
+ id: params.id,
436
+ userId: params.userId,
437
+ publicKey: params.publicKey,
438
+ name: params.name,
439
+ revoked: false,
440
+ createdAt: now,
441
+ lastSeenAt: now
442
+ };
443
+ this.devicesById.set(params.id, device);
444
+ let userDevices = this.devicesByUserId.get(params.userId);
445
+ if (userDevices === void 0) {
446
+ userDevices = /* @__PURE__ */ new Set();
447
+ this.devicesByUserId.set(params.userId, userDevices);
448
+ }
449
+ userDevices.add(params.id);
450
+ return device;
451
+ }
452
+ /**
453
+ * Find a device by its ID.
454
+ *
455
+ * @param deviceId - The device ID to search for
456
+ * @returns The device record, or null if not found
457
+ */
458
+ async findDevice(deviceId) {
459
+ return this.devicesById.get(deviceId) ?? null;
460
+ }
461
+ /**
462
+ * List all devices registered for a user.
463
+ *
464
+ * @param userId - The user ID whose devices to list
465
+ * @returns Array of device records (includes revoked devices)
466
+ */
467
+ async listDevices(userId) {
468
+ const deviceIds = this.devicesByUserId.get(userId);
469
+ if (deviceIds === void 0) {
470
+ return [];
471
+ }
472
+ const devices = [];
473
+ for (const deviceId of deviceIds) {
474
+ const device = this.devicesById.get(deviceId);
475
+ if (device !== void 0) {
476
+ devices.push(device);
477
+ }
478
+ }
479
+ return devices;
480
+ }
481
+ /**
482
+ * Revoke a device, preventing it from being used for authentication.
483
+ *
484
+ * This is a soft revoke — the device record remains but is marked as revoked.
485
+ * If the device does not exist, this is a no-op.
486
+ *
487
+ * @param deviceId - The ID of the device to revoke
488
+ */
489
+ async revokeDevice(deviceId) {
490
+ const device = this.devicesById.get(deviceId);
491
+ if (device !== void 0) {
492
+ device.revoked = true;
493
+ }
494
+ }
495
+ /**
496
+ * Update the last-seen timestamp for a device.
497
+ *
498
+ * Called when a device authenticates or syncs to track activity.
499
+ * If the device does not exist, this is a no-op.
500
+ *
501
+ * @param deviceId - The ID of the device to update
502
+ */
503
+ async touchDevice(deviceId) {
504
+ const device = this.devicesById.get(deviceId);
505
+ if (device !== void 0) {
506
+ device.lastSeenAt = Date.now();
507
+ }
508
+ }
509
+ };
510
+ function toAuthUser(stored) {
511
+ return {
512
+ id: stored.id,
513
+ email: stored.email,
514
+ name: stored.name,
515
+ createdAt: stored.createdAt
516
+ };
517
+ }
518
+
519
+ // src/provider/built-in/auth-routes.ts
520
+ var MIN_PASSWORD_LENGTH = 8;
521
+ function isValidEmail(email) {
522
+ if (email.length === 0 || email.length > 254) {
523
+ return false;
524
+ }
525
+ const atIndex = email.indexOf("@");
526
+ if (atIndex < 1) {
527
+ return false;
528
+ }
529
+ const domain = email.slice(atIndex + 1);
530
+ if (domain.length === 0 || !domain.includes(".")) {
531
+ return false;
532
+ }
533
+ if (email.indexOf("@", atIndex + 1) !== -1) {
534
+ return false;
535
+ }
536
+ if (email.includes(" ")) {
537
+ return false;
538
+ }
539
+ return true;
540
+ }
541
+ var BuiltInAuthRoutes = class {
542
+ userStore;
543
+ tokenManager;
544
+ constructor(config) {
545
+ this.userStore = config.userStore;
546
+ this.tokenManager = config.tokenManager;
547
+ }
548
+ /**
549
+ * Handle user sign-up (POST /auth/signup).
550
+ *
551
+ * Validates email format and password length, hashes the password,
552
+ * creates the user, optionally registers a device, and issues tokens.
553
+ *
554
+ * @param body - Sign-up request body
555
+ * @param body.email - The user's email address
556
+ * @param body.password - The plaintext password (min 8 characters)
557
+ * @param body.name - Optional display name (defaults to email local part)
558
+ * @param body.deviceId - Optional device ID to register
559
+ * @param body.devicePublicKey - Optional device public key (base64url)
560
+ * @returns Auth response with the created user and tokens, or an error
561
+ */
562
+ async handleSignUp(body) {
563
+ if (!isValidEmail(body.email)) {
564
+ return {
565
+ status: 400,
566
+ body: {
567
+ error: "Invalid email address. Please provide a valid email in the format user@domain.com."
568
+ }
569
+ };
570
+ }
571
+ if (body.password.length < MIN_PASSWORD_LENGTH) {
572
+ return {
573
+ status: 400,
574
+ body: {
575
+ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long.`
576
+ }
577
+ };
578
+ }
579
+ const { hash, salt } = await hashPassword(body.password);
580
+ let user;
581
+ try {
582
+ user = await this.userStore.createUser({
583
+ email: body.email,
584
+ passwordHash: hash,
585
+ salt,
586
+ name: body.name ?? body.email.split("@")[0] ?? body.email
587
+ });
588
+ } catch (err) {
589
+ if (err instanceof Error && err.name === "DuplicateEmailError") {
590
+ return {
591
+ status: 409,
592
+ body: { error: "An account with this email already exists." }
593
+ };
594
+ }
595
+ throw err;
596
+ }
597
+ const deviceId = body.deviceId ?? `device-${user.id}`;
598
+ if (body.deviceId !== void 0 && body.devicePublicKey !== void 0) {
599
+ await this.userStore.registerDevice({
600
+ id: body.deviceId,
601
+ userId: user.id,
602
+ publicKey: body.devicePublicKey,
603
+ name: "Primary Device"
604
+ });
605
+ }
606
+ const tokens = this.tokenManager.issueTokens(user.id, deviceId);
607
+ return {
608
+ status: 201,
609
+ body: { data: { user, tokens } }
610
+ };
611
+ }
612
+ /**
613
+ * Handle user sign-in (POST /auth/signin).
614
+ *
615
+ * Looks up the user by email, verifies the password, optionally registers
616
+ * a new device, and issues tokens.
617
+ *
618
+ * @param body - Sign-in request body
619
+ * @param body.email - The user's email address
620
+ * @param body.password - The plaintext password
621
+ * @param body.deviceId - Optional device ID to register
622
+ * @param body.devicePublicKey - Optional device public key (base64url)
623
+ * @returns Auth response with the user and tokens, or an error
624
+ */
625
+ async handleSignIn(body) {
626
+ const storedUser = await this.userStore.findByEmail(body.email);
627
+ if (storedUser === null) {
628
+ return {
629
+ status: 401,
630
+ body: { error: "Invalid email or password." }
631
+ };
632
+ }
633
+ const passwordValid = await verifyPassword(
634
+ body.password,
635
+ storedUser.passwordHash,
636
+ storedUser.salt
637
+ );
638
+ if (!passwordValid) {
639
+ return {
640
+ status: 401,
641
+ body: { error: "Invalid email or password." }
642
+ };
643
+ }
644
+ const deviceId = body.deviceId ?? `device-${storedUser.id}`;
645
+ if (body.deviceId !== void 0 && body.devicePublicKey !== void 0) {
646
+ await this.userStore.registerDevice({
647
+ id: body.deviceId,
648
+ userId: storedUser.id,
649
+ publicKey: body.devicePublicKey,
650
+ name: "Device"
651
+ });
652
+ }
653
+ const tokens = this.tokenManager.issueTokens(storedUser.id, deviceId);
654
+ const user = {
655
+ id: storedUser.id,
656
+ email: storedUser.email,
657
+ name: storedUser.name,
658
+ createdAt: storedUser.createdAt
659
+ };
660
+ return {
661
+ status: 200,
662
+ body: { data: { user, tokens } }
663
+ };
664
+ }
665
+ /**
666
+ * Handle token refresh (POST /auth/refresh).
667
+ *
668
+ * Validates the provided refresh token and issues a new token pair
669
+ * (refresh token rotation). The old refresh token should be considered
670
+ * consumed after this call.
671
+ *
672
+ * @param body - Refresh request body
673
+ * @param body.refreshToken - The current refresh token
674
+ * @returns Auth response with new tokens, or an error
675
+ */
676
+ async handleRefresh(body) {
677
+ const result = this.tokenManager.refreshAccessToken(body.refreshToken);
678
+ if (result === null) {
679
+ return {
680
+ status: 401,
681
+ body: { error: "Invalid or expired refresh token." }
682
+ };
683
+ }
684
+ return {
685
+ status: 200,
686
+ body: { data: result }
687
+ };
688
+ }
689
+ /**
690
+ * Handle get-current-user (GET /auth/me).
691
+ *
692
+ * Validates the access token and returns the authenticated user's profile.
693
+ *
694
+ * @param accessToken - The JWT access token (without "Bearer " prefix)
695
+ * @returns Auth response with the user profile, or an error
696
+ */
697
+ async handleGetMe(accessToken) {
698
+ const payload = this.tokenManager.validateToken(accessToken);
699
+ if (payload === null || payload.type !== "access") {
700
+ return {
701
+ status: 401,
702
+ body: { error: "Invalid or expired access token." }
703
+ };
704
+ }
705
+ const storedUser = await this.userStore.findById(payload.sub);
706
+ if (storedUser === null) {
707
+ return {
708
+ status: 404,
709
+ body: { error: "User not found." }
710
+ };
711
+ }
712
+ const user = {
713
+ id: storedUser.id,
714
+ email: storedUser.email,
715
+ name: storedUser.name,
716
+ createdAt: storedUser.createdAt
717
+ };
718
+ return {
719
+ status: 200,
720
+ body: { data: user }
721
+ };
722
+ }
723
+ /**
724
+ * Handle list-devices (GET /auth/devices).
725
+ *
726
+ * Validates the access token and returns all devices registered for the user.
727
+ *
728
+ * @param accessToken - The JWT access token (without "Bearer " prefix)
729
+ * @returns Auth response with the device list, or an error
730
+ */
731
+ async handleListDevices(accessToken) {
732
+ const payload = this.tokenManager.validateToken(accessToken);
733
+ if (payload === null || payload.type !== "access") {
734
+ return {
735
+ status: 401,
736
+ body: { error: "Invalid or expired access token." }
737
+ };
738
+ }
739
+ const devices = await this.userStore.listDevices(payload.sub);
740
+ return {
741
+ status: 200,
742
+ body: { data: devices }
743
+ };
744
+ }
745
+ /**
746
+ * Handle device revocation (DELETE /auth/device/:id).
747
+ *
748
+ * Validates the access token and revokes the specified device.
749
+ * Only the device's owner can revoke it.
750
+ *
751
+ * @param accessToken - The JWT access token (without "Bearer " prefix)
752
+ * @param deviceId - The ID of the device to revoke
753
+ * @returns Auth response with success flag, or an error
754
+ */
755
+ async handleRevokeDevice(accessToken, deviceId) {
756
+ const payload = this.tokenManager.validateToken(accessToken);
757
+ if (payload === null || payload.type !== "access") {
758
+ return {
759
+ status: 401,
760
+ body: { error: "Invalid or expired access token." }
761
+ };
762
+ }
763
+ const device = await this.userStore.findDevice(deviceId);
764
+ if (device === null) {
765
+ return {
766
+ status: 404,
767
+ body: { error: "Device not found." }
768
+ };
769
+ }
770
+ if (device.userId !== payload.sub) {
771
+ return {
772
+ status: 403,
773
+ body: { error: "You can only revoke your own devices." }
774
+ };
775
+ }
776
+ await this.userStore.revokeDevice(deviceId);
777
+ return {
778
+ status: 200,
779
+ body: { data: { success: true } }
780
+ };
781
+ }
782
+ /**
783
+ * Creates a sync server auth provider compatible with `@korajs/server`.
784
+ *
785
+ * The returned object implements the `AuthProvider` interface from
786
+ * `@korajs/server`, validating access tokens and returning an auth
787
+ * context containing the user ID and device metadata. This bridges
788
+ * the built-in auth system with the sync server's authentication layer.
789
+ *
790
+ * @returns An object with an `authenticate` method suitable for KoraSyncServer's `auth` config
791
+ *
792
+ * @example
793
+ * ```typescript
794
+ * const routes = new BuiltInAuthRoutes({ userStore, tokenManager })
795
+ * const syncServer = new KoraSyncServer({
796
+ * store,
797
+ * auth: routes.toSyncAuthProvider(),
798
+ * })
799
+ * ```
800
+ */
801
+ toSyncAuthProvider() {
802
+ const tokenManager = this.tokenManager;
803
+ const userStore = this.userStore;
804
+ return {
805
+ async authenticate(token) {
806
+ const payload = tokenManager.validateToken(token);
807
+ if (payload === null || payload.type !== "access") {
808
+ return null;
809
+ }
810
+ const user = await userStore.findById(payload.sub);
811
+ if (user === null) {
812
+ return null;
813
+ }
814
+ await userStore.touchDevice(payload.dev);
815
+ return {
816
+ userId: payload.sub,
817
+ metadata: {
818
+ deviceId: payload.dev,
819
+ email: user.email,
820
+ name: user.name
821
+ }
822
+ };
823
+ }
824
+ };
825
+ }
826
+ };
827
+
828
+ // src/provider/adapter.ts
829
+ var AuthProviderError = class extends Error {
830
+ /** HTTP-style status code */
831
+ status;
832
+ constructor(message, status) {
833
+ super(message);
834
+ this.name = "AuthProviderError";
835
+ this.status = status;
836
+ }
837
+ };
838
+ var BuiltInProvider = class {
839
+ routes;
840
+ tokenManager;
841
+ userStore;
842
+ constructor(config) {
843
+ this.routes = new BuiltInAuthRoutes(config);
844
+ this.tokenManager = config.tokenManager;
845
+ this.userStore = config.userStore;
846
+ }
847
+ /** @inheritdoc */
848
+ async signUp(params) {
849
+ const result = await this.routes.handleSignUp(params);
850
+ if ("error" in result.body) {
851
+ throw new AuthProviderError(result.body.error, result.status);
852
+ }
853
+ return result.body.data;
854
+ }
855
+ /** @inheritdoc */
856
+ async signIn(params) {
857
+ const result = await this.routes.handleSignIn(params);
858
+ if ("error" in result.body) {
859
+ throw new AuthProviderError(result.body.error, result.status);
860
+ }
861
+ return result.body.data;
862
+ }
863
+ /** @inheritdoc */
864
+ async refreshTokens(refreshToken) {
865
+ const result = await this.routes.handleRefresh({ refreshToken });
866
+ if ("error" in result.body) {
867
+ throw new AuthProviderError(result.body.error, result.status);
868
+ }
869
+ return result.body.data;
870
+ }
871
+ /** @inheritdoc */
872
+ async validateAccessToken(token) {
873
+ const payload = this.tokenManager.validateToken(token);
874
+ if (payload === null || payload.type !== "access") {
875
+ return null;
876
+ }
877
+ return { userId: payload.sub, deviceId: payload.dev };
878
+ }
879
+ /** @inheritdoc */
880
+ async getUser(userId) {
881
+ const stored = await this.userStore.findById(userId);
882
+ if (stored === null) {
883
+ return null;
884
+ }
885
+ return {
886
+ id: stored.id,
887
+ email: stored.email,
888
+ name: stored.name,
889
+ createdAt: stored.createdAt
890
+ };
891
+ }
892
+ /** @inheritdoc */
893
+ async revokeDevice(accessToken, deviceId) {
894
+ const result = await this.routes.handleRevokeDevice(accessToken, deviceId);
895
+ if ("error" in result.body) {
896
+ throw new AuthProviderError(result.body.error, result.status);
897
+ }
898
+ }
899
+ /** @inheritdoc */
900
+ async listDevices(accessToken) {
901
+ const result = await this.routes.handleListDevices(accessToken);
902
+ if ("error" in result.body) {
903
+ throw new AuthProviderError(result.body.error, result.status);
904
+ }
905
+ return result.body.data;
906
+ }
907
+ };
908
+
909
+ // src/device/device-identity.ts
910
+ var import_core3 = require("@korajs/core");
911
+ var CryptoUnavailableError = class extends import_core3.KoraError {
912
+ constructor() {
913
+ super(
914
+ "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.",
915
+ "CRYPTO_UNAVAILABLE"
916
+ );
917
+ this.name = "CryptoUnavailableError";
918
+ }
919
+ };
920
+ var DeviceIdentityError = class extends import_core3.KoraError {
921
+ constructor(message, context) {
922
+ super(message, "DEVICE_IDENTITY_ERROR", context);
923
+ this.name = "DeviceIdentityError";
924
+ }
925
+ };
926
+ function toBase64Url(buffer) {
927
+ const bytes = new Uint8Array(buffer);
928
+ let binary = "";
929
+ for (let i = 0; i < bytes.length; i++) {
930
+ binary += String.fromCharCode(bytes[i]);
931
+ }
932
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
933
+ }
934
+ function fromBase64Url(str) {
935
+ let base64 = str.replace(/-/g, "+").replace(/_/g, "/");
936
+ const paddingNeeded = (4 - base64.length % 4) % 4;
937
+ base64 += "=".repeat(paddingNeeded);
938
+ const binary = atob(base64);
939
+ const bytes = new Uint8Array(binary.length);
940
+ for (let i = 0; i < binary.length; i++) {
941
+ bytes[i] = binary.charCodeAt(i);
942
+ }
943
+ return bytes;
944
+ }
945
+ function assertCryptoAvailable() {
946
+ if (typeof globalThis.crypto === "undefined" || typeof globalThis.crypto.subtle === "undefined") {
947
+ throw new CryptoUnavailableError();
948
+ }
949
+ }
950
+ var ECDSA_ALGORITHM = {
951
+ name: "ECDSA",
952
+ namedCurve: "P-256"
953
+ };
954
+ var ECDSA_SIGN_ALGORITHM = {
955
+ name: "ECDSA",
956
+ hash: { name: "SHA-256" }
957
+ };
958
+ async function verifyChallenge(publicKeyJwk, challenge, signature) {
959
+ assertCryptoAvailable();
960
+ try {
961
+ const publicKey = await globalThis.crypto.subtle.importKey(
962
+ "jwk",
963
+ publicKeyJwk,
964
+ ECDSA_ALGORITHM,
965
+ true,
966
+ ["verify"]
967
+ );
968
+ const encoded = new TextEncoder().encode(challenge);
969
+ const signatureBytes = fromBase64Url(signature);
970
+ const isValid = await globalThis.crypto.subtle.verify(
971
+ ECDSA_SIGN_ALGORITHM,
972
+ publicKey,
973
+ signatureBytes,
974
+ encoded
975
+ );
976
+ return isValid;
977
+ } catch (cause) {
978
+ throw new DeviceIdentityError(
979
+ "Failed to verify challenge signature. The public key JWK or signature format may be invalid.",
980
+ {
981
+ cause: cause instanceof Error ? cause.message : String(cause),
982
+ publicKeyKty: publicKeyJwk.kty,
983
+ publicKeyCrv: publicKeyJwk.crv
984
+ }
985
+ );
986
+ }
987
+ }
988
+ async function computePublicKeyThumbprint(publicKeyJwk) {
989
+ assertCryptoAvailable();
990
+ if (publicKeyJwk.kty !== "EC") {
991
+ throw new DeviceIdentityError(
992
+ `Expected JWK key type "EC" but received "${publicKeyJwk.kty ?? "undefined"}". Only ECDSA public keys are supported for device identity.`,
993
+ { kty: publicKeyJwk.kty }
994
+ );
995
+ }
996
+ if (!publicKeyJwk.crv || !publicKeyJwk.x || !publicKeyJwk.y) {
997
+ throw new DeviceIdentityError(
998
+ 'JWK is missing required EC fields. An EC public key JWK must include "crv", "x", and "y" members.',
999
+ {
1000
+ hasCrv: Boolean(publicKeyJwk.crv),
1001
+ hasX: Boolean(publicKeyJwk.x),
1002
+ hasY: Boolean(publicKeyJwk.y)
1003
+ }
1004
+ );
1005
+ }
1006
+ const canonicalJson = JSON.stringify({
1007
+ crv: publicKeyJwk.crv,
1008
+ kty: publicKeyJwk.kty,
1009
+ x: publicKeyJwk.x,
1010
+ y: publicKeyJwk.y
1011
+ });
1012
+ try {
1013
+ const encoded = new TextEncoder().encode(canonicalJson);
1014
+ const hashBuffer = await globalThis.crypto.subtle.digest("SHA-256", encoded);
1015
+ return toBase64Url(hashBuffer);
1016
+ } catch (cause) {
1017
+ throw new DeviceIdentityError(
1018
+ "Failed to compute SHA-256 thumbprint of the public key JWK.",
1019
+ { cause: cause instanceof Error ? cause.message : String(cause) }
1020
+ );
1021
+ }
1022
+ }
1023
+ // Annotate the CommonJS export names for ESM import in node:
1024
+ 0 && (module.exports = {
1025
+ AuthProviderError,
1026
+ BuiltInAuthRoutes,
1027
+ BuiltInProvider,
1028
+ DuplicateEmailError,
1029
+ InMemoryUserStore,
1030
+ TokenManager,
1031
+ computePublicKeyThumbprint,
1032
+ decodeJwt,
1033
+ encodeJwt,
1034
+ hashPassword,
1035
+ isExpired,
1036
+ verifyChallenge,
1037
+ verifyJwt,
1038
+ verifyPassword
1039
+ });
1040
+ //# sourceMappingURL=server.cjs.map