@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/server.js CHANGED
@@ -7,1290 +7,1049 @@ import {
7
7
  isEncryptedField,
8
8
  toBase64Url,
9
9
  verifyChallenge
10
- } from "./chunk-FSU4SK32.js";
10
+ } from "./chunk-IO2MCCG2.js";
11
11
  import {
12
12
  hashPassword,
13
13
  verifyPassword
14
- } from "./chunk-HOZXDR6Y.js";
14
+ } from "./chunk-L7GXPS74.js";
15
15
 
16
16
  // src/provider/built-in/auth-routes.ts
17
- import { randomBytes as randomBytes2 } from "crypto";
18
-
19
- // src/tokens/token-manager.ts
20
- import { randomBytes, randomUUID } from "crypto";
21
-
22
- // src/types.ts
23
- import { KoraError } from "@korajs/core";
24
- var DEFAULT_ACCESS_TOKEN_LIFETIME = 15 * 60 * 1e3;
25
- var DEFAULT_REFRESH_TOKEN_LIFETIME = 90 * 24 * 60 * 60 * 1e3;
26
- var DEFAULT_DEVICE_CREDENTIAL_LIFETIME = 90 * 24 * 60 * 60 * 1e3;
27
- var DEFAULT_MAX_OFFLINE_DURATION = 30 * 24 * 60 * 60 * 1e3;
28
- var DEFAULT_AUTO_LOCK_TIMEOUT = 15 * 60 * 1e3;
29
-
30
- // src/tokens/jwt.ts
31
- import { createHmac } from "crypto";
32
- function base64urlEncode(input) {
33
- return Buffer.from(input, "utf-8").toString("base64url");
34
- }
35
- function base64urlDecode(input) {
36
- return Buffer.from(input, "base64url").toString("utf-8");
37
- }
38
- function hmacSha256Base64url(data, secret) {
39
- return createHmac("sha256", secret).update(data).digest("base64url");
40
- }
41
- var ENCODED_HEADER = base64urlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" }));
42
- function encodeJwt(payload, secret) {
43
- const encodedPayload = base64urlEncode(JSON.stringify(payload));
44
- const signingInput = `${ENCODED_HEADER}.${encodedPayload}`;
45
- const signature = hmacSha256Base64url(signingInput, secret);
46
- return `${signingInput}.${signature}`;
47
- }
48
- function decodeJwt(token) {
49
- const parts = token.split(".");
50
- if (parts.length !== 3) {
51
- return null;
52
- }
53
- const payloadSegment = parts[1];
54
- if (payloadSegment === void 0) {
55
- return null;
17
+ import { randomBytes } from "crypto";
18
+ var InMemoryChallengeStore = class {
19
+ challenges = /* @__PURE__ */ new Map();
20
+ async store(challenge, deviceId, expiresAt) {
21
+ this.challenges.set(challenge, { deviceId, expiresAt });
56
22
  }
57
- try {
58
- const decoded = base64urlDecode(payloadSegment);
59
- const parsed = JSON.parse(decoded);
60
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
23
+ async consume(challenge) {
24
+ const entry = this.challenges.get(challenge);
25
+ if (entry === void 0) {
61
26
  return null;
62
27
  }
63
- return parsed;
64
- } catch {
65
- return null;
66
- }
67
- }
68
- function verifyJwt(token, secret) {
69
- const parts = token.split(".");
70
- if (parts.length !== 3) {
71
- return null;
72
- }
73
- const headerSegment = parts[0];
74
- const payloadSegment = parts[1];
75
- const signatureSegment = parts[2];
76
- if (headerSegment === void 0 || payloadSegment === void 0 || signatureSegment === void 0) {
77
- return null;
28
+ this.challenges.delete(challenge);
29
+ if (Date.now() > entry.expiresAt) {
30
+ return null;
31
+ }
32
+ return { deviceId: entry.deviceId };
78
33
  }
79
- if (headerSegment !== ENCODED_HEADER) {
80
- return null;
34
+ /**
35
+ * Remove expired challenges to prevent unbounded memory growth.
36
+ */
37
+ cleanup() {
38
+ const now = Date.now();
39
+ for (const [challenge, entry] of this.challenges) {
40
+ if (now > entry.expiresAt) {
41
+ this.challenges.delete(challenge);
42
+ }
43
+ }
81
44
  }
82
- const signingInput = `${headerSegment}.${payloadSegment}`;
83
- const expectedSignature = hmacSha256Base64url(signingInput, secret);
84
- if (expectedSignature.length !== signatureSegment.length) {
85
- return null;
45
+ };
46
+ var InMemoryRateLimiter = class {
47
+ attempts = /* @__PURE__ */ new Map();
48
+ maxAttempts;
49
+ windowMs;
50
+ /**
51
+ * @param maxAttempts - Maximum number of attempts within the time window (default: 10)
52
+ * @param windowMs - Time window in milliseconds (default: 60,000 = 1 minute)
53
+ */
54
+ constructor(maxAttempts = 10, windowMs = 6e4) {
55
+ this.maxAttempts = maxAttempts;
56
+ this.windowMs = windowMs;
86
57
  }
87
- let mismatch = 0;
88
- for (let i = 0; i < expectedSignature.length; i++) {
89
- mismatch |= expectedSignature.charCodeAt(i) ^ signatureSegment.charCodeAt(i);
58
+ async isAllowed(key) {
59
+ const now = Date.now();
60
+ const attempts = this.attempts.get(key) ?? [];
61
+ const recentAttempts = attempts.filter((t) => now - t < this.windowMs);
62
+ return recentAttempts.length < this.maxAttempts;
90
63
  }
91
- if (mismatch !== 0) {
92
- return null;
64
+ async record(key) {
65
+ const now = Date.now();
66
+ const attempts = this.attempts.get(key) ?? [];
67
+ const recentAttempts = attempts.filter((t) => now - t < this.windowMs);
68
+ recentAttempts.push(now);
69
+ this.attempts.set(key, recentAttempts);
93
70
  }
94
- try {
95
- const decoded = base64urlDecode(payloadSegment);
96
- const parsed = JSON.parse(decoded);
97
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
98
- return null;
99
- }
100
- return parsed;
101
- } catch {
102
- return null;
71
+ async reset(key) {
72
+ this.attempts.delete(key);
103
73
  }
104
- }
105
- var CLOCK_SKEW_TOLERANCE_SECONDS = 5;
106
- function isExpired(payload) {
107
- if (typeof payload.exp !== "number") {
74
+ };
75
+ var MIN_PASSWORD_LENGTH = 8;
76
+ var MAX_PASSWORD_LENGTH = 128;
77
+ var MAX_NAME_LENGTH = 200;
78
+ var CHALLENGE_TTL_MS = 6e4;
79
+ function isValidEmail(email) {
80
+ if (email.length === 0 || email.length > 254) {
108
81
  return false;
109
82
  }
110
- const nowSeconds = Math.floor(Date.now() / 1e3);
111
- return nowSeconds >= payload.exp + CLOCK_SKEW_TOLERANCE_SECONDS;
112
- }
113
-
114
- // src/tokens/token-manager.ts
115
- var MIN_SECRET_LENGTH = 32;
116
- var InMemoryTokenRevocationStore = class {
117
- revokedTokens = /* @__PURE__ */ new Map();
118
- revokedDevices = /* @__PURE__ */ new Set();
119
- async isRevoked(jti) {
120
- return this.revokedTokens.has(jti);
83
+ const atIndex = email.indexOf("@");
84
+ if (atIndex < 1) {
85
+ return false;
121
86
  }
122
- async revoke(jti, expiresAt) {
123
- this.revokedTokens.set(jti, expiresAt);
87
+ const domain = email.slice(atIndex + 1);
88
+ if (domain.length === 0 || !domain.includes(".")) {
89
+ return false;
124
90
  }
125
- async revokeAllForDevice(deviceId) {
126
- this.revokedDevices.add(deviceId);
91
+ if (email.indexOf("@", atIndex + 1) !== -1) {
92
+ return false;
127
93
  }
128
- /**
129
- * Check if a device has been revoked.
130
- */
131
- isDeviceRevoked(deviceId) {
132
- return this.revokedDevices.has(deviceId);
94
+ if (email.includes(" ")) {
95
+ return false;
133
96
  }
134
- /**
135
- * Remove expired revocations to prevent unbounded memory growth.
136
- * Call periodically (e.g., every hour) in long-running servers.
137
- */
138
- cleanup() {
139
- const nowSeconds = Math.floor(Date.now() / 1e3);
140
- for (const [jti, expiresAt] of this.revokedTokens) {
141
- if (nowSeconds > expiresAt) {
142
- this.revokedTokens.delete(jti);
143
- }
144
- }
97
+ return true;
98
+ }
99
+ function sanitizeName(name) {
100
+ const cleaned = name.replace(/[\x00-\x1f\x7f]/g, "");
101
+ const trimmed = cleaned.trim();
102
+ if (trimmed.length > MAX_NAME_LENGTH) {
103
+ return trimmed.slice(0, MAX_NAME_LENGTH);
145
104
  }
146
- };
147
- var TokenManager = class {
148
- /** All signing/verification secrets (index 0 = current signing key) */
149
- secrets;
150
- accessTokenLifetime;
151
- refreshTokenLifetime;
152
- deviceCredentialLifetime;
153
- revocationStore;
105
+ return trimmed;
106
+ }
107
+ var BuiltInAuthRoutes = class {
108
+ userStore;
109
+ tokenManager;
110
+ challengeStore;
111
+ rateLimiter;
154
112
  constructor(config) {
155
- const secrets = Array.isArray(config.secret) ? config.secret : [config.secret];
156
- if (secrets.length === 0) {
157
- throw new Error("TokenManager requires at least one secret.");
158
- }
159
- for (const secret of secrets) {
160
- if (secret.length < MIN_SECRET_LENGTH) {
161
- throw new Error(
162
- `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.`
163
- );
164
- }
165
- }
166
- this.secrets = secrets;
167
- this.accessTokenLifetime = config.accessTokenLifetime ?? DEFAULT_ACCESS_TOKEN_LIFETIME;
168
- this.refreshTokenLifetime = config.refreshTokenLifetime ?? DEFAULT_REFRESH_TOKEN_LIFETIME;
169
- this.deviceCredentialLifetime = config.deviceCredentialLifetime ?? DEFAULT_DEVICE_CREDENTIAL_LIFETIME;
170
- this.revocationStore = config.revocationStore;
171
- }
172
- /**
173
- * Generate a cryptographically random secret suitable for HMAC-SHA256 signing.
174
- *
175
- * Returns a 64-character hex string (32 bytes / 256 bits of entropy).
176
- * Store this securely (environment variable, secrets manager) — never in source code.
177
- *
178
- * @returns A random 256-bit hex-encoded secret
179
- */
180
- static generateSecret() {
181
- return randomBytes(32).toString("hex");
113
+ this.userStore = config.userStore;
114
+ this.tokenManager = config.tokenManager;
115
+ this.challengeStore = config.challengeStore ?? new InMemoryChallengeStore();
116
+ this.rateLimiter = config.rateLimiter ?? new InMemoryRateLimiter();
182
117
  }
183
118
  /**
184
- * Issue a signed JWT access token.
119
+ * Handle user sign-up (POST /auth/signup).
185
120
  *
186
- * Access tokens are short-lived (default 15 minutes) and used to authorize
187
- * API requests. When expired, use {@link refreshAccessToken} with a valid
188
- * refresh token to obtain a new one.
121
+ * Validates email format and password length, hashes the password,
122
+ * creates the user, optionally registers a device, and issues tokens.
189
123
  *
190
- * @param userId - The subject (user ID) to encode in the token
191
- * @param deviceId - The device ID of the requesting device
192
- * @returns A signed JWT string with type 'access'
124
+ * @param body - Sign-up request body
125
+ * @param body.email - The user's email address
126
+ * @param body.password - The plaintext password (8-128 characters)
127
+ * @param body.name - Optional display name (defaults to email local part)
128
+ * @param body.deviceId - Optional device ID to register
129
+ * @param body.devicePublicKey - Optional device public key (base64url)
130
+ * @param clientIp - Optional client IP for rate limiting
131
+ * @returns Auth response with the created user and tokens, or an error
193
132
  */
194
- issueAccessToken(userId, deviceId) {
195
- const nowSeconds = Math.floor(Date.now() / 1e3);
196
- const payload = {
197
- jti: randomUUID(),
198
- sub: userId,
199
- dev: deviceId,
200
- type: "access",
201
- iat: nowSeconds,
202
- exp: nowSeconds + Math.floor(this.accessTokenLifetime / 1e3)
203
- };
204
- return encodeJwt(payload, this.secrets[0]);
205
- }
206
- /**
207
- * Issue a signed JWT refresh token.
208
- *
209
- * Refresh tokens are longer-lived (default 90 days) and used exclusively
210
- * to obtain new access tokens via {@link refreshAccessToken}. They should
211
- * be stored securely and never sent to resource APIs.
212
- *
213
- * @param userId - The subject (user ID) to encode in the token
214
- * @param deviceId - The device ID of the requesting device
215
- * @returns A signed JWT string with type 'refresh'
216
- */
217
- issueRefreshToken(userId, deviceId) {
218
- const nowSeconds = Math.floor(Date.now() / 1e3);
219
- const payload = {
220
- jti: randomUUID(),
221
- sub: userId,
222
- dev: deviceId,
223
- type: "refresh",
224
- iat: nowSeconds,
225
- exp: nowSeconds + Math.floor(this.refreshTokenLifetime / 1e3)
133
+ async handleSignUp(body, clientIp) {
134
+ const rateLimitKey = clientIp ?? "global";
135
+ if (!await this.rateLimiter.isAllowed(rateLimitKey)) {
136
+ return {
137
+ status: 429,
138
+ body: { error: "Too many requests. Please try again later." }
139
+ };
140
+ }
141
+ await this.rateLimiter.record(rateLimitKey);
142
+ if (!isValidEmail(body.email)) {
143
+ return {
144
+ status: 400,
145
+ body: {
146
+ error: "Invalid email address. Please provide a valid email in the format user@domain.com."
147
+ }
148
+ };
149
+ }
150
+ if (body.password.length < MIN_PASSWORD_LENGTH) {
151
+ return {
152
+ status: 400,
153
+ body: {
154
+ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long.`
155
+ }
156
+ };
157
+ }
158
+ if (body.password.length > MAX_PASSWORD_LENGTH) {
159
+ return {
160
+ status: 400,
161
+ body: {
162
+ error: `Password must be at most ${MAX_PASSWORD_LENGTH} characters long.`
163
+ }
164
+ };
165
+ }
166
+ const { hash, salt } = await hashPassword(body.password);
167
+ const rawName = body.name ?? body.email.split("@")[0] ?? body.email;
168
+ const name = sanitizeName(rawName);
169
+ let user;
170
+ try {
171
+ user = await this.userStore.createUser({
172
+ email: body.email,
173
+ passwordHash: hash,
174
+ salt,
175
+ name
176
+ });
177
+ } catch (err) {
178
+ if (err instanceof Error && err.name === "DuplicateEmailError") {
179
+ return {
180
+ status: 409,
181
+ body: { error: "An account with this email already exists." }
182
+ };
183
+ }
184
+ throw err;
185
+ }
186
+ const deviceId = body.deviceId ?? `device-${user.id}`;
187
+ await this.userStore.registerDevice({
188
+ id: deviceId,
189
+ userId: user.id,
190
+ publicKey: body.devicePublicKey ?? "",
191
+ name: body.deviceId ? "Primary Device" : "Browser"
192
+ });
193
+ const tokens = this.tokenManager.issueTokens(user.id, deviceId);
194
+ return {
195
+ status: 201,
196
+ body: { data: { user, tokens } }
226
197
  };
227
- return encodeJwt(payload, this.secrets[0]);
228
198
  }
229
199
  /**
230
- * Issue a signed device credential token.
200
+ * Handle user sign-in (POST /auth/signin).
231
201
  *
232
- * Device credentials are long-lived tokens bound to a device's public key.
233
- * They include a `mustCheckinBy` deadline; if the device does not check in
234
- * before this deadline, the credential should be treated as revoked.
202
+ * Looks up the user by email, verifies the password, optionally registers
203
+ * a new device, and issues tokens.
235
204
  *
236
- * @param userId - The subject (user ID) to encode in the token
237
- * @param deviceId - The device ID of the requesting device
238
- * @param publicKeyThumbprint - SHA-256 thumbprint of the device's public key
239
- * @returns A signed JWT string with type 'device_credential'
205
+ * @param body - Sign-in request body
206
+ * @param body.email - The user's email address
207
+ * @param body.password - The plaintext password
208
+ * @param body.deviceId - Optional device ID to register
209
+ * @param body.devicePublicKey - Optional device public key (base64url)
210
+ * @param clientIp - Optional client IP for rate limiting
211
+ * @returns Auth response with the user and tokens, or an error
240
212
  */
241
- issueDeviceCredential(userId, deviceId, publicKeyThumbprint) {
242
- const nowSeconds = Math.floor(Date.now() / 1e3);
243
- const lifetimeSeconds = Math.floor(this.deviceCredentialLifetime / 1e3);
244
- const payload = {
245
- jti: randomUUID(),
246
- sub: userId,
247
- dev: deviceId,
248
- type: "device_credential",
249
- iat: nowSeconds,
250
- exp: nowSeconds + lifetimeSeconds,
251
- dpk: publicKeyThumbprint,
252
- mustCheckinBy: nowSeconds + lifetimeSeconds
213
+ async handleSignIn(body, clientIp) {
214
+ const rateLimitKey = clientIp ? `signin:${body.email.toLowerCase()}:${clientIp}` : `signin:${body.email.toLowerCase()}`;
215
+ if (!await this.rateLimiter.isAllowed(rateLimitKey)) {
216
+ return {
217
+ status: 429,
218
+ body: { error: "Too many sign-in attempts. Please try again later." }
219
+ };
220
+ }
221
+ await this.rateLimiter.record(rateLimitKey);
222
+ const storedUser = await this.userStore.findByEmail(body.email);
223
+ if (storedUser === null) {
224
+ return {
225
+ status: 401,
226
+ body: { error: "Invalid email or password." }
227
+ };
228
+ }
229
+ const passwordValid = await verifyPassword(
230
+ body.password,
231
+ storedUser.passwordHash,
232
+ storedUser.salt
233
+ );
234
+ if (!passwordValid) {
235
+ return {
236
+ status: 401,
237
+ body: { error: "Invalid email or password." }
238
+ };
239
+ }
240
+ await this.rateLimiter.reset(rateLimitKey);
241
+ const deviceId = body.deviceId ?? `device-${storedUser.id}`;
242
+ await this.userStore.registerDevice({
243
+ id: deviceId,
244
+ userId: storedUser.id,
245
+ publicKey: body.devicePublicKey ?? "",
246
+ name: body.deviceId ? "Device" : "Browser"
247
+ });
248
+ const tokens = this.tokenManager.issueTokens(storedUser.id, deviceId);
249
+ const user = {
250
+ id: storedUser.id,
251
+ email: storedUser.email,
252
+ name: storedUser.name,
253
+ emailVerified: storedUser.emailVerified,
254
+ createdAt: storedUser.createdAt
255
+ };
256
+ return {
257
+ status: 200,
258
+ body: { data: { user, tokens } }
253
259
  };
254
- return encodeJwt(payload, this.secrets[0]);
255
260
  }
256
261
  /**
257
- * Issue a complete set of authentication tokens.
262
+ * Handle token refresh (POST /auth/refresh).
258
263
  *
259
- * Always issues an access token and refresh token. If a `publicKeyThumbprint`
260
- * is provided, also issues a device credential.
264
+ * Validates the provided refresh token and issues a new token pair
265
+ * (refresh token rotation with reuse detection). The old refresh token
266
+ * is marked as consumed in the revocation store.
261
267
  *
262
- * @param userId - The subject (user ID) to encode in the tokens
263
- * @param deviceId - The device ID of the requesting device
264
- * @param publicKeyThumbprint - Optional SHA-256 thumbprint of the device's public key.
265
- * When provided, a device credential is included in the returned tokens.
266
- * @returns An {@link AuthTokens} object containing the issued tokens
268
+ * @param body - Refresh request body
269
+ * @param body.refreshToken - The current refresh token
270
+ * @returns Auth response with new tokens, or an error
267
271
  */
268
- issueTokens(userId, deviceId, publicKeyThumbprint) {
269
- const tokens = {
270
- accessToken: this.issueAccessToken(userId, deviceId),
271
- refreshToken: this.issueRefreshToken(userId, deviceId)
272
- };
273
- if (publicKeyThumbprint !== void 0) {
274
- tokens.deviceCredential = this.issueDeviceCredential(
275
- userId,
276
- deviceId,
277
- publicKeyThumbprint
278
- );
272
+ async handleRefresh(body) {
273
+ const result = await this.tokenManager.refreshAccessToken(body.refreshToken);
274
+ if (result === null) {
275
+ return {
276
+ status: 401,
277
+ body: { error: "Invalid or expired refresh token." }
278
+ };
279
279
  }
280
- return tokens;
280
+ return {
281
+ status: 200,
282
+ body: { data: result }
283
+ };
281
284
  }
282
285
  /**
283
- * Validate and decode a token.
286
+ * Handle sign-out (POST /auth/signout).
284
287
  *
285
- * Verifies the HMAC-SHA256 signature (trying all configured secrets for key rotation),
286
- * checks that the token has not expired, and validates all required claims.
287
- * Returns null (rather than throwing) for invalid or expired tokens, so callers
288
- * can handle authentication failure without try/catch.
288
+ * Validates the access token and revokes the current refresh token
289
+ * (if a revocation store is configured). This ensures that stolen
290
+ * refresh tokens cannot be used after the user signs out.
289
291
  *
290
- * @param token - The JWT string to validate
291
- * @returns The decoded {@link TokenPayload} if valid, or null if the token is
292
- * invalid, expired, or missing required claims
292
+ * @param accessToken - The JWT access token (without "Bearer " prefix)
293
+ * @param body - Sign-out request body
294
+ * @param body.refreshToken - The current refresh token to revoke
295
+ * @returns Auth response with success flag, or an error
293
296
  */
294
- validateToken(token) {
295
- let decoded = null;
296
- for (const secret of this.secrets) {
297
- decoded = verifyJwt(token, secret);
298
- if (decoded !== null) {
299
- break;
300
- }
301
- }
302
- if (decoded === null) {
303
- return null;
304
- }
305
- if (isExpired(decoded)) {
306
- return null;
307
- }
308
- 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") {
309
- return null;
297
+ async handleSignOut(accessToken, body) {
298
+ const payload = this.tokenManager.validateToken(accessToken);
299
+ if (payload === null || payload.type !== "access") {
300
+ return {
301
+ status: 401,
302
+ body: { error: "Invalid or expired access token." }
303
+ };
310
304
  }
311
- const type = decoded["type"];
312
- if (type !== "access" && type !== "refresh" && type !== "device_credential") {
313
- return null;
305
+ await this.tokenManager.revokeToken(payload.jti, payload.exp);
306
+ if (body.refreshToken) {
307
+ const refreshPayload = this.tokenManager.validateToken(body.refreshToken);
308
+ if (refreshPayload !== null && refreshPayload.type === "refresh") {
309
+ await this.tokenManager.revokeToken(refreshPayload.jti, refreshPayload.exp);
310
+ }
314
311
  }
315
312
  return {
316
- jti: decoded["jti"],
317
- sub: decoded["sub"],
318
- dev: decoded["dev"],
319
- type,
320
- iat: decoded["iat"],
321
- exp: decoded["exp"]
313
+ status: 200,
314
+ body: { data: { success: true } }
322
315
  };
323
316
  }
324
317
  /**
325
- * Validate a token and check it against the revocation store.
318
+ * Handle get-current-user (GET /auth/me).
326
319
  *
327
- * Like {@link validateToken}, but also checks whether the token's `jti` has been
328
- * revoked. Requires a revocation store to be configured.
320
+ * Validates the access token and returns the authenticated user's profile.
329
321
  *
330
- * @param token - The JWT string to validate
331
- * @returns The decoded {@link TokenPayload} if valid and not revoked, or null otherwise
332
- */
333
- async validateTokenWithRevocation(token) {
334
- const payload = this.validateToken(token);
335
- if (payload === null) {
336
- return null;
322
+ * @param accessToken - The JWT access token (without "Bearer " prefix)
323
+ * @returns Auth response with the user profile, or an error
324
+ */
325
+ async handleGetMe(accessToken) {
326
+ const payload = this.tokenManager.validateToken(accessToken);
327
+ if (payload === null || payload.type !== "access") {
328
+ return {
329
+ status: 401,
330
+ body: { error: "Invalid or expired access token." }
331
+ };
337
332
  }
338
- if (this.revocationStore) {
339
- const revoked = await this.revocationStore.isRevoked(payload.jti);
340
- if (revoked) {
341
- return null;
342
- }
333
+ const storedUser = await this.userStore.findById(payload.sub);
334
+ if (storedUser === null) {
335
+ return {
336
+ status: 404,
337
+ body: { error: "User not found." }
338
+ };
343
339
  }
344
- return payload;
340
+ const user = {
341
+ id: storedUser.id,
342
+ email: storedUser.email,
343
+ name: storedUser.name,
344
+ emailVerified: storedUser.emailVerified,
345
+ createdAt: storedUser.createdAt
346
+ };
347
+ return {
348
+ status: 200,
349
+ body: { data: user }
350
+ };
345
351
  }
346
352
  /**
347
- * Revoke a specific token by its JWT ID.
353
+ * Handle list-devices (GET /auth/devices).
348
354
  *
349
- * Requires a revocation store to be configured. After revocation, the token
350
- * will be rejected by {@link validateTokenWithRevocation}.
355
+ * Validates the access token and returns all devices registered for the user.
351
356
  *
352
- * @param jti - The JWT ID of the token to revoke
353
- * @param expiresAt - The token's expiration time (seconds since epoch)
357
+ * @param accessToken - The JWT access token (without "Bearer " prefix)
358
+ * @returns Auth response with the device list, or an error
354
359
  */
355
- async revokeToken(jti, expiresAt) {
356
- if (this.revocationStore) {
357
- await this.revocationStore.revoke(jti, expiresAt);
360
+ async handleListDevices(accessToken) {
361
+ const payload = this.tokenManager.validateToken(accessToken);
362
+ if (payload === null || payload.type !== "access") {
363
+ return {
364
+ status: 401,
365
+ body: { error: "Invalid or expired access token." }
366
+ };
358
367
  }
368
+ const devices = await this.userStore.listDevices(payload.sub);
369
+ return {
370
+ status: 200,
371
+ body: { data: devices }
372
+ };
359
373
  }
360
374
  /**
361
- * Revoke all tokens for a specific device.
375
+ * Handle device revocation (DELETE /auth/device/:id).
362
376
  *
363
- * Called when a device is revoked to ensure all its existing tokens
364
- * (access, refresh, and device credentials) are invalidated.
377
+ * Validates the access token, revokes the specified device, and invalidates
378
+ * all tokens issued to that device. Only the device's owner can revoke it.
365
379
  *
366
- * @param deviceId - The device ID whose tokens should be revoked
380
+ * @param accessToken - The JWT access token (without "Bearer " prefix)
381
+ * @param deviceId - The ID of the device to revoke
382
+ * @returns Auth response with success flag, or an error
367
383
  */
368
- async revokeDeviceTokens(deviceId) {
369
- if (this.revocationStore) {
370
- await this.revocationStore.revokeAllForDevice(deviceId);
384
+ async handleRevokeDevice(accessToken, deviceId) {
385
+ const payload = this.tokenManager.validateToken(accessToken);
386
+ if (payload === null || payload.type !== "access") {
387
+ return {
388
+ status: 401,
389
+ body: { error: "Invalid or expired access token." }
390
+ };
391
+ }
392
+ const device = await this.userStore.findDevice(deviceId);
393
+ if (device === null) {
394
+ return {
395
+ status: 404,
396
+ body: { error: "Device not found." }
397
+ };
371
398
  }
399
+ if (device.userId !== payload.sub) {
400
+ return {
401
+ status: 403,
402
+ body: { error: "You can only revoke your own devices." }
403
+ };
404
+ }
405
+ await this.userStore.revokeDevice(deviceId);
406
+ await this.tokenManager.revokeDeviceTokens(deviceId);
407
+ return {
408
+ status: 200,
409
+ body: { data: { success: true } }
410
+ };
372
411
  }
373
412
  /**
374
- * Refresh an access token using a valid refresh token.
375
- *
376
- * Implements **refresh token rotation with reuse detection**: a new refresh token
377
- * is issued alongside the new access token. The old refresh token's `jti` is
378
- * recorded in the revocation store (if configured). If a previously consumed
379
- * refresh token is presented again, it indicates potential token theft.
413
+ * Handle device registration (POST /auth/device/register).
380
414
  *
381
- * Returns null if the provided token is invalid, expired, or not a refresh token.
415
+ * Requires a valid access token. Registers a new device for the authenticated
416
+ * user and issues a device credential token bound to the device's public key.
382
417
  *
383
- * @param refreshToken - The refresh token JWT string
384
- * @returns A new access/refresh token pair, or null if the refresh token is invalid
418
+ * @param accessToken - The JWT access token (without "Bearer " prefix)
419
+ * @param body - Device registration request body
420
+ * @param body.deviceId - Unique identifier for the device
421
+ * @param body.publicKey - The device's public key as a JWK JSON string
422
+ * @param body.name - Human-readable device name (e.g., "Chrome on MacBook")
423
+ * @returns Auth response with the registered device and device credential, or an error
385
424
  */
386
- async refreshAccessToken(refreshToken) {
387
- const payload = this.validateToken(refreshToken);
388
- if (payload === null) {
389
- return null;
425
+ async handleDeviceRegister(accessToken, body) {
426
+ const payload = this.tokenManager.validateToken(accessToken);
427
+ if (payload === null || payload.type !== "access") {
428
+ return {
429
+ status: 401,
430
+ body: { error: "Invalid or expired access token." }
431
+ };
390
432
  }
391
- if (payload.type !== "refresh") {
392
- return null;
433
+ const deviceName = sanitizeName(body.name);
434
+ if (deviceName.length === 0) {
435
+ return {
436
+ status: 400,
437
+ body: { error: "Device name must not be empty." }
438
+ };
393
439
  }
394
- if (this.revocationStore) {
395
- const wasRevoked = await this.revocationStore.isRevoked(payload.jti);
396
- if (wasRevoked) {
397
- await this.revocationStore.revokeAllForDevice(payload.dev);
398
- return null;
399
- }
400
- await this.revocationStore.revoke(payload.jti, payload.exp);
440
+ let publicKeyJwk;
441
+ try {
442
+ publicKeyJwk = JSON.parse(body.publicKey);
443
+ } catch {
444
+ return {
445
+ status: 400,
446
+ body: { error: "Invalid public key format. Expected a JSON-encoded JWK string." }
447
+ };
448
+ }
449
+ let thumbprint;
450
+ try {
451
+ thumbprint = await computePublicKeyThumbprint(publicKeyJwk);
452
+ } catch {
453
+ return {
454
+ status: 400,
455
+ body: {
456
+ error: "Failed to compute public key thumbprint. Ensure the key is a valid EC P-256 JWK."
457
+ }
458
+ };
401
459
  }
460
+ const device = await this.userStore.registerDevice({
461
+ id: body.deviceId,
462
+ userId: payload.sub,
463
+ publicKey: body.publicKey,
464
+ name: deviceName
465
+ });
466
+ const deviceCredential = this.tokenManager.issueDeviceCredential(
467
+ payload.sub,
468
+ body.deviceId,
469
+ thumbprint
470
+ );
402
471
  return {
403
- accessToken: this.issueAccessToken(payload.sub, payload.dev),
404
- refreshToken: this.issueRefreshToken(payload.sub, payload.dev)
472
+ status: 201,
473
+ body: { data: { device, deviceCredential } }
405
474
  };
406
475
  }
407
- };
408
-
409
- // src/provider/built-in/user-store.ts
410
- import { KoraError as KoraError2 } from "@korajs/core";
411
- import { randomUUID as randomUUID2 } from "crypto";
412
- var DuplicateEmailError = class extends KoraError2 {
413
- constructor() {
414
- super(
415
- "A user with this email already exists.",
416
- "DUPLICATE_EMAIL"
417
- );
418
- this.name = "DuplicateEmailError";
419
- }
420
- };
421
- var InMemoryUserStore = class {
422
- /** Users indexed by ID */
423
- usersById = /* @__PURE__ */ new Map();
424
- /** Users indexed by email (lowercase) for fast lookup */
425
- usersByEmail = /* @__PURE__ */ new Map();
426
- /** Devices indexed by device ID */
427
- devicesById = /* @__PURE__ */ new Map();
428
- /** Device IDs indexed by user ID for fast listing */
429
- devicesByUserId = /* @__PURE__ */ new Map();
430
476
  /**
431
- * Create a new user account.
477
+ * Generate a challenge for device proof-of-possession verification.
432
478
  *
433
- * @param params - User creation parameters
434
- * @param params.email - The user's email address (must be unique, case-insensitive)
435
- * @param params.passwordHash - Hex-encoded PBKDF2 derived key
436
- * @param params.salt - Hex-encoded salt used during hashing
437
- * @param params.name - The user's display name
438
- * @returns The created user (without sensitive credential fields)
439
- * @throws {DuplicateEmailError} If a user with the same email already exists
479
+ * Creates a cryptographically random challenge, stores it server-side with
480
+ * a 60-second TTL and the target device ID, and returns the challenge string.
481
+ * The client signs this challenge with its private key and submits it via
482
+ * {@link handleDeviceVerify}.
483
+ *
484
+ * @param accessToken - The JWT access token (without "Bearer " prefix)
485
+ * @param deviceId - The device this challenge is intended for
486
+ * @returns Auth response with the challenge string, or an error
440
487
  */
441
- async createUser(params) {
442
- const normalizedEmail = params.email.toLowerCase();
443
- if (this.usersByEmail.has(normalizedEmail)) {
444
- throw new DuplicateEmailError();
488
+ async handleDeviceChallenge(accessToken, deviceId) {
489
+ const payload = this.tokenManager.validateToken(accessToken);
490
+ if (payload === null || payload.type !== "access") {
491
+ return {
492
+ status: 401,
493
+ body: { error: "Invalid or expired access token." }
494
+ };
445
495
  }
446
- const now = Date.now();
447
- const id = randomUUID2();
448
- const storedUser = {
449
- id,
450
- email: normalizedEmail,
451
- name: params.name,
452
- emailVerified: false,
453
- createdAt: now,
454
- passwordHash: params.passwordHash,
455
- salt: params.salt
496
+ const device = await this.userStore.findDevice(deviceId);
497
+ if (device === null || device.userId !== payload.sub) {
498
+ return {
499
+ status: 404,
500
+ body: { error: "Device not found." }
501
+ };
502
+ }
503
+ if (device.revoked) {
504
+ return {
505
+ status: 403,
506
+ body: { error: "Device has been revoked." }
507
+ };
508
+ }
509
+ const challenge = randomBytes(32).toString("hex");
510
+ const expiresAt = Date.now() + CHALLENGE_TTL_MS;
511
+ await this.challengeStore.store(challenge, deviceId, expiresAt);
512
+ return {
513
+ status: 200,
514
+ body: { data: { challenge } }
456
515
  };
457
- this.usersById.set(id, storedUser);
458
- this.usersByEmail.set(normalizedEmail, storedUser);
459
- return toAuthUser(storedUser);
460
516
  }
461
517
  /**
462
- * Find a user by email address.
463
- *
464
- * @param email - The email to search for (case-insensitive)
465
- * @returns The stored user record including credentials, or null if not found
466
- */
467
- async findByEmail(email) {
468
- return this.usersByEmail.get(email.toLowerCase()) ?? null;
469
- }
470
- /**
471
- * Find a user by ID.
472
- *
473
- * @param id - The user ID to search for
474
- * @returns The stored user record including credentials, or null if not found
475
- */
476
- async findById(id) {
477
- return this.usersById.get(id) ?? null;
478
- }
479
- /**
480
- * Register a device for a user.
481
- *
482
- * If a device with the same ID already exists and is not revoked, it is
483
- * returned as-is (idempotent registration). If it was previously revoked,
484
- * it is re-activated with updated details.
485
- *
486
- * @param params - Device registration parameters
487
- * @param params.id - Unique device identifier
488
- * @param params.userId - ID of the user who owns the device
489
- * @param params.publicKey - Base64url-encoded device public key or thumbprint
490
- * @param params.name - Human-readable device name
491
- * @returns The registered device record
492
- */
493
- async registerDevice(params) {
494
- const existing = this.devicesById.get(params.id);
495
- if (existing !== void 0 && !existing.revoked) {
496
- return existing;
497
- }
498
- const now = Date.now();
499
- const device = {
500
- id: params.id,
501
- userId: params.userId,
502
- publicKey: params.publicKey,
503
- name: params.name,
504
- revoked: false,
505
- createdAt: now,
506
- lastSeenAt: now
507
- };
508
- this.devicesById.set(params.id, device);
509
- let userDevices = this.devicesByUserId.get(params.userId);
510
- if (userDevices === void 0) {
511
- userDevices = /* @__PURE__ */ new Set();
512
- this.devicesByUserId.set(params.userId, userDevices);
513
- }
514
- userDevices.add(params.id);
515
- return device;
516
- }
517
- /**
518
- * Find a device by its ID.
519
- *
520
- * @param deviceId - The device ID to search for
521
- * @returns The device record, or null if not found
522
- */
523
- async findDevice(deviceId) {
524
- return this.devicesById.get(deviceId) ?? null;
525
- }
526
- /**
527
- * List all devices registered for a user.
528
- *
529
- * @param userId - The user ID whose devices to list
530
- * @returns Array of device records (includes revoked devices)
531
- */
532
- async listDevices(userId) {
533
- const deviceIds = this.devicesByUserId.get(userId);
534
- if (deviceIds === void 0) {
535
- return [];
536
- }
537
- const devices = [];
538
- for (const deviceId of deviceIds) {
539
- const device = this.devicesById.get(deviceId);
540
- if (device !== void 0) {
541
- devices.push(device);
542
- }
543
- }
544
- return devices;
545
- }
546
- /**
547
- * Revoke a device, preventing it from being used for authentication.
548
- *
549
- * This is a soft revoke — the device record remains but is marked as revoked.
550
- * If the device does not exist, this is a no-op.
551
- *
552
- * @param deviceId - The ID of the device to revoke
553
- */
554
- async revokeDevice(deviceId) {
555
- const device = this.devicesById.get(deviceId);
556
- if (device !== void 0) {
557
- device.revoked = true;
558
- }
559
- }
560
- /**
561
- * Set a user's email verification status.
562
- *
563
- * @param userId - The user whose email to verify
564
- * @param verified - Whether the email is verified
565
- */
566
- async setEmailVerified(userId, verified) {
567
- const user = this.usersById.get(userId);
568
- if (!user) return;
569
- const updated = { ...user, emailVerified: verified };
570
- this.usersById.set(userId, updated);
571
- this.usersByEmail.set(user.email, updated);
572
- }
573
- /**
574
- * Update a user's password hash and salt.
518
+ * Handle device proof-of-possession verification (POST /auth/device/verify).
575
519
  *
576
- * @param userId - The user whose password to update
577
- * @param passwordHash - New hex-encoded PBKDF2 derived key
578
- * @param salt - New hex-encoded salt
579
- */
580
- async updatePassword(userId, passwordHash, salt) {
581
- const user = this.usersById.get(userId);
582
- if (!user) return;
583
- const updated = { ...user, passwordHash, salt };
584
- this.usersById.set(userId, updated);
585
- this.usersByEmail.set(user.email, updated);
586
- }
587
- /**
588
- * List all users. For admin/development use.
589
- */
590
- async listAll() {
591
- return [...this.usersById.values()];
592
- }
593
- /**
594
- * Update a stored user record.
595
- */
596
- async update(user) {
597
- const existing = this.usersById.get(user.id);
598
- if (!existing) return;
599
- if (existing.email !== user.email) {
600
- this.usersByEmail.delete(existing.email);
601
- this.usersByEmail.set(user.email, user);
602
- } else {
603
- this.usersByEmail.set(user.email, user);
604
- }
605
- this.usersById.set(user.id, user);
606
- }
607
- /**
608
- * Delete a user and all associated devices.
609
- */
610
- async delete(userId) {
611
- const user = this.usersById.get(userId);
612
- if (!user) return;
613
- this.usersById.delete(userId);
614
- this.usersByEmail.delete(user.email);
615
- const deviceIds = this.devicesByUserId.get(userId);
616
- if (deviceIds) {
617
- for (const deviceId of deviceIds) {
618
- this.devicesById.delete(deviceId);
619
- }
620
- this.devicesByUserId.delete(userId);
621
- }
622
- }
623
- /**
624
- * Update the last-seen timestamp for a device.
520
+ * Verifies that the device holds the private key corresponding to its registered
521
+ * public key by checking a signed challenge. The challenge must have been previously
522
+ * issued via {@link handleDeviceChallenge} and is single-use.
625
523
  *
626
- * Called when a device authenticates or syncs to track activity.
627
- * If the device does not exist, this is a no-op.
524
+ * On success, issues fresh tokens for the device.
628
525
  *
629
- * @param deviceId - The ID of the device to update
526
+ * @param body - Device verification request body
527
+ * @param body.deviceId - The ID of the device to verify
528
+ * @param body.challenge - The challenge string (from handleDeviceChallenge)
529
+ * @param body.signature - The base64url-encoded ECDSA signature of the challenge
530
+ * @returns Auth response with fresh tokens on success, or an error
630
531
  */
631
- async touchDevice(deviceId) {
632
- const device = this.devicesById.get(deviceId);
633
- if (device !== void 0) {
634
- device.lastSeenAt = Date.now();
635
- }
636
- }
637
- };
638
- function toAuthUser(stored) {
639
- return {
640
- id: stored.id,
641
- email: stored.email,
642
- name: stored.name,
643
- emailVerified: stored.emailVerified,
644
- createdAt: stored.createdAt
645
- };
646
- }
647
-
648
- // src/provider/built-in/auth-routes.ts
649
- var InMemoryChallengeStore = class {
650
- challenges = /* @__PURE__ */ new Map();
651
- async store(challenge, deviceId, expiresAt) {
652
- this.challenges.set(challenge, { deviceId, expiresAt });
653
- }
654
- async consume(challenge) {
655
- const entry = this.challenges.get(challenge);
656
- if (entry === void 0) {
657
- return null;
658
- }
659
- this.challenges.delete(challenge);
660
- if (Date.now() > entry.expiresAt) {
661
- return null;
532
+ async handleDeviceVerify(body) {
533
+ const challengeEntry = await this.challengeStore.consume(body.challenge);
534
+ if (challengeEntry === null) {
535
+ return {
536
+ status: 401,
537
+ body: { error: "Invalid or expired challenge. Request a new challenge and try again." }
538
+ };
662
539
  }
663
- return { deviceId: entry.deviceId };
664
- }
665
- /**
666
- * Remove expired challenges to prevent unbounded memory growth.
667
- */
668
- cleanup() {
669
- const now = Date.now();
670
- for (const [challenge, entry] of this.challenges) {
671
- if (now > entry.expiresAt) {
672
- this.challenges.delete(challenge);
673
- }
540
+ if (challengeEntry.deviceId !== body.deviceId) {
541
+ return {
542
+ status: 401,
543
+ body: { error: "Challenge was not issued for this device." }
544
+ };
674
545
  }
675
- }
676
- };
677
- var InMemoryRateLimiter = class {
678
- attempts = /* @__PURE__ */ new Map();
679
- maxAttempts;
680
- windowMs;
681
- /**
682
- * @param maxAttempts - Maximum number of attempts within the time window (default: 10)
683
- * @param windowMs - Time window in milliseconds (default: 60,000 = 1 minute)
684
- */
685
- constructor(maxAttempts = 10, windowMs = 6e4) {
686
- this.maxAttempts = maxAttempts;
687
- this.windowMs = windowMs;
688
- }
689
- async isAllowed(key) {
690
- const now = Date.now();
691
- const attempts = this.attempts.get(key) ?? [];
692
- const recentAttempts = attempts.filter((t) => now - t < this.windowMs);
693
- return recentAttempts.length < this.maxAttempts;
694
- }
695
- async record(key) {
696
- const now = Date.now();
697
- const attempts = this.attempts.get(key) ?? [];
698
- const recentAttempts = attempts.filter((t) => now - t < this.windowMs);
699
- recentAttempts.push(now);
700
- this.attempts.set(key, recentAttempts);
701
- }
702
- async reset(key) {
703
- this.attempts.delete(key);
704
- }
705
- };
706
- var MIN_PASSWORD_LENGTH = 8;
707
- var MAX_PASSWORD_LENGTH = 128;
708
- var MAX_NAME_LENGTH = 200;
709
- var CHALLENGE_TTL_MS = 6e4;
710
- function isValidEmail(email) {
711
- if (email.length === 0 || email.length > 254) {
712
- return false;
713
- }
714
- const atIndex = email.indexOf("@");
715
- if (atIndex < 1) {
716
- return false;
717
- }
718
- const domain = email.slice(atIndex + 1);
719
- if (domain.length === 0 || !domain.includes(".")) {
720
- return false;
721
- }
722
- if (email.indexOf("@", atIndex + 1) !== -1) {
723
- return false;
724
- }
725
- if (email.includes(" ")) {
726
- return false;
727
- }
728
- return true;
729
- }
730
- function sanitizeName(name) {
731
- const cleaned = name.replace(/[\x00-\x1f\x7f]/g, "");
732
- const trimmed = cleaned.trim();
733
- if (trimmed.length > MAX_NAME_LENGTH) {
734
- return trimmed.slice(0, MAX_NAME_LENGTH);
735
- }
736
- return trimmed;
737
- }
738
- var BuiltInAuthRoutes = class {
739
- userStore;
740
- tokenManager;
741
- challengeStore;
742
- rateLimiter;
743
- constructor(config) {
744
- this.userStore = config.userStore;
745
- this.tokenManager = config.tokenManager;
746
- this.challengeStore = config.challengeStore ?? new InMemoryChallengeStore();
747
- this.rateLimiter = config.rateLimiter ?? new InMemoryRateLimiter();
748
- }
749
- /**
750
- * Handle user sign-up (POST /auth/signup).
751
- *
752
- * Validates email format and password length, hashes the password,
753
- * creates the user, optionally registers a device, and issues tokens.
754
- *
755
- * @param body - Sign-up request body
756
- * @param body.email - The user's email address
757
- * @param body.password - The plaintext password (8-128 characters)
758
- * @param body.name - Optional display name (defaults to email local part)
759
- * @param body.deviceId - Optional device ID to register
760
- * @param body.devicePublicKey - Optional device public key (base64url)
761
- * @param clientIp - Optional client IP for rate limiting
762
- * @returns Auth response with the created user and tokens, or an error
763
- */
764
- async handleSignUp(body, clientIp) {
765
- const rateLimitKey = clientIp ?? "global";
766
- if (!await this.rateLimiter.isAllowed(rateLimitKey)) {
546
+ const device = await this.userStore.findDevice(body.deviceId);
547
+ if (device === null) {
548
+ return {
549
+ status: 404,
550
+ body: { error: "Device not found." }
551
+ };
552
+ }
553
+ if (device.revoked) {
767
554
  return {
768
- status: 429,
769
- body: { error: "Too many requests. Please try again later." }
555
+ status: 403,
556
+ body: { error: "Device has been revoked and cannot authenticate." }
770
557
  };
771
558
  }
772
- await this.rateLimiter.record(rateLimitKey);
773
- if (!isValidEmail(body.email)) {
559
+ let publicKeyJwk;
560
+ try {
561
+ publicKeyJwk = JSON.parse(device.publicKey);
562
+ } catch {
774
563
  return {
775
- status: 400,
776
- body: {
777
- error: "Invalid email address. Please provide a valid email in the format user@domain.com."
778
- }
564
+ status: 500,
565
+ body: { error: "Device has an invalid stored public key." }
779
566
  };
780
567
  }
781
- if (body.password.length < MIN_PASSWORD_LENGTH) {
568
+ let isValid;
569
+ try {
570
+ isValid = await verifyChallenge(publicKeyJwk, body.challenge, body.signature);
571
+ } catch {
782
572
  return {
783
573
  status: 400,
784
574
  body: {
785
- error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long.`
575
+ error: "Signature verification failed. The signature or public key format may be invalid."
786
576
  }
787
577
  };
788
578
  }
789
- if (body.password.length > MAX_PASSWORD_LENGTH) {
579
+ if (!isValid) {
790
580
  return {
791
- status: 400,
792
- body: {
793
- error: `Password must be at most ${MAX_PASSWORD_LENGTH} characters long.`
794
- }
581
+ status: 401,
582
+ body: { error: "Invalid signature. Proof-of-possession verification failed." }
795
583
  };
796
584
  }
797
- const { hash, salt } = await hashPassword(body.password);
798
- const rawName = body.name ?? body.email.split("@")[0] ?? body.email;
799
- const name = sanitizeName(rawName);
800
- let user;
585
+ let thumbprint;
801
586
  try {
802
- user = await this.userStore.createUser({
803
- email: body.email,
804
- passwordHash: hash,
805
- salt,
806
- name
807
- });
808
- } catch (err) {
809
- if (err instanceof Error && err.name === "DuplicateEmailError") {
810
- return {
811
- status: 409,
812
- body: { error: "An account with this email already exists." }
813
- };
814
- }
815
- throw err;
587
+ thumbprint = await computePublicKeyThumbprint(publicKeyJwk);
588
+ } catch {
589
+ return {
590
+ status: 500,
591
+ body: { error: "Failed to compute public key thumbprint." }
592
+ };
816
593
  }
817
- const deviceId = body.deviceId ?? `device-${user.id}`;
818
- await this.userStore.registerDevice({
819
- id: deviceId,
820
- userId: user.id,
821
- publicKey: body.devicePublicKey ?? "",
822
- name: body.deviceId ? "Primary Device" : "Browser"
823
- });
824
- const tokens = this.tokenManager.issueTokens(user.id, deviceId);
594
+ const tokens = this.tokenManager.issueTokens(device.userId, device.id, thumbprint);
825
595
  return {
826
- status: 201,
827
- body: { data: { user, tokens } }
596
+ status: 200,
597
+ body: { data: { tokens } }
828
598
  };
829
599
  }
830
600
  /**
831
- * Handle user sign-in (POST /auth/signin).
601
+ * Generates a random challenge string for proof-of-possession verification.
832
602
  *
833
- * Looks up the user by email, verifies the password, optionally registers
834
- * a new device, and issues tokens.
603
+ * **Deprecated:** Use {@link handleDeviceChallenge} instead, which stores
604
+ * the challenge server-side with expiry and single-use semantics.
835
605
  *
836
- * @param body - Sign-in request body
837
- * @param body.email - The user's email address
838
- * @param body.password - The plaintext password
839
- * @param body.deviceId - Optional device ID to register
840
- * @param body.devicePublicKey - Optional device public key (base64url)
841
- * @param clientIp - Optional client IP for rate limiting
842
- * @returns Auth response with the user and tokens, or an error
606
+ * @returns A 64-character hex string (32 random bytes)
843
607
  */
844
- async handleSignIn(body, clientIp) {
845
- const rateLimitKey = clientIp ? `signin:${body.email.toLowerCase()}:${clientIp}` : `signin:${body.email.toLowerCase()}`;
846
- if (!await this.rateLimiter.isAllowed(rateLimitKey)) {
847
- return {
848
- status: 429,
849
- body: { error: "Too many sign-in attempts. Please try again later." }
850
- };
851
- }
852
- await this.rateLimiter.record(rateLimitKey);
853
- const storedUser = await this.userStore.findByEmail(body.email);
854
- if (storedUser === null) {
855
- return {
856
- status: 401,
857
- body: { error: "Invalid email or password." }
858
- };
859
- }
860
- const passwordValid = await verifyPassword(
861
- body.password,
862
- storedUser.passwordHash,
863
- storedUser.salt
864
- );
865
- if (!passwordValid) {
866
- return {
867
- status: 401,
868
- body: { error: "Invalid email or password." }
869
- };
870
- }
871
- await this.rateLimiter.reset(rateLimitKey);
872
- const deviceId = body.deviceId ?? `device-${storedUser.id}`;
873
- await this.userStore.registerDevice({
874
- id: deviceId,
875
- userId: storedUser.id,
876
- publicKey: body.devicePublicKey ?? "",
877
- name: body.deviceId ? "Device" : "Browser"
878
- });
879
- const tokens = this.tokenManager.issueTokens(storedUser.id, deviceId);
880
- const user = {
881
- id: storedUser.id,
882
- email: storedUser.email,
883
- name: storedUser.name,
884
- emailVerified: storedUser.emailVerified,
885
- createdAt: storedUser.createdAt
886
- };
608
+ static generateChallenge() {
609
+ return randomBytes(32).toString("hex");
610
+ }
611
+ /**
612
+ * Creates a sync server auth provider compatible with `@korajs/server`.
613
+ *
614
+ * The returned object implements the `AuthProvider` interface from
615
+ * `@korajs/server`, validating access tokens and returning an auth
616
+ * context containing the user ID and device metadata. This bridges
617
+ * the built-in auth system with the sync server's authentication layer.
618
+ *
619
+ * Also checks device revocation status during authentication, ensuring
620
+ * that revoked devices are rejected even if their tokens haven't expired.
621
+ *
622
+ * @returns An object with an `authenticate` method suitable for KoraSyncServer's `auth` config
623
+ *
624
+ * @example
625
+ * ```typescript
626
+ * const routes = new BuiltInAuthRoutes({ userStore, tokenManager })
627
+ * const syncServer = new KoraSyncServer({
628
+ * store,
629
+ * auth: routes.toSyncAuthProvider(),
630
+ * })
631
+ * ```
632
+ */
633
+ toSyncAuthProvider() {
634
+ const tokenManager = this.tokenManager;
635
+ const userStore = this.userStore;
887
636
  return {
888
- status: 200,
889
- body: { data: { user, tokens } }
637
+ async authenticate(token) {
638
+ const payload = tokenManager.validateToken(token);
639
+ if (payload === null || payload.type !== "access") {
640
+ return null;
641
+ }
642
+ const user = await userStore.findById(payload.sub);
643
+ if (user === null) {
644
+ return null;
645
+ }
646
+ const device = await userStore.findDevice(payload.dev);
647
+ if (device?.revoked) {
648
+ return null;
649
+ }
650
+ await userStore.touchDevice(payload.dev);
651
+ return {
652
+ userId: payload.sub,
653
+ metadata: {
654
+ deviceId: payload.dev,
655
+ email: user.email,
656
+ name: user.name
657
+ }
658
+ };
659
+ }
890
660
  };
891
661
  }
662
+ };
663
+
664
+ // src/tokens/token-manager.ts
665
+ import { randomBytes as randomBytes2, randomUUID } from "crypto";
666
+
667
+ // src/types.ts
668
+ import { KoraError } from "@korajs/core";
669
+ var DEFAULT_ACCESS_TOKEN_LIFETIME = 15 * 60 * 1e3;
670
+ var DEFAULT_REFRESH_TOKEN_LIFETIME = 90 * 24 * 60 * 60 * 1e3;
671
+ var DEFAULT_DEVICE_CREDENTIAL_LIFETIME = 90 * 24 * 60 * 60 * 1e3;
672
+ var DEFAULT_MAX_OFFLINE_DURATION = 30 * 24 * 60 * 60 * 1e3;
673
+ var DEFAULT_AUTO_LOCK_TIMEOUT = 15 * 60 * 1e3;
674
+
675
+ // src/tokens/jwt.ts
676
+ import { createHmac } from "crypto";
677
+ function base64urlEncode(input) {
678
+ return Buffer.from(input, "utf-8").toString("base64url");
679
+ }
680
+ function base64urlDecode(input) {
681
+ return Buffer.from(input, "base64url").toString("utf-8");
682
+ }
683
+ function hmacSha256Base64url(data, secret) {
684
+ return createHmac("sha256", secret).update(data).digest("base64url");
685
+ }
686
+ var ENCODED_HEADER = base64urlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" }));
687
+ function encodeJwt(payload, secret) {
688
+ const encodedPayload = base64urlEncode(JSON.stringify(payload));
689
+ const signingInput = `${ENCODED_HEADER}.${encodedPayload}`;
690
+ const signature = hmacSha256Base64url(signingInput, secret);
691
+ return `${signingInput}.${signature}`;
692
+ }
693
+ function decodeJwt(token) {
694
+ const parts = token.split(".");
695
+ if (parts.length !== 3) {
696
+ return null;
697
+ }
698
+ const payloadSegment = parts[1];
699
+ if (payloadSegment === void 0) {
700
+ return null;
701
+ }
702
+ try {
703
+ const decoded = base64urlDecode(payloadSegment);
704
+ const parsed = JSON.parse(decoded);
705
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
706
+ return null;
707
+ }
708
+ return parsed;
709
+ } catch {
710
+ return null;
711
+ }
712
+ }
713
+ function verifyJwt(token, secret) {
714
+ const parts = token.split(".");
715
+ if (parts.length !== 3) {
716
+ return null;
717
+ }
718
+ const headerSegment = parts[0];
719
+ const payloadSegment = parts[1];
720
+ const signatureSegment = parts[2];
721
+ if (headerSegment === void 0 || payloadSegment === void 0 || signatureSegment === void 0) {
722
+ return null;
723
+ }
724
+ if (headerSegment !== ENCODED_HEADER) {
725
+ return null;
726
+ }
727
+ const signingInput = `${headerSegment}.${payloadSegment}`;
728
+ const expectedSignature = hmacSha256Base64url(signingInput, secret);
729
+ if (expectedSignature.length !== signatureSegment.length) {
730
+ return null;
731
+ }
732
+ let mismatch = 0;
733
+ for (let i = 0; i < expectedSignature.length; i++) {
734
+ mismatch |= expectedSignature.charCodeAt(i) ^ signatureSegment.charCodeAt(i);
735
+ }
736
+ if (mismatch !== 0) {
737
+ return null;
738
+ }
739
+ try {
740
+ const decoded = base64urlDecode(payloadSegment);
741
+ const parsed = JSON.parse(decoded);
742
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
743
+ return null;
744
+ }
745
+ return parsed;
746
+ } catch {
747
+ return null;
748
+ }
749
+ }
750
+ var CLOCK_SKEW_TOLERANCE_SECONDS = 5;
751
+ function isExpired(payload) {
752
+ if (typeof payload.exp !== "number") {
753
+ return false;
754
+ }
755
+ const nowSeconds = Math.floor(Date.now() / 1e3);
756
+ return nowSeconds >= payload.exp + CLOCK_SKEW_TOLERANCE_SECONDS;
757
+ }
758
+
759
+ // src/tokens/token-manager.ts
760
+ var MIN_SECRET_LENGTH = 32;
761
+ var InMemoryTokenRevocationStore = class {
762
+ revokedTokens = /* @__PURE__ */ new Map();
763
+ revokedDevices = /* @__PURE__ */ new Set();
764
+ async isRevoked(jti) {
765
+ return this.revokedTokens.has(jti);
766
+ }
767
+ async revoke(jti, expiresAt) {
768
+ this.revokedTokens.set(jti, expiresAt);
769
+ }
770
+ async revokeAllForDevice(deviceId) {
771
+ this.revokedDevices.add(deviceId);
772
+ }
892
773
  /**
893
- * Handle token refresh (POST /auth/refresh).
894
- *
895
- * Validates the provided refresh token and issues a new token pair
896
- * (refresh token rotation with reuse detection). The old refresh token
897
- * is marked as consumed in the revocation store.
898
- *
899
- * @param body - Refresh request body
900
- * @param body.refreshToken - The current refresh token
901
- * @returns Auth response with new tokens, or an error
774
+ * Check if a device has been revoked.
902
775
  */
903
- async handleRefresh(body) {
904
- const result = await this.tokenManager.refreshAccessToken(body.refreshToken);
905
- if (result === null) {
906
- return {
907
- status: 401,
908
- body: { error: "Invalid or expired refresh token." }
909
- };
910
- }
911
- return {
912
- status: 200,
913
- body: { data: result }
914
- };
776
+ isDeviceRevoked(deviceId) {
777
+ return this.revokedDevices.has(deviceId);
915
778
  }
916
779
  /**
917
- * Handle sign-out (POST /auth/signout).
918
- *
919
- * Validates the access token and revokes the current refresh token
920
- * (if a revocation store is configured). This ensures that stolen
921
- * refresh tokens cannot be used after the user signs out.
922
- *
923
- * @param accessToken - The JWT access token (without "Bearer " prefix)
924
- * @param body - Sign-out request body
925
- * @param body.refreshToken - The current refresh token to revoke
926
- * @returns Auth response with success flag, or an error
780
+ * Remove expired revocations to prevent unbounded memory growth.
781
+ * Call periodically (e.g., every hour) in long-running servers.
927
782
  */
928
- async handleSignOut(accessToken, body) {
929
- const payload = this.tokenManager.validateToken(accessToken);
930
- if (payload === null || payload.type !== "access") {
931
- return {
932
- status: 401,
933
- body: { error: "Invalid or expired access token." }
934
- };
783
+ cleanup() {
784
+ const nowSeconds = Math.floor(Date.now() / 1e3);
785
+ for (const [jti, expiresAt] of this.revokedTokens) {
786
+ if (nowSeconds > expiresAt) {
787
+ this.revokedTokens.delete(jti);
788
+ }
935
789
  }
936
- await this.tokenManager.revokeToken(payload.jti, payload.exp);
937
- if (body.refreshToken) {
938
- const refreshPayload = this.tokenManager.validateToken(body.refreshToken);
939
- if (refreshPayload !== null && refreshPayload.type === "refresh") {
940
- await this.tokenManager.revokeToken(refreshPayload.jti, refreshPayload.exp);
790
+ }
791
+ };
792
+ var TokenManager = class {
793
+ /** All signing/verification secrets (index 0 = current signing key) */
794
+ secrets;
795
+ accessTokenLifetime;
796
+ refreshTokenLifetime;
797
+ deviceCredentialLifetime;
798
+ revocationStore;
799
+ constructor(config) {
800
+ const secrets = Array.isArray(config.secret) ? config.secret : [config.secret];
801
+ if (secrets.length === 0) {
802
+ throw new Error("TokenManager requires at least one secret.");
803
+ }
804
+ for (const secret of secrets) {
805
+ if (secret.length < MIN_SECRET_LENGTH) {
806
+ throw new Error(
807
+ `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.`
808
+ );
941
809
  }
942
810
  }
943
- return {
944
- status: 200,
945
- body: { data: { success: true } }
946
- };
811
+ this.secrets = secrets;
812
+ this.accessTokenLifetime = config.accessTokenLifetime ?? DEFAULT_ACCESS_TOKEN_LIFETIME;
813
+ this.refreshTokenLifetime = config.refreshTokenLifetime ?? DEFAULT_REFRESH_TOKEN_LIFETIME;
814
+ this.deviceCredentialLifetime = config.deviceCredentialLifetime ?? DEFAULT_DEVICE_CREDENTIAL_LIFETIME;
815
+ this.revocationStore = config.revocationStore;
947
816
  }
948
817
  /**
949
- * Handle get-current-user (GET /auth/me).
818
+ * Generate a cryptographically random secret suitable for HMAC-SHA256 signing.
950
819
  *
951
- * Validates the access token and returns the authenticated user's profile.
820
+ * Returns a 64-character hex string (32 bytes / 256 bits of entropy).
821
+ * Store this securely (environment variable, secrets manager) — never in source code.
952
822
  *
953
- * @param accessToken - The JWT access token (without "Bearer " prefix)
954
- * @returns Auth response with the user profile, or an error
823
+ * @returns A random 256-bit hex-encoded secret
955
824
  */
956
- async handleGetMe(accessToken) {
957
- const payload = this.tokenManager.validateToken(accessToken);
958
- if (payload === null || payload.type !== "access") {
959
- return {
960
- status: 401,
961
- body: { error: "Invalid or expired access token." }
962
- };
963
- }
964
- const storedUser = await this.userStore.findById(payload.sub);
965
- if (storedUser === null) {
966
- return {
967
- status: 404,
968
- body: { error: "User not found." }
969
- };
970
- }
971
- const user = {
972
- id: storedUser.id,
973
- email: storedUser.email,
974
- name: storedUser.name,
975
- emailVerified: storedUser.emailVerified,
976
- createdAt: storedUser.createdAt
977
- };
978
- return {
979
- status: 200,
980
- body: { data: user }
825
+ static generateSecret() {
826
+ return randomBytes2(32).toString("hex");
827
+ }
828
+ /**
829
+ * Issue a signed JWT access token.
830
+ *
831
+ * Access tokens are short-lived (default 15 minutes) and used to authorize
832
+ * API requests. When expired, use {@link refreshAccessToken} with a valid
833
+ * refresh token to obtain a new one.
834
+ *
835
+ * @param userId - The subject (user ID) to encode in the token
836
+ * @param deviceId - The device ID of the requesting device
837
+ * @returns A signed JWT string with type 'access'
838
+ */
839
+ issueAccessToken(userId, deviceId) {
840
+ const nowSeconds = Math.floor(Date.now() / 1e3);
841
+ const payload = {
842
+ jti: randomUUID(),
843
+ sub: userId,
844
+ dev: deviceId,
845
+ type: "access",
846
+ iat: nowSeconds,
847
+ exp: nowSeconds + Math.floor(this.accessTokenLifetime / 1e3)
981
848
  };
849
+ return encodeJwt(payload, this.secrets[0]);
982
850
  }
983
851
  /**
984
- * Handle list-devices (GET /auth/devices).
852
+ * Issue a signed JWT refresh token.
985
853
  *
986
- * Validates the access token and returns all devices registered for the user.
854
+ * Refresh tokens are longer-lived (default 90 days) and used exclusively
855
+ * to obtain new access tokens via {@link refreshAccessToken}. They should
856
+ * be stored securely and never sent to resource APIs.
987
857
  *
988
- * @param accessToken - The JWT access token (without "Bearer " prefix)
989
- * @returns Auth response with the device list, or an error
858
+ * @param userId - The subject (user ID) to encode in the token
859
+ * @param deviceId - The device ID of the requesting device
860
+ * @returns A signed JWT string with type 'refresh'
990
861
  */
991
- async handleListDevices(accessToken) {
992
- const payload = this.tokenManager.validateToken(accessToken);
993
- if (payload === null || payload.type !== "access") {
994
- return {
995
- status: 401,
996
- body: { error: "Invalid or expired access token." }
997
- };
998
- }
999
- const devices = await this.userStore.listDevices(payload.sub);
1000
- return {
1001
- status: 200,
1002
- body: { data: devices }
862
+ issueRefreshToken(userId, deviceId) {
863
+ const nowSeconds = Math.floor(Date.now() / 1e3);
864
+ const payload = {
865
+ jti: randomUUID(),
866
+ sub: userId,
867
+ dev: deviceId,
868
+ type: "refresh",
869
+ iat: nowSeconds,
870
+ exp: nowSeconds + Math.floor(this.refreshTokenLifetime / 1e3)
1003
871
  };
872
+ return encodeJwt(payload, this.secrets[0]);
1004
873
  }
1005
874
  /**
1006
- * Handle device revocation (DELETE /auth/device/:id).
875
+ * Issue a signed device credential token.
1007
876
  *
1008
- * Validates the access token, revokes the specified device, and invalidates
1009
- * all tokens issued to that device. Only the device's owner can revoke it.
877
+ * Device credentials are long-lived tokens bound to a device's public key.
878
+ * They include a `mustCheckinBy` deadline; if the device does not check in
879
+ * before this deadline, the credential should be treated as revoked.
1010
880
  *
1011
- * @param accessToken - The JWT access token (without "Bearer " prefix)
1012
- * @param deviceId - The ID of the device to revoke
1013
- * @returns Auth response with success flag, or an error
881
+ * @param userId - The subject (user ID) to encode in the token
882
+ * @param deviceId - The device ID of the requesting device
883
+ * @param publicKeyThumbprint - SHA-256 thumbprint of the device's public key
884
+ * @returns A signed JWT string with type 'device_credential'
1014
885
  */
1015
- async handleRevokeDevice(accessToken, deviceId) {
1016
- const payload = this.tokenManager.validateToken(accessToken);
1017
- if (payload === null || payload.type !== "access") {
1018
- return {
1019
- status: 401,
1020
- body: { error: "Invalid or expired access token." }
1021
- };
1022
- }
1023
- const device = await this.userStore.findDevice(deviceId);
1024
- if (device === null) {
1025
- return {
1026
- status: 404,
1027
- body: { error: "Device not found." }
1028
- };
1029
- }
1030
- if (device.userId !== payload.sub) {
1031
- return {
1032
- status: 403,
1033
- body: { error: "You can only revoke your own devices." }
1034
- };
1035
- }
1036
- await this.userStore.revokeDevice(deviceId);
1037
- await this.tokenManager.revokeDeviceTokens(deviceId);
1038
- return {
1039
- status: 200,
1040
- body: { data: { success: true } }
886
+ issueDeviceCredential(userId, deviceId, publicKeyThumbprint) {
887
+ const nowSeconds = Math.floor(Date.now() / 1e3);
888
+ const lifetimeSeconds = Math.floor(this.deviceCredentialLifetime / 1e3);
889
+ const payload = {
890
+ jti: randomUUID(),
891
+ sub: userId,
892
+ dev: deviceId,
893
+ type: "device_credential",
894
+ iat: nowSeconds,
895
+ exp: nowSeconds + lifetimeSeconds,
896
+ dpk: publicKeyThumbprint,
897
+ mustCheckinBy: nowSeconds + lifetimeSeconds
1041
898
  };
899
+ return encodeJwt(payload, this.secrets[0]);
1042
900
  }
1043
901
  /**
1044
- * Handle device registration (POST /auth/device/register).
902
+ * Issue a complete set of authentication tokens.
1045
903
  *
1046
- * Requires a valid access token. Registers a new device for the authenticated
1047
- * user and issues a device credential token bound to the device's public key.
904
+ * Always issues an access token and refresh token. If a `publicKeyThumbprint`
905
+ * is provided, also issues a device credential.
1048
906
  *
1049
- * @param accessToken - The JWT access token (without "Bearer " prefix)
1050
- * @param body - Device registration request body
1051
- * @param body.deviceId - Unique identifier for the device
1052
- * @param body.publicKey - The device's public key as a JWK JSON string
1053
- * @param body.name - Human-readable device name (e.g., "Chrome on MacBook")
1054
- * @returns Auth response with the registered device and device credential, or an error
907
+ * @param userId - The subject (user ID) to encode in the tokens
908
+ * @param deviceId - The device ID of the requesting device
909
+ * @param publicKeyThumbprint - Optional SHA-256 thumbprint of the device's public key.
910
+ * When provided, a device credential is included in the returned tokens.
911
+ * @returns An {@link AuthTokens} object containing the issued tokens
1055
912
  */
1056
- async handleDeviceRegister(accessToken, body) {
1057
- const payload = this.tokenManager.validateToken(accessToken);
1058
- if (payload === null || payload.type !== "access") {
1059
- return {
1060
- status: 401,
1061
- body: { error: "Invalid or expired access token." }
1062
- };
1063
- }
1064
- const deviceName = sanitizeName(body.name);
1065
- if (deviceName.length === 0) {
1066
- return {
1067
- status: 400,
1068
- body: { error: "Device name must not be empty." }
1069
- };
1070
- }
1071
- let publicKeyJwk;
1072
- try {
1073
- publicKeyJwk = JSON.parse(body.publicKey);
1074
- } catch {
1075
- return {
1076
- status: 400,
1077
- body: { error: "Invalid public key format. Expected a JSON-encoded JWK string." }
1078
- };
1079
- }
1080
- let thumbprint;
1081
- try {
1082
- thumbprint = await computePublicKeyThumbprint(publicKeyJwk);
1083
- } catch {
1084
- return {
1085
- status: 400,
1086
- body: { error: "Failed to compute public key thumbprint. Ensure the key is a valid EC P-256 JWK." }
1087
- };
1088
- }
1089
- const device = await this.userStore.registerDevice({
1090
- id: body.deviceId,
1091
- userId: payload.sub,
1092
- publicKey: body.publicKey,
1093
- name: deviceName
1094
- });
1095
- const deviceCredential = this.tokenManager.issueDeviceCredential(
1096
- payload.sub,
1097
- body.deviceId,
1098
- thumbprint
1099
- );
1100
- return {
1101
- status: 201,
1102
- body: { data: { device, deviceCredential } }
913
+ issueTokens(userId, deviceId, publicKeyThumbprint) {
914
+ const tokens = {
915
+ accessToken: this.issueAccessToken(userId, deviceId),
916
+ refreshToken: this.issueRefreshToken(userId, deviceId)
1103
917
  };
918
+ if (publicKeyThumbprint !== void 0) {
919
+ tokens.deviceCredential = this.issueDeviceCredential(userId, deviceId, publicKeyThumbprint);
920
+ }
921
+ return tokens;
1104
922
  }
1105
923
  /**
1106
- * Generate a challenge for device proof-of-possession verification.
924
+ * Validate and decode a token.
1107
925
  *
1108
- * Creates a cryptographically random challenge, stores it server-side with
1109
- * a 60-second TTL and the target device ID, and returns the challenge string.
1110
- * The client signs this challenge with its private key and submits it via
1111
- * {@link handleDeviceVerify}.
926
+ * Verifies the HMAC-SHA256 signature (trying all configured secrets for key rotation),
927
+ * checks that the token has not expired, and validates all required claims.
928
+ * Returns null (rather than throwing) for invalid or expired tokens, so callers
929
+ * can handle authentication failure without try/catch.
1112
930
  *
1113
- * @param accessToken - The JWT access token (without "Bearer " prefix)
1114
- * @param deviceId - The device this challenge is intended for
1115
- * @returns Auth response with the challenge string, or an error
931
+ * @param token - The JWT string to validate
932
+ * @returns The decoded {@link TokenPayload} if valid, or null if the token is
933
+ * invalid, expired, or missing required claims
1116
934
  */
1117
- async handleDeviceChallenge(accessToken, deviceId) {
1118
- const payload = this.tokenManager.validateToken(accessToken);
1119
- if (payload === null || payload.type !== "access") {
1120
- return {
1121
- status: 401,
1122
- body: { error: "Invalid or expired access token." }
1123
- };
935
+ validateToken(token) {
936
+ let decoded = null;
937
+ for (const secret of this.secrets) {
938
+ decoded = verifyJwt(token, secret);
939
+ if (decoded !== null) {
940
+ break;
941
+ }
1124
942
  }
1125
- const device = await this.userStore.findDevice(deviceId);
1126
- if (device === null || device.userId !== payload.sub) {
1127
- return {
1128
- status: 404,
1129
- body: { error: "Device not found." }
1130
- };
943
+ if (decoded === null) {
944
+ return null;
1131
945
  }
1132
- if (device.revoked) {
1133
- return {
1134
- status: 403,
1135
- body: { error: "Device has been revoked." }
1136
- };
946
+ if (isExpired(decoded)) {
947
+ return null;
948
+ }
949
+ 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") {
950
+ return null;
951
+ }
952
+ const type = decoded.type;
953
+ if (type !== "access" && type !== "refresh" && type !== "device_credential") {
954
+ return null;
1137
955
  }
1138
- const challenge = randomBytes2(32).toString("hex");
1139
- const expiresAt = Date.now() + CHALLENGE_TTL_MS;
1140
- await this.challengeStore.store(challenge, deviceId, expiresAt);
1141
956
  return {
1142
- status: 200,
1143
- body: { data: { challenge } }
957
+ jti: decoded.jti,
958
+ sub: decoded.sub,
959
+ dev: decoded.dev,
960
+ type,
961
+ iat: decoded.iat,
962
+ exp: decoded.exp
1144
963
  };
1145
964
  }
1146
965
  /**
1147
- * Handle device proof-of-possession verification (POST /auth/device/verify).
1148
- *
1149
- * Verifies that the device holds the private key corresponding to its registered
1150
- * public key by checking a signed challenge. The challenge must have been previously
1151
- * issued via {@link handleDeviceChallenge} and is single-use.
966
+ * Validate a token and check it against the revocation store.
1152
967
  *
1153
- * On success, issues fresh tokens for the device.
968
+ * Like {@link validateToken}, but also checks whether the token's `jti` has been
969
+ * revoked. Requires a revocation store to be configured.
1154
970
  *
1155
- * @param body - Device verification request body
1156
- * @param body.deviceId - The ID of the device to verify
1157
- * @param body.challenge - The challenge string (from handleDeviceChallenge)
1158
- * @param body.signature - The base64url-encoded ECDSA signature of the challenge
1159
- * @returns Auth response with fresh tokens on success, or an error
971
+ * @param token - The JWT string to validate
972
+ * @returns The decoded {@link TokenPayload} if valid and not revoked, or null otherwise
1160
973
  */
1161
- async handleDeviceVerify(body) {
1162
- const challengeEntry = await this.challengeStore.consume(body.challenge);
1163
- if (challengeEntry === null) {
1164
- return {
1165
- status: 401,
1166
- body: { error: "Invalid or expired challenge. Request a new challenge and try again." }
1167
- };
1168
- }
1169
- if (challengeEntry.deviceId !== body.deviceId) {
1170
- return {
1171
- status: 401,
1172
- body: { error: "Challenge was not issued for this device." }
1173
- };
1174
- }
1175
- const device = await this.userStore.findDevice(body.deviceId);
1176
- if (device === null) {
1177
- return {
1178
- status: 404,
1179
- body: { error: "Device not found." }
1180
- };
1181
- }
1182
- if (device.revoked) {
1183
- return {
1184
- status: 403,
1185
- body: { error: "Device has been revoked and cannot authenticate." }
1186
- };
1187
- }
1188
- let publicKeyJwk;
1189
- try {
1190
- publicKeyJwk = JSON.parse(device.publicKey);
1191
- } catch {
1192
- return {
1193
- status: 500,
1194
- body: { error: "Device has an invalid stored public key." }
1195
- };
1196
- }
1197
- let isValid;
1198
- try {
1199
- isValid = await verifyChallenge(publicKeyJwk, body.challenge, body.signature);
1200
- } catch {
1201
- return {
1202
- status: 400,
1203
- body: { error: "Signature verification failed. The signature or public key format may be invalid." }
1204
- };
1205
- }
1206
- if (!isValid) {
1207
- return {
1208
- status: 401,
1209
- body: { error: "Invalid signature. Proof-of-possession verification failed." }
1210
- };
974
+ async validateTokenWithRevocation(token) {
975
+ const payload = this.validateToken(token);
976
+ if (payload === null) {
977
+ return null;
1211
978
  }
1212
- let thumbprint;
1213
- try {
1214
- thumbprint = await computePublicKeyThumbprint(publicKeyJwk);
1215
- } catch {
1216
- return {
1217
- status: 500,
1218
- body: { error: "Failed to compute public key thumbprint." }
1219
- };
979
+ if (this.revocationStore) {
980
+ const revoked = await this.revocationStore.isRevoked(payload.jti);
981
+ if (revoked) {
982
+ return null;
983
+ }
1220
984
  }
1221
- const tokens = this.tokenManager.issueTokens(device.userId, device.id, thumbprint);
1222
- return {
1223
- status: 200,
1224
- body: { data: { tokens } }
1225
- };
985
+ return payload;
1226
986
  }
1227
987
  /**
1228
- * Generates a random challenge string for proof-of-possession verification.
988
+ * Revoke a specific token by its JWT ID.
1229
989
  *
1230
- * **Deprecated:** Use {@link handleDeviceChallenge} instead, which stores
1231
- * the challenge server-side with expiry and single-use semantics.
990
+ * Requires a revocation store to be configured. After revocation, the token
991
+ * will be rejected by {@link validateTokenWithRevocation}.
1232
992
  *
1233
- * @returns A 64-character hex string (32 random bytes)
993
+ * @param jti - The JWT ID of the token to revoke
994
+ * @param expiresAt - The token's expiration time (seconds since epoch)
1234
995
  */
1235
- static generateChallenge() {
1236
- return randomBytes2(32).toString("hex");
996
+ async revokeToken(jti, expiresAt) {
997
+ if (this.revocationStore) {
998
+ await this.revocationStore.revoke(jti, expiresAt);
999
+ }
1237
1000
  }
1238
1001
  /**
1239
- * Creates a sync server auth provider compatible with `@korajs/server`.
1002
+ * Revoke all tokens for a specific device.
1240
1003
  *
1241
- * The returned object implements the `AuthProvider` interface from
1242
- * `@korajs/server`, validating access tokens and returning an auth
1243
- * context containing the user ID and device metadata. This bridges
1244
- * the built-in auth system with the sync server's authentication layer.
1004
+ * Called when a device is revoked to ensure all its existing tokens
1005
+ * (access, refresh, and device credentials) are invalidated.
1245
1006
  *
1246
- * Also checks device revocation status during authentication, ensuring
1247
- * that revoked devices are rejected even if their tokens haven't expired.
1007
+ * @param deviceId - The device ID whose tokens should be revoked
1008
+ */
1009
+ async revokeDeviceTokens(deviceId) {
1010
+ if (this.revocationStore) {
1011
+ await this.revocationStore.revokeAllForDevice(deviceId);
1012
+ }
1013
+ }
1014
+ /**
1015
+ * Refresh an access token using a valid refresh token.
1248
1016
  *
1249
- * @returns An object with an `authenticate` method suitable for KoraSyncServer's `auth` config
1017
+ * Implements **refresh token rotation with reuse detection**: a new refresh token
1018
+ * is issued alongside the new access token. The old refresh token's `jti` is
1019
+ * recorded in the revocation store (if configured). If a previously consumed
1020
+ * refresh token is presented again, it indicates potential token theft.
1021
+ *
1022
+ * Returns null if the provided token is invalid, expired, or not a refresh token.
1250
1023
  *
1251
- * @example
1252
- * ```typescript
1253
- * const routes = new BuiltInAuthRoutes({ userStore, tokenManager })
1254
- * const syncServer = new KoraSyncServer({
1255
- * store,
1256
- * auth: routes.toSyncAuthProvider(),
1257
- * })
1258
- * ```
1024
+ * @param refreshToken - The refresh token JWT string
1025
+ * @returns A new access/refresh token pair, or null if the refresh token is invalid
1259
1026
  */
1260
- toSyncAuthProvider() {
1261
- const tokenManager = this.tokenManager;
1262
- const userStore = this.userStore;
1263
- return {
1264
- async authenticate(token) {
1265
- const payload = tokenManager.validateToken(token);
1266
- if (payload === null || payload.type !== "access") {
1267
- return null;
1268
- }
1269
- const user = await userStore.findById(payload.sub);
1270
- if (user === null) {
1271
- return null;
1272
- }
1273
- const device = await userStore.findDevice(payload.dev);
1274
- if (device !== null && device.revoked) {
1275
- return null;
1276
- }
1277
- await userStore.touchDevice(payload.dev);
1278
- return {
1279
- userId: payload.sub,
1280
- metadata: {
1281
- deviceId: payload.dev,
1282
- email: user.email,
1283
- name: user.name
1284
- }
1285
- };
1027
+ async refreshAccessToken(refreshToken) {
1028
+ const payload = this.validateToken(refreshToken);
1029
+ if (payload === null) {
1030
+ return null;
1031
+ }
1032
+ if (payload.type !== "refresh") {
1033
+ return null;
1034
+ }
1035
+ if (this.revocationStore) {
1036
+ const wasRevoked = await this.revocationStore.isRevoked(payload.jti);
1037
+ if (wasRevoked) {
1038
+ await this.revocationStore.revokeAllForDevice(payload.dev);
1039
+ return null;
1286
1040
  }
1041
+ await this.revocationStore.revoke(payload.jti, payload.exp);
1042
+ }
1043
+ return {
1044
+ accessToken: this.issueAccessToken(payload.sub, payload.dev),
1045
+ refreshToken: this.issueRefreshToken(payload.sub, payload.dev)
1287
1046
  };
1288
1047
  }
1289
1048
  };
1290
1049
 
1291
1050
  // src/provider/built-in/password-reset.ts
1292
- import { KoraError as KoraError3 } from "@korajs/core";
1293
- var PasswordResetError = class extends KoraError3 {
1051
+ import { KoraError as KoraError2 } from "@korajs/core";
1052
+ var PasswordResetError = class extends KoraError2 {
1294
1053
  constructor(message, code, context) {
1295
1054
  super(message, code, context);
1296
1055
  this.name = "PasswordResetError";
@@ -1375,7 +1134,11 @@ var PasswordResetManager = class {
1375
1134
  const normalizedEmail = email.toLowerCase().trim();
1376
1135
  const successResponse = {
1377
1136
  status: 200,
1378
- body: { data: { message: "If an account with that email exists, a password reset link has been sent." } }
1137
+ body: {
1138
+ data: {
1139
+ message: "If an account with that email exists, a password reset link has been sent."
1140
+ }
1141
+ }
1379
1142
  };
1380
1143
  const user = await this.userStore.findByEmail(normalizedEmail);
1381
1144
  if (!user) {
@@ -1456,7 +1219,7 @@ var PasswordResetManager = class {
1456
1219
  if (!user) {
1457
1220
  return { status: 404, body: { error: "User not found." } };
1458
1221
  }
1459
- const { verifyPassword: verifyPassword2 } = await import("./password-hash-HDH6VQCQ.js");
1222
+ const { verifyPassword: verifyPassword2 } = await import("./password-hash-QRBG6BNJ.js");
1460
1223
  const isValid = await verifyPassword2(currentPassword, user.passwordHash, user.salt);
1461
1224
  if (!isValid) {
1462
1225
  return { status: 401, body: { error: "Current password is incorrect." } };
@@ -1477,8 +1240,8 @@ function generateSecureToken() {
1477
1240
  }
1478
1241
 
1479
1242
  // src/provider/built-in/email-verification.ts
1480
- import { KoraError as KoraError4 } from "@korajs/core";
1481
- var EmailVerificationError = class extends KoraError4 {
1243
+ import { KoraError as KoraError3 } from "@korajs/core";
1244
+ var EmailVerificationError = class extends KoraError3 {
1482
1245
  constructor(message, code, context) {
1483
1246
  super(message, code, context);
1484
1247
  this.name = "EmailVerificationError";
@@ -1527,104 +1290,343 @@ var InMemoryEmailVerificationStore = class {
1527
1290
  count++;
1528
1291
  }
1529
1292
  }
1530
- return count;
1293
+ return count;
1294
+ }
1295
+ };
1296
+ var DEFAULT_TOKEN_TTL_MS2 = 24 * 60 * 60 * 1e3;
1297
+ var DEFAULT_MAX_REQUESTS2 = 3;
1298
+ var EmailVerificationManager = class {
1299
+ userStore;
1300
+ verificationStore;
1301
+ tokenTtlMs;
1302
+ maxRequestsPerUser;
1303
+ onVerificationRequired;
1304
+ constructor(config) {
1305
+ this.userStore = config.userStore;
1306
+ this.verificationStore = config.verificationStore ?? new InMemoryEmailVerificationStore();
1307
+ this.tokenTtlMs = config.tokenTtlMs ?? DEFAULT_TOKEN_TTL_MS2;
1308
+ this.maxRequestsPerUser = config.maxRequestsPerUser ?? DEFAULT_MAX_REQUESTS2;
1309
+ this.onVerificationRequired = config.onVerificationRequired;
1310
+ }
1311
+ /**
1312
+ * Send a verification email for a user.
1313
+ * Rate-limited to prevent abuse.
1314
+ */
1315
+ async sendVerification(userId, email) {
1316
+ const normalizedEmail = email.toLowerCase().trim();
1317
+ const activeCount = await this.verificationStore.countActiveForUser(userId);
1318
+ if (activeCount >= this.maxRequestsPerUser) {
1319
+ return {
1320
+ status: 429,
1321
+ body: { error: "Too many verification requests. Please try again later." }
1322
+ };
1323
+ }
1324
+ const token = generateSecureToken2();
1325
+ const now = Date.now();
1326
+ const verificationToken = {
1327
+ token,
1328
+ userId,
1329
+ email: normalizedEmail,
1330
+ createdAt: now,
1331
+ expiresAt: now + this.tokenTtlMs,
1332
+ consumed: false
1333
+ };
1334
+ await this.verificationStore.store(verificationToken);
1335
+ if (this.onVerificationRequired) {
1336
+ try {
1337
+ await this.onVerificationRequired(normalizedEmail, token, verificationToken.expiresAt);
1338
+ } catch {
1339
+ }
1340
+ }
1341
+ const responseData = {
1342
+ message: "Verification email sent."
1343
+ };
1344
+ if (!this.onVerificationRequired) {
1345
+ responseData.token = token;
1346
+ }
1347
+ return { status: 200, body: { data: responseData } };
1348
+ }
1349
+ /**
1350
+ * Verify an email using a verification token.
1351
+ */
1352
+ async verifyEmail(token) {
1353
+ const verificationToken = await this.verificationStore.get(token);
1354
+ if (!verificationToken || verificationToken.consumed) {
1355
+ return { status: 404, body: { error: "Verification token not found or already used." } };
1356
+ }
1357
+ if (Date.now() > verificationToken.expiresAt) {
1358
+ await this.verificationStore.consume(token);
1359
+ return { status: 410, body: { error: "Verification token has expired." } };
1360
+ }
1361
+ await this.verificationStore.consume(token);
1362
+ await this.userStore.setEmailVerified(verificationToken.userId, true);
1363
+ return {
1364
+ status: 200,
1365
+ body: {
1366
+ data: {
1367
+ message: "Email verified successfully.",
1368
+ userId: verificationToken.userId,
1369
+ email: verificationToken.email
1370
+ }
1371
+ }
1372
+ };
1373
+ }
1374
+ /**
1375
+ * Resend verification email for a user.
1376
+ * Delegates to sendVerification with rate limiting.
1377
+ */
1378
+ async resendVerification(userId) {
1379
+ const user = await this.userStore.findById(userId);
1380
+ if (!user) {
1381
+ return { status: 404, body: { error: "User not found." } };
1382
+ }
1383
+ return this.sendVerification(userId, user.email);
1384
+ }
1385
+ };
1386
+ function generateSecureToken2() {
1387
+ const bytes = new Uint8Array(32);
1388
+ globalThis.crypto.getRandomValues(bytes);
1389
+ let binary = "";
1390
+ for (let i = 0; i < bytes.length; i++) {
1391
+ binary += String.fromCharCode(bytes[i]);
1392
+ }
1393
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1394
+ }
1395
+
1396
+ // src/provider/built-in/user-store.ts
1397
+ import { randomUUID as randomUUID2 } from "crypto";
1398
+ import { KoraError as KoraError4 } from "@korajs/core";
1399
+ var DuplicateEmailError = class extends KoraError4 {
1400
+ constructor() {
1401
+ super("A user with this email already exists.", "DUPLICATE_EMAIL");
1402
+ this.name = "DuplicateEmailError";
1403
+ }
1404
+ };
1405
+ var InMemoryUserStore = class {
1406
+ /** Users indexed by ID */
1407
+ usersById = /* @__PURE__ */ new Map();
1408
+ /** Users indexed by email (lowercase) for fast lookup */
1409
+ usersByEmail = /* @__PURE__ */ new Map();
1410
+ /** Devices indexed by device ID */
1411
+ devicesById = /* @__PURE__ */ new Map();
1412
+ /** Device IDs indexed by user ID for fast listing */
1413
+ devicesByUserId = /* @__PURE__ */ new Map();
1414
+ /**
1415
+ * Create a new user account.
1416
+ *
1417
+ * @param params - User creation parameters
1418
+ * @param params.email - The user's email address (must be unique, case-insensitive)
1419
+ * @param params.passwordHash - Hex-encoded PBKDF2 derived key
1420
+ * @param params.salt - Hex-encoded salt used during hashing
1421
+ * @param params.name - The user's display name
1422
+ * @returns The created user (without sensitive credential fields)
1423
+ * @throws {DuplicateEmailError} If a user with the same email already exists
1424
+ */
1425
+ async createUser(params) {
1426
+ const normalizedEmail = params.email.toLowerCase();
1427
+ if (this.usersByEmail.has(normalizedEmail)) {
1428
+ throw new DuplicateEmailError();
1429
+ }
1430
+ const now = Date.now();
1431
+ const id = randomUUID2();
1432
+ const storedUser = {
1433
+ id,
1434
+ email: normalizedEmail,
1435
+ name: params.name,
1436
+ emailVerified: false,
1437
+ createdAt: now,
1438
+ passwordHash: params.passwordHash,
1439
+ salt: params.salt
1440
+ };
1441
+ this.usersById.set(id, storedUser);
1442
+ this.usersByEmail.set(normalizedEmail, storedUser);
1443
+ return toAuthUser(storedUser);
1444
+ }
1445
+ /**
1446
+ * Find a user by email address.
1447
+ *
1448
+ * @param email - The email to search for (case-insensitive)
1449
+ * @returns The stored user record including credentials, or null if not found
1450
+ */
1451
+ async findByEmail(email) {
1452
+ return this.usersByEmail.get(email.toLowerCase()) ?? null;
1453
+ }
1454
+ /**
1455
+ * Find a user by ID.
1456
+ *
1457
+ * @param id - The user ID to search for
1458
+ * @returns The stored user record including credentials, or null if not found
1459
+ */
1460
+ async findById(id) {
1461
+ return this.usersById.get(id) ?? null;
1462
+ }
1463
+ /**
1464
+ * Register a device for a user.
1465
+ *
1466
+ * If a device with the same ID already exists and is not revoked, it is
1467
+ * returned as-is (idempotent registration). If it was previously revoked,
1468
+ * it is re-activated with updated details.
1469
+ *
1470
+ * @param params - Device registration parameters
1471
+ * @param params.id - Unique device identifier
1472
+ * @param params.userId - ID of the user who owns the device
1473
+ * @param params.publicKey - Base64url-encoded device public key or thumbprint
1474
+ * @param params.name - Human-readable device name
1475
+ * @returns The registered device record
1476
+ */
1477
+ async registerDevice(params) {
1478
+ const existing = this.devicesById.get(params.id);
1479
+ if (existing !== void 0 && !existing.revoked) {
1480
+ return existing;
1481
+ }
1482
+ const now = Date.now();
1483
+ const device = {
1484
+ id: params.id,
1485
+ userId: params.userId,
1486
+ publicKey: params.publicKey,
1487
+ name: params.name,
1488
+ revoked: false,
1489
+ createdAt: now,
1490
+ lastSeenAt: now
1491
+ };
1492
+ this.devicesById.set(params.id, device);
1493
+ let userDevices = this.devicesByUserId.get(params.userId);
1494
+ if (userDevices === void 0) {
1495
+ userDevices = /* @__PURE__ */ new Set();
1496
+ this.devicesByUserId.set(params.userId, userDevices);
1497
+ }
1498
+ userDevices.add(params.id);
1499
+ return device;
1531
1500
  }
1532
- };
1533
- var DEFAULT_TOKEN_TTL_MS2 = 24 * 60 * 60 * 1e3;
1534
- var DEFAULT_MAX_REQUESTS2 = 3;
1535
- var EmailVerificationManager = class {
1536
- userStore;
1537
- verificationStore;
1538
- tokenTtlMs;
1539
- maxRequestsPerUser;
1540
- onVerificationRequired;
1541
- constructor(config) {
1542
- this.userStore = config.userStore;
1543
- this.verificationStore = config.verificationStore ?? new InMemoryEmailVerificationStore();
1544
- this.tokenTtlMs = config.tokenTtlMs ?? DEFAULT_TOKEN_TTL_MS2;
1545
- this.maxRequestsPerUser = config.maxRequestsPerUser ?? DEFAULT_MAX_REQUESTS2;
1546
- this.onVerificationRequired = config.onVerificationRequired;
1501
+ /**
1502
+ * Find a device by its ID.
1503
+ *
1504
+ * @param deviceId - The device ID to search for
1505
+ * @returns The device record, or null if not found
1506
+ */
1507
+ async findDevice(deviceId) {
1508
+ return this.devicesById.get(deviceId) ?? null;
1547
1509
  }
1548
1510
  /**
1549
- * Send a verification email for a user.
1550
- * Rate-limited to prevent abuse.
1511
+ * List all devices registered for a user.
1512
+ *
1513
+ * @param userId - The user ID whose devices to list
1514
+ * @returns Array of device records (includes revoked devices)
1551
1515
  */
1552
- async sendVerification(userId, email) {
1553
- const normalizedEmail = email.toLowerCase().trim();
1554
- const activeCount = await this.verificationStore.countActiveForUser(userId);
1555
- if (activeCount >= this.maxRequestsPerUser) {
1556
- return { status: 429, body: { error: "Too many verification requests. Please try again later." } };
1516
+ async listDevices(userId) {
1517
+ const deviceIds = this.devicesByUserId.get(userId);
1518
+ if (deviceIds === void 0) {
1519
+ return [];
1557
1520
  }
1558
- const token = generateSecureToken2();
1559
- const now = Date.now();
1560
- const verificationToken = {
1561
- token,
1562
- userId,
1563
- email: normalizedEmail,
1564
- createdAt: now,
1565
- expiresAt: now + this.tokenTtlMs,
1566
- consumed: false
1567
- };
1568
- await this.verificationStore.store(verificationToken);
1569
- if (this.onVerificationRequired) {
1570
- try {
1571
- await this.onVerificationRequired(normalizedEmail, token, verificationToken.expiresAt);
1572
- } catch {
1521
+ const devices = [];
1522
+ for (const deviceId of deviceIds) {
1523
+ const device = this.devicesById.get(deviceId);
1524
+ if (device !== void 0) {
1525
+ devices.push(device);
1573
1526
  }
1574
1527
  }
1575
- const responseData = {
1576
- message: "Verification email sent."
1577
- };
1578
- if (!this.onVerificationRequired) {
1579
- responseData.token = token;
1580
- }
1581
- return { status: 200, body: { data: responseData } };
1528
+ return devices;
1582
1529
  }
1583
1530
  /**
1584
- * Verify an email using a verification token.
1531
+ * Revoke a device, preventing it from being used for authentication.
1532
+ *
1533
+ * This is a soft revoke — the device record remains but is marked as revoked.
1534
+ * If the device does not exist, this is a no-op.
1535
+ *
1536
+ * @param deviceId - The ID of the device to revoke
1585
1537
  */
1586
- async verifyEmail(token) {
1587
- const verificationToken = await this.verificationStore.get(token);
1588
- if (!verificationToken || verificationToken.consumed) {
1589
- return { status: 404, body: { error: "Verification token not found or already used." } };
1538
+ async revokeDevice(deviceId) {
1539
+ const device = this.devicesById.get(deviceId);
1540
+ if (device !== void 0) {
1541
+ device.revoked = true;
1590
1542
  }
1591
- if (Date.now() > verificationToken.expiresAt) {
1592
- await this.verificationStore.consume(token);
1593
- return { status: 410, body: { error: "Verification token has expired." } };
1543
+ }
1544
+ /**
1545
+ * Set a user's email verification status.
1546
+ *
1547
+ * @param userId - The user whose email to verify
1548
+ * @param verified - Whether the email is verified
1549
+ */
1550
+ async setEmailVerified(userId, verified) {
1551
+ const user = this.usersById.get(userId);
1552
+ if (!user) return;
1553
+ const updated = { ...user, emailVerified: verified };
1554
+ this.usersById.set(userId, updated);
1555
+ this.usersByEmail.set(user.email, updated);
1556
+ }
1557
+ /**
1558
+ * Update a user's password hash and salt.
1559
+ *
1560
+ * @param userId - The user whose password to update
1561
+ * @param passwordHash - New hex-encoded PBKDF2 derived key
1562
+ * @param salt - New hex-encoded salt
1563
+ */
1564
+ async updatePassword(userId, passwordHash, salt) {
1565
+ const user = this.usersById.get(userId);
1566
+ if (!user) return;
1567
+ const updated = { ...user, passwordHash, salt };
1568
+ this.usersById.set(userId, updated);
1569
+ this.usersByEmail.set(user.email, updated);
1570
+ }
1571
+ /**
1572
+ * List all users. For admin/development use.
1573
+ */
1574
+ async listAll() {
1575
+ return [...this.usersById.values()];
1576
+ }
1577
+ /**
1578
+ * Update a stored user record.
1579
+ */
1580
+ async update(user) {
1581
+ const existing = this.usersById.get(user.id);
1582
+ if (!existing) return;
1583
+ if (existing.email !== user.email) {
1584
+ this.usersByEmail.delete(existing.email);
1585
+ this.usersByEmail.set(user.email, user);
1586
+ } else {
1587
+ this.usersByEmail.set(user.email, user);
1594
1588
  }
1595
- await this.verificationStore.consume(token);
1596
- await this.userStore.setEmailVerified(verificationToken.userId, true);
1597
- return {
1598
- status: 200,
1599
- body: {
1600
- data: {
1601
- message: "Email verified successfully.",
1602
- userId: verificationToken.userId,
1603
- email: verificationToken.email
1604
- }
1589
+ this.usersById.set(user.id, user);
1590
+ }
1591
+ /**
1592
+ * Delete a user and all associated devices.
1593
+ */
1594
+ async delete(userId) {
1595
+ const user = this.usersById.get(userId);
1596
+ if (!user) return;
1597
+ this.usersById.delete(userId);
1598
+ this.usersByEmail.delete(user.email);
1599
+ const deviceIds = this.devicesByUserId.get(userId);
1600
+ if (deviceIds) {
1601
+ for (const deviceId of deviceIds) {
1602
+ this.devicesById.delete(deviceId);
1605
1603
  }
1606
- };
1604
+ this.devicesByUserId.delete(userId);
1605
+ }
1607
1606
  }
1608
1607
  /**
1609
- * Resend verification email for a user.
1610
- * Delegates to sendVerification with rate limiting.
1608
+ * Update the last-seen timestamp for a device.
1609
+ *
1610
+ * Called when a device authenticates or syncs to track activity.
1611
+ * If the device does not exist, this is a no-op.
1612
+ *
1613
+ * @param deviceId - The ID of the device to update
1611
1614
  */
1612
- async resendVerification(userId) {
1613
- const user = await this.userStore.findById(userId);
1614
- if (!user) {
1615
- return { status: 404, body: { error: "User not found." } };
1615
+ async touchDevice(deviceId) {
1616
+ const device = this.devicesById.get(deviceId);
1617
+ if (device !== void 0) {
1618
+ device.lastSeenAt = Date.now();
1616
1619
  }
1617
- return this.sendVerification(userId, user.email);
1618
1620
  }
1619
1621
  };
1620
- function generateSecureToken2() {
1621
- const bytes = new Uint8Array(32);
1622
- globalThis.crypto.getRandomValues(bytes);
1623
- let binary = "";
1624
- for (let i = 0; i < bytes.length; i++) {
1625
- binary += String.fromCharCode(bytes[i]);
1626
- }
1627
- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1622
+ function toAuthUser(stored) {
1623
+ return {
1624
+ id: stored.id,
1625
+ email: stored.email,
1626
+ name: stored.name,
1627
+ emailVerified: stored.emailVerified,
1628
+ createdAt: stored.createdAt
1629
+ };
1628
1630
  }
1629
1631
 
1630
1632
  // src/provider/built-in/sqlite-user-store.ts
@@ -1680,21 +1682,15 @@ var SqliteUserStore = class {
1680
1682
  return { id, email: normalizedEmail, name: params.name, emailVerified: false, createdAt: now };
1681
1683
  }
1682
1684
  async findByEmail(email) {
1683
- const row = this.db.prepare(
1684
- "SELECT * FROM auth_users WHERE email = ?"
1685
- ).get(email.toLowerCase());
1685
+ const row = this.db.prepare("SELECT * FROM auth_users WHERE email = ?").get(email.toLowerCase());
1686
1686
  return row ? rowToStoredUser(row) : null;
1687
1687
  }
1688
1688
  async findById(id) {
1689
- const row = this.db.prepare(
1690
- "SELECT * FROM auth_users WHERE id = ?"
1691
- ).get(id);
1689
+ const row = this.db.prepare("SELECT * FROM auth_users WHERE id = ?").get(id);
1692
1690
  return row ? rowToStoredUser(row) : null;
1693
1691
  }
1694
1692
  async registerDevice(params) {
1695
- const existing = this.db.prepare(
1696
- "SELECT * FROM auth_devices WHERE id = ?"
1697
- ).get(params.id);
1693
+ const existing = this.db.prepare("SELECT * FROM auth_devices WHERE id = ?").get(params.id);
1698
1694
  if (existing && !existing.revoked) {
1699
1695
  return rowToDevice(existing);
1700
1696
  }
@@ -1721,29 +1717,21 @@ var SqliteUserStore = class {
1721
1717
  };
1722
1718
  }
1723
1719
  async findDevice(deviceId) {
1724
- const row = this.db.prepare(
1725
- "SELECT * FROM auth_devices WHERE id = ?"
1726
- ).get(deviceId);
1720
+ const row = this.db.prepare("SELECT * FROM auth_devices WHERE id = ?").get(deviceId);
1727
1721
  return row ? rowToDevice(row) : null;
1728
1722
  }
1729
1723
  async listDevices(userId) {
1730
- const rows = this.db.prepare(
1731
- "SELECT * FROM auth_devices WHERE user_id = ?"
1732
- ).all(userId);
1724
+ const rows = this.db.prepare("SELECT * FROM auth_devices WHERE user_id = ?").all(userId);
1733
1725
  return rows.map(rowToDevice);
1734
1726
  }
1735
1727
  async revokeDevice(deviceId) {
1736
1728
  this.db.prepare("UPDATE auth_devices SET revoked = 1 WHERE id = ?").run(deviceId);
1737
1729
  }
1738
1730
  async setEmailVerified(userId, verified) {
1739
- this.db.prepare(
1740
- "UPDATE auth_users SET email_verified = ? WHERE id = ?"
1741
- ).run(verified ? 1 : 0, userId);
1731
+ this.db.prepare("UPDATE auth_users SET email_verified = ? WHERE id = ?").run(verified ? 1 : 0, userId);
1742
1732
  }
1743
1733
  async updatePassword(userId, passwordHash, salt) {
1744
- this.db.prepare(
1745
- "UPDATE auth_users SET password_hash = ?, salt = ? WHERE id = ?"
1746
- ).run(passwordHash, salt, userId);
1734
+ this.db.prepare("UPDATE auth_users SET password_hash = ?, salt = ? WHERE id = ?").run(passwordHash, salt, userId);
1747
1735
  }
1748
1736
  async listAll() {
1749
1737
  const rows = this.db.prepare("SELECT * FROM auth_users").all();
@@ -1764,9 +1752,7 @@ var SqliteUserStore = class {
1764
1752
  deleteInTransaction();
1765
1753
  }
1766
1754
  async touchDevice(deviceId) {
1767
- this.db.prepare(
1768
- "UPDATE auth_devices SET last_seen_at = ? WHERE id = ?"
1769
- ).run(Date.now(), deviceId);
1755
+ this.db.prepare("UPDATE auth_devices SET last_seen_at = ? WHERE id = ?").run(Date.now(), deviceId);
1770
1756
  }
1771
1757
  };
1772
1758
  async function createSqliteUserStore(options) {
@@ -1884,7 +1870,7 @@ var PostgresUserStore = class {
1884
1870
  const existingRows = await this.sql`
1885
1871
  SELECT * FROM auth_devices WHERE id = ${params.id}
1886
1872
  `;
1887
- if (existingRows.length > 0 && !existingRows[0].revoked) {
1873
+ if (existingRows.length > 0 && !existingRows[0]?.revoked) {
1888
1874
  return rowToDevice2(existingRows[0]);
1889
1875
  }
1890
1876
  const now = Date.now();
@@ -1905,7 +1891,7 @@ var PostgresUserStore = class {
1905
1891
  publicKey: params.publicKey,
1906
1892
  name: params.name,
1907
1893
  revoked: false,
1908
- createdAt: existingRows.length > 0 ? Number(existingRows[0].created_at) : now,
1894
+ createdAt: existingRows.length > 0 ? Number(existingRows[0]?.created_at) : now,
1909
1895
  lastSeenAt: now
1910
1896
  };
1911
1897
  }
@@ -2100,16 +2086,12 @@ var ExternalAuthOperationNotSupportedError = class extends KoraError5 {
2100
2086
  };
2101
2087
  var ExternalTokenValidationError = class extends KoraError5 {
2102
2088
  constructor(reason, context) {
2103
- super(
2104
- `External token validation failed: ${reason}`,
2105
- "AUTH_EXTERNAL_TOKEN_INVALID",
2106
- context
2107
- );
2089
+ super(`External token validation failed: ${reason}`, "AUTH_EXTERNAL_TOKEN_INVALID", context);
2108
2090
  this.name = "ExternalTokenValidationError";
2109
2091
  }
2110
2092
  };
2111
2093
  function defaultMapClaims(claims) {
2112
- const sub = claims["sub"];
2094
+ const sub = claims.sub;
2113
2095
  if (typeof sub !== "string" || sub.length === 0) {
2114
2096
  throw new ExternalTokenValidationError(
2115
2097
  'JWT is missing a valid "sub" (subject) claim. The "sub" claim must be a non-empty string identifying the user.',
@@ -2118,8 +2100,8 @@ function defaultMapClaims(claims) {
2118
2100
  }
2119
2101
  return {
2120
2102
  userId: sub,
2121
- email: typeof claims["email"] === "string" ? claims["email"] : void 0,
2122
- name: typeof claims["name"] === "string" ? claims["name"] : void 0,
2103
+ email: typeof claims.email === "string" ? claims.email : void 0,
2104
+ name: typeof claims.name === "string" ? claims.name : void 0,
2123
2105
  metadata: void 0
2124
2106
  };
2125
2107
  }
@@ -2318,23 +2300,23 @@ var ExternalJwtProvider = class {
2318
2300
 
2319
2301
  // src/provider/external/clerk-adapter.ts
2320
2302
  function defaultClerkClaimMapping(claims) {
2321
- const sub = claims["sub"];
2303
+ const sub = claims.sub;
2322
2304
  if (typeof sub !== "string" || sub.length === 0) {
2323
2305
  return { userId: "" };
2324
2306
  }
2325
- const firstName = typeof claims["first_name"] === "string" ? claims["first_name"] : "";
2326
- const lastName = typeof claims["last_name"] === "string" ? claims["last_name"] : "";
2307
+ const firstName = typeof claims.first_name === "string" ? claims.first_name : "";
2308
+ const lastName = typeof claims.last_name === "string" ? claims.last_name : "";
2327
2309
  const fullName = [firstName, lastName].filter(Boolean).join(" ");
2328
- const email = typeof claims["email"] === "string" ? claims["email"] : void 0;
2310
+ const email = typeof claims.email === "string" ? claims.email : void 0;
2329
2311
  const metadata = {};
2330
- if (typeof claims["org_id"] === "string") {
2331
- metadata["orgId"] = claims["org_id"];
2312
+ if (typeof claims.org_id === "string") {
2313
+ metadata.orgId = claims.org_id;
2332
2314
  }
2333
- if (typeof claims["org_slug"] === "string") {
2334
- metadata["orgSlug"] = claims["org_slug"];
2315
+ if (typeof claims.org_slug === "string") {
2316
+ metadata.orgSlug = claims.org_slug;
2335
2317
  }
2336
- if (typeof claims["org_role"] === "string") {
2337
- metadata["orgRole"] = claims["org_role"];
2318
+ if (typeof claims.org_role === "string") {
2319
+ metadata.orgRole = claims.org_role;
2338
2320
  }
2339
2321
  return {
2340
2322
  userId: sub,
@@ -2353,30 +2335,30 @@ function createClerkAdapter(config) {
2353
2335
 
2354
2336
  // src/provider/external/supabase-adapter.ts
2355
2337
  function defaultSupabaseClaimMapping(claims) {
2356
- const sub = claims["sub"];
2338
+ const sub = claims.sub;
2357
2339
  if (typeof sub !== "string" || sub.length === 0) {
2358
2340
  return { userId: "" };
2359
2341
  }
2360
- const email = typeof claims["email"] === "string" ? claims["email"] : void 0;
2342
+ const email = typeof claims.email === "string" ? claims.email : void 0;
2361
2343
  let name;
2362
- const userMetadata = claims["user_metadata"];
2344
+ const userMetadata = claims.user_metadata;
2363
2345
  if (typeof userMetadata === "object" && userMetadata !== null && !Array.isArray(userMetadata)) {
2364
2346
  const meta = userMetadata;
2365
- if (typeof meta["full_name"] === "string" && meta["full_name"].length > 0) {
2366
- name = meta["full_name"];
2367
- } else if (typeof meta["name"] === "string" && meta["name"].length > 0) {
2368
- name = meta["name"];
2347
+ if (typeof meta.full_name === "string" && meta.full_name.length > 0) {
2348
+ name = meta.full_name;
2349
+ } else if (typeof meta.name === "string" && meta.name.length > 0) {
2350
+ name = meta.name;
2369
2351
  }
2370
2352
  }
2371
2353
  const metadata = {};
2372
- if (typeof claims["role"] === "string") {
2373
- metadata["role"] = claims["role"];
2354
+ if (typeof claims.role === "string") {
2355
+ metadata.role = claims.role;
2374
2356
  }
2375
- if (typeof claims["aud"] === "string") {
2376
- metadata["aud"] = claims["aud"];
2357
+ if (typeof claims.aud === "string") {
2358
+ metadata.aud = claims.aud;
2377
2359
  }
2378
- if (typeof claims["app_metadata"] === "object" && claims["app_metadata"] !== null && !Array.isArray(claims["app_metadata"])) {
2379
- metadata["appMetadata"] = claims["app_metadata"];
2360
+ if (typeof claims.app_metadata === "object" && claims.app_metadata !== null && !Array.isArray(claims.app_metadata)) {
2361
+ metadata.appMetadata = claims.app_metadata;
2380
2362
  }
2381
2363
  return {
2382
2364
  userId: sub,
@@ -2404,10 +2386,12 @@ var PasskeyVerificationError = class extends KoraError6 {
2404
2386
  };
2405
2387
  function generateRegistrationOptions(params) {
2406
2388
  const challengeBytes = randomBytes3(32);
2407
- const challenge = toBase64Url(challengeBytes.buffer.slice(
2408
- challengeBytes.byteOffset,
2409
- challengeBytes.byteOffset + challengeBytes.byteLength
2410
- ));
2389
+ const challenge = toBase64Url(
2390
+ challengeBytes.buffer.slice(
2391
+ challengeBytes.byteOffset,
2392
+ challengeBytes.byteOffset + challengeBytes.byteLength
2393
+ )
2394
+ );
2411
2395
  return {
2412
2396
  challenge,
2413
2397
  rpId: params.rpId,
@@ -2517,10 +2501,12 @@ async function verifyRegistrationResponse(params) {
2517
2501
  }
2518
2502
  function generateAuthenticationOptions(params) {
2519
2503
  const challengeBytes = randomBytes3(32);
2520
- const challenge = toBase64Url(challengeBytes.buffer.slice(
2521
- challengeBytes.byteOffset,
2522
- challengeBytes.byteOffset + challengeBytes.byteLength
2523
- ));
2504
+ const challenge = toBase64Url(
2505
+ challengeBytes.buffer.slice(
2506
+ challengeBytes.byteOffset,
2507
+ challengeBytes.byteOffset + challengeBytes.byteLength
2508
+ )
2509
+ );
2524
2510
  return {
2525
2511
  challenge,
2526
2512
  rpId: params.rpId,
@@ -2575,9 +2561,7 @@ async function verifyAuthenticationResponse(params) {
2575
2561
  }
2576
2562
  const flags = authDataBytes[32];
2577
2563
  if ((flags & 1) === 0) {
2578
- throw new PasskeyVerificationError(
2579
- "User Present flag is not set in authenticator data."
2580
- );
2564
+ throw new PasskeyVerificationError("User Present flag is not set in authenticator data.");
2581
2565
  }
2582
2566
  const signCount = (authDataBytes[33] << 24 | authDataBytes[34] << 16 | authDataBytes[35] << 8 | authDataBytes[36]) >>> 0;
2583
2567
  if (previousSignCount > 0 || signCount > 0) {
@@ -2592,9 +2576,7 @@ async function verifyAuthenticationResponse(params) {
2592
2576
  }
2593
2577
  }
2594
2578
  const clientDataHash = await sha256(clientDataBytes);
2595
- const signedData = new Uint8Array(
2596
- authDataBytes.length + clientDataHash.byteLength
2597
- );
2579
+ const signedData = new Uint8Array(authDataBytes.length + clientDataHash.byteLength);
2598
2580
  signedData.set(authDataBytes, 0);
2599
2581
  signedData.set(new Uint8Array(clientDataHash), authDataBytes.length);
2600
2582
  const coseKeyBytes = fromBase64Url(publicKey);
@@ -2651,10 +2633,9 @@ async function verifyAuthenticationResponse(params) {
2651
2633
  signedData.buffer
2652
2634
  );
2653
2635
  } catch (error) {
2654
- throw new PasskeyVerificationError(
2655
- "Signature verification operation failed.",
2656
- { cause: error instanceof Error ? error.message : String(error) }
2657
- );
2636
+ throw new PasskeyVerificationError("Signature verification operation failed.", {
2637
+ cause: error instanceof Error ? error.message : String(error)
2638
+ });
2658
2639
  }
2659
2640
  if (!verified) {
2660
2641
  return { verified: false, newSignCount: signCount };
@@ -2677,9 +2658,7 @@ function constantTimeEqual(a, b) {
2677
2658
  function derToP1363(derSignature, componentLength) {
2678
2659
  let offset = 0;
2679
2660
  if (derSignature[offset] !== 48) {
2680
- throw new PasskeyVerificationError(
2681
- "Invalid DER signature: expected SEQUENCE tag (0x30)."
2682
- );
2661
+ throw new PasskeyVerificationError("Invalid DER signature: expected SEQUENCE tag (0x30).");
2683
2662
  }
2684
2663
  offset += 1;
2685
2664
  if (derSignature[offset] & 128) {
@@ -2766,11 +2745,9 @@ var MemberAlreadyExistsError = class extends OrgError {
2766
2745
  };
2767
2746
  var InsufficientRoleError = class extends OrgError {
2768
2747
  constructor(required) {
2769
- super(
2770
- `This action requires at least the "${required}" role.`,
2771
- "INSUFFICIENT_ROLE",
2772
- { requiredRole: required }
2773
- );
2748
+ super(`This action requires at least the "${required}" role.`, "INSUFFICIENT_ROLE", {
2749
+ requiredRole: required
2750
+ });
2774
2751
  }
2775
2752
  };
2776
2753
  var CannotRemoveOwnerError = class extends OrgError {
@@ -2980,17 +2957,28 @@ var OrgRoutes = class {
2980
2957
  return { status: 400, body: { error: "Target user ID is required." } };
2981
2958
  }
2982
2959
  if (typeof params.role !== "string" || !isValidRole(params.role)) {
2983
- return { status: 400, body: { error: "A valid role is required (admin, member, viewer, billing)." } };
2960
+ return {
2961
+ status: 400,
2962
+ body: { error: "A valid role is required (admin, member, viewer, billing)." }
2963
+ };
2984
2964
  }
2985
2965
  if (params.role === "owner") {
2986
- return { status: 400, body: { error: "Cannot assign owner role directly. Use ownership transfer." } };
2966
+ return {
2967
+ status: 400,
2968
+ body: { error: "Cannot assign owner role directly. Use ownership transfer." }
2969
+ };
2987
2970
  }
2988
2971
  const callerMembership = await this.store.getMembership(orgId, userId);
2989
2972
  if (callerMembership && callerMembership.role !== "owner" && params.role === "admin") {
2990
2973
  return { status: 403, body: { error: "Only the owner can add admin members." } };
2991
2974
  }
2992
2975
  try {
2993
- const membership = await this.store.addMember(orgId, params.targetUserId, params.role, userId);
2976
+ const membership = await this.store.addMember(
2977
+ orgId,
2978
+ params.targetUserId,
2979
+ params.role,
2980
+ userId
2981
+ );
2994
2982
  return { status: 201, body: { data: membership } };
2995
2983
  } catch (err) {
2996
2984
  if (err instanceof OrgNotFoundError) {
@@ -3043,17 +3031,27 @@ var OrgRoutes = class {
3043
3031
  return { status: 400, body: { error: "Target user ID is required." } };
3044
3032
  }
3045
3033
  if (typeof params.role !== "string" || !isValidRole(params.role)) {
3046
- return { status: 400, body: { error: "A valid role is required (admin, member, viewer, billing)." } };
3034
+ return {
3035
+ status: 400,
3036
+ body: { error: "A valid role is required (admin, member, viewer, billing)." }
3037
+ };
3047
3038
  }
3048
3039
  if (params.role === "owner") {
3049
- return { status: 400, body: { error: "Cannot assign owner role directly. Use ownership transfer." } };
3040
+ return {
3041
+ status: 400,
3042
+ body: { error: "Cannot assign owner role directly. Use ownership transfer." }
3043
+ };
3050
3044
  }
3051
3045
  const callerMembership = await this.store.getMembership(orgId, userId);
3052
3046
  if (callerMembership && callerMembership.role !== "owner" && params.role === "admin") {
3053
3047
  return { status: 403, body: { error: "Only the owner can assign admin role." } };
3054
3048
  }
3055
3049
  try {
3056
- const membership = await this.store.updateMemberRole(orgId, params.targetUserId, params.role);
3050
+ const membership = await this.store.updateMemberRole(
3051
+ orgId,
3052
+ params.targetUserId,
3053
+ params.role
3054
+ );
3057
3055
  return { status: 200, body: { data: membership } };
3058
3056
  } catch (err) {
3059
3057
  if (err instanceof MembershipNotFoundError) {
@@ -3116,10 +3114,16 @@ var OrgRoutes = class {
3116
3114
  return { status: 400, body: { error: "A valid email address is required." } };
3117
3115
  }
3118
3116
  if (typeof params.role !== "string" || !isValidRole(params.role)) {
3119
- return { status: 400, body: { error: "A valid role is required (admin, member, viewer, billing)." } };
3117
+ return {
3118
+ status: 400,
3119
+ body: { error: "A valid role is required (admin, member, viewer, billing)." }
3120
+ };
3120
3121
  }
3121
3122
  if (params.role === "owner") {
3122
- return { status: 400, body: { error: "Cannot invite with owner role. Use ownership transfer." } };
3123
+ return {
3124
+ status: 400,
3125
+ body: { error: "Cannot invite with owner role. Use ownership transfer." }
3126
+ };
3123
3127
  }
3124
3128
  const callerMembership = await this.store.getMembership(orgId, userId);
3125
3129
  if (callerMembership && callerMembership.role !== "owner" && params.role === "admin") {
@@ -3566,20 +3570,14 @@ var InvalidPermissionError = class extends RbacError {
3566
3570
  };
3567
3571
  var RoleNotFoundError = class extends RbacError {
3568
3572
  constructor(role) {
3569
- super(
3570
- `Role "${role}" is not defined.`,
3571
- "ROLE_NOT_FOUND",
3572
- { role }
3573
- );
3573
+ super(`Role "${role}" is not defined.`, "ROLE_NOT_FOUND", { role });
3574
3574
  }
3575
3575
  };
3576
3576
  var CircularInheritanceError = class extends RbacError {
3577
3577
  constructor(chain) {
3578
- super(
3579
- `Circular role inheritance detected: ${chain.join(" \u2192 ")}.`,
3580
- "CIRCULAR_INHERITANCE",
3581
- { chain }
3582
- );
3578
+ super(`Circular role inheritance detected: ${chain.join(" \u2192 ")}.`, "CIRCULAR_INHERITANCE", {
3579
+ chain
3580
+ });
3583
3581
  }
3584
3582
  };
3585
3583
 
@@ -3680,15 +3678,11 @@ var RbacEngine = class {
3680
3678
  permissions
3681
3679
  };
3682
3680
  const scopes = {};
3683
- const hasAnyRead = permissions.some(
3684
- (p) => permissionCovers(p, "*:read")
3685
- );
3681
+ const hasAnyRead = permissions.some((p) => permissionCovers(p, "*:read"));
3686
3682
  if (!hasAnyRead) {
3687
3683
  return scopes;
3688
3684
  }
3689
- const isReadOnly = !permissions.some(
3690
- (p) => permissionCovers(p, "*:write")
3691
- );
3685
+ const isReadOnly = !permissions.some((p) => permissionCovers(p, "*:write"));
3692
3686
  const collectionsToResolve = collections ?? [...this.collectionResolvers.keys()];
3693
3687
  for (const collection of collectionsToResolve) {
3694
3688
  const customResolver = this.collectionResolvers.get(collection);
@@ -3849,21 +3843,13 @@ var OrgScopeResolver = class {
3849
3843
  * Check if a user can write to a specific collection in an org.
3850
3844
  */
3851
3845
  async canWrite(userId, orgId, collection) {
3852
- return this.rbac.hasPermission(
3853
- userId,
3854
- orgId,
3855
- `${collection}:write`
3856
- );
3846
+ return this.rbac.hasPermission(userId, orgId, `${collection}:write`);
3857
3847
  }
3858
3848
  /**
3859
3849
  * Check if a user can read from a specific collection in an org.
3860
3850
  */
3861
3851
  async canRead(userId, orgId, collection) {
3862
- return this.rbac.hasPermission(
3863
- userId,
3864
- orgId,
3865
- `${collection}:read`
3866
- );
3852
+ return this.rbac.hasPermission(userId, orgId, `${collection}:read`);
3867
3853
  }
3868
3854
  // --- Private ---
3869
3855
  resolveCollectionScope(ctx, collection) {
@@ -3919,7 +3905,9 @@ var OAuthUserInfoError = class extends OAuthError {
3919
3905
  };
3920
3906
  var OAuthProviderNotFoundError = class extends OAuthError {
3921
3907
  constructor(provider) {
3922
- super(`OAuth provider "${provider}" is not configured.`, "OAUTH_PROVIDER_NOT_FOUND", { provider });
3908
+ super(`OAuth provider "${provider}" is not configured.`, "OAUTH_PROVIDER_NOT_FOUND", {
3909
+ provider
3910
+ });
3923
3911
  }
3924
3912
  };
3925
3913
 
@@ -4045,9 +4033,7 @@ var OAuthManager = class {
4045
4033
  body: body.toString()
4046
4034
  });
4047
4035
  } catch (err) {
4048
- throw new OAuthCodeExchangeError(
4049
- err instanceof Error ? err.message : "Network error"
4050
- );
4036
+ throw new OAuthCodeExchangeError(err instanceof Error ? err.message : "Network error");
4051
4037
  }
4052
4038
  if (!response.ok) {
4053
4039
  let details = `HTTP ${response.status}`;
@@ -4060,12 +4046,12 @@ var OAuthManager = class {
4060
4046
  }
4061
4047
  const data = await response.json();
4062
4048
  return {
4063
- accessToken: data["access_token"],
4064
- tokenType: data["token_type"] ?? "Bearer",
4065
- expiresIn: data["expires_in"],
4066
- refreshToken: data["refresh_token"],
4067
- idToken: data["id_token"],
4068
- scope: data["scope"]
4049
+ accessToken: data.access_token,
4050
+ tokenType: data.token_type ?? "Bearer",
4051
+ expiresIn: data.expires_in,
4052
+ refreshToken: data.refresh_token,
4053
+ idToken: data.id_token,
4054
+ scope: data.scope
4069
4055
  };
4070
4056
  }
4071
4057
  async fetchUserInfo(provider, accessToken) {
@@ -4078,9 +4064,7 @@ var OAuthManager = class {
4078
4064
  }
4079
4065
  });
4080
4066
  } catch (err) {
4081
- throw new OAuthUserInfoError(
4082
- err instanceof Error ? err.message : "Network error"
4083
- );
4067
+ throw new OAuthUserInfoError(err instanceof Error ? err.message : "Network error");
4084
4068
  }
4085
4069
  if (!response.ok) {
4086
4070
  throw new OAuthUserInfoError(`HTTP ${response.status}`);
@@ -4093,43 +4077,43 @@ function normalizeUserInfo(providerId, profile) {
4093
4077
  switch (providerId) {
4094
4078
  case "google":
4095
4079
  return {
4096
- providerId: profile["sub"],
4080
+ providerId: profile.sub,
4097
4081
  provider: "google",
4098
- email: profile["email"] ?? null,
4099
- emailVerified: profile["email_verified"] ?? false,
4100
- name: profile["name"] ?? null,
4101
- avatarUrl: profile["picture"] ?? null,
4082
+ email: profile.email ?? null,
4083
+ emailVerified: profile.email_verified ?? false,
4084
+ name: profile.name ?? null,
4085
+ avatarUrl: profile.picture ?? null,
4102
4086
  rawProfile: profile
4103
4087
  };
4104
4088
  case "github":
4105
4089
  return {
4106
- providerId: String(profile["id"]),
4090
+ providerId: String(profile.id),
4107
4091
  provider: "github",
4108
- email: profile["email"] ?? null,
4092
+ email: profile.email ?? null,
4109
4093
  emailVerified: false,
4110
4094
  // GitHub doesn't confirm in the profile response
4111
- name: profile["name"] ?? profile["login"] ?? null,
4112
- avatarUrl: profile["avatar_url"] ?? null,
4095
+ name: profile.name ?? profile.login ?? null,
4096
+ avatarUrl: profile.avatar_url ?? null,
4113
4097
  rawProfile: profile
4114
4098
  };
4115
4099
  case "microsoft":
4116
4100
  return {
4117
- providerId: profile["id"],
4101
+ providerId: profile.id,
4118
4102
  provider: "microsoft",
4119
- email: profile["mail"] ?? profile["userPrincipalName"] ?? null,
4103
+ email: profile.mail ?? profile.userPrincipalName ?? null,
4120
4104
  emailVerified: false,
4121
- name: profile["displayName"] ?? null,
4105
+ name: profile.displayName ?? null,
4122
4106
  avatarUrl: null,
4123
4107
  rawProfile: profile
4124
4108
  };
4125
4109
  default:
4126
4110
  return {
4127
- providerId: String(profile["id"] ?? profile["sub"] ?? ""),
4111
+ providerId: String(profile.id ?? profile.sub ?? ""),
4128
4112
  provider: providerId,
4129
- email: profile["email"] ?? null,
4130
- emailVerified: profile["email_verified"] ?? false,
4131
- name: profile["name"] ?? null,
4132
- avatarUrl: profile["picture"] ?? profile["avatar_url"] ?? null,
4113
+ email: profile.email ?? null,
4114
+ emailVerified: profile.email_verified ?? false,
4115
+ name: profile.name ?? null,
4116
+ avatarUrl: profile.picture ?? profile.avatar_url ?? null,
4133
4117
  rawProfile: profile
4134
4118
  };
4135
4119
  }
@@ -4406,7 +4390,7 @@ function generateSessionId() {
4406
4390
  globalThis.crypto.getRandomValues(bytes);
4407
4391
  let hex = "";
4408
4392
  for (let i = 0; i < bytes.length; i++) {
4409
- hex += bytes[i].toString(16).padStart(2, "0");
4393
+ hex += bytes[i]?.toString(16).padStart(2, "0");
4410
4394
  }
4411
4395
  return hex;
4412
4396
  }
@@ -4621,7 +4605,7 @@ var TotpManager = class {
4621
4605
  */
4622
4606
  async isEnabled(userId) {
4623
4607
  const stored = await this.store.getByUserId(userId);
4624
- return stored !== null && stored.verified;
4608
+ return stored?.verified ?? false;
4625
4609
  }
4626
4610
  /**
4627
4611
  * Get the number of remaining recovery codes for a user.
@@ -4655,7 +4639,7 @@ function generateTotpCode(secret, counter, digits, algorithm) {
4655
4639
  const hash = hmacSha(algorithm, secret, counterBytes);
4656
4640
  const offset = hash[hash.length - 1] & 15;
4657
4641
  const binary = (hash[offset] & 127) << 24 | (hash[offset + 1] & 255) << 16 | (hash[offset + 2] & 255) << 8 | hash[offset + 3] & 255;
4658
- const otp = binary % Math.pow(10, digits);
4642
+ const otp = binary % 10 ** digits;
4659
4643
  return otp.toString().padStart(digits, "0");
4660
4644
  }
4661
4645
  function hmacSha(algorithm, key, message) {
@@ -4699,7 +4683,10 @@ function sha1(data) {
4699
4683
  w[i] = view.getInt32(offset + i * 4, false);
4700
4684
  }
4701
4685
  for (let i = 16; i < 80; i++) {
4702
- w[i] = rotl32(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16] | 0, 1);
4686
+ w[i] = rotl32(
4687
+ w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16] | 0,
4688
+ 1
4689
+ );
4703
4690
  }
4704
4691
  let a = h0;
4705
4692
  let b = h1;
@@ -4808,7 +4795,7 @@ async function hashRecoveryCode(code) {
4808
4795
  const bytes = new Uint8Array(hash);
4809
4796
  let hex = "";
4810
4797
  for (let i = 0; i < bytes.length; i++) {
4811
- hex += bytes[i].toString(16).padStart(2, "0");
4798
+ hex += bytes[i]?.toString(16).padStart(2, "0");
4812
4799
  }
4813
4800
  return hex;
4814
4801
  }
@@ -5011,8 +4998,9 @@ var InMemoryAuditLogStore = class {
5011
4998
  const initialLength = this.entries.length;
5012
4999
  let writeIndex = 0;
5013
5000
  for (let i = 0; i < this.entries.length; i++) {
5014
- if (this.entries[i].timestamp >= timestamp) {
5015
- this.entries[writeIndex] = this.entries[i];
5001
+ const entry = this.entries[i];
5002
+ if (entry.timestamp >= timestamp) {
5003
+ this.entries[writeIndex] = entry;
5016
5004
  writeIndex++;
5017
5005
  }
5018
5006
  }
@@ -5103,7 +5091,7 @@ function generateAuditId() {
5103
5091
  globalThis.crypto.getRandomValues(bytes);
5104
5092
  let hex = "";
5105
5093
  for (let i = 0; i < bytes.length; i++) {
5106
- hex += bytes[i].toString(16).padStart(2, "0");
5094
+ hex += bytes[i]?.toString(16).padStart(2, "0");
5107
5095
  }
5108
5096
  return hex;
5109
5097
  }
@@ -5222,9 +5210,7 @@ var WebhookManager = class {
5222
5210
  */
5223
5211
  async dispatch(event, data) {
5224
5212
  const endpoints = await this.store.listEndpoints();
5225
- const matching = endpoints.filter(
5226
- (ep) => ep.active && ep.events.includes(event)
5227
- );
5213
+ const matching = endpoints.filter((ep) => ep.active && ep.events.includes(event));
5228
5214
  const payload = {
5229
5215
  id: generateId2(),
5230
5216
  event,
@@ -5232,9 +5218,7 @@ var WebhookManager = class {
5232
5218
  data
5233
5219
  };
5234
5220
  const payloadJson = JSON.stringify(payload);
5235
- await Promise.allSettled(
5236
- matching.map((ep) => this.deliverToEndpoint(ep, payloadJson, event))
5237
- );
5221
+ await Promise.allSettled(matching.map((ep) => this.deliverToEndpoint(ep, payloadJson, event)));
5238
5222
  }
5239
5223
  // --- Private ---
5240
5224
  async deliverToEndpoint(endpoint, payloadJson, event) {
@@ -5287,7 +5271,7 @@ function generateId2() {
5287
5271
  globalThis.crypto.getRandomValues(bytes);
5288
5272
  let hex = "";
5289
5273
  for (let i = 0; i < bytes.length; i++) {
5290
- hex += bytes[i].toString(16).padStart(2, "0");
5274
+ hex += bytes[i]?.toString(16).padStart(2, "0");
5291
5275
  }
5292
5276
  return hex;
5293
5277
  }
@@ -5296,7 +5280,7 @@ function generateSecret2() {
5296
5280
  globalThis.crypto.getRandomValues(bytes);
5297
5281
  let hex = "";
5298
5282
  for (let i = 0; i < bytes.length; i++) {
5299
- hex += bytes[i].toString(16).padStart(2, "0");
5283
+ hex += bytes[i]?.toString(16).padStart(2, "0");
5300
5284
  }
5301
5285
  return `whsec_${hex}`;
5302
5286
  }
@@ -5309,15 +5293,11 @@ async function signPayload(payload, secret) {
5309
5293
  false,
5310
5294
  ["sign"]
5311
5295
  );
5312
- const signature = await globalThis.crypto.subtle.sign(
5313
- "HMAC",
5314
- key,
5315
- encoder.encode(payload)
5316
- );
5296
+ const signature = await globalThis.crypto.subtle.sign("HMAC", key, encoder.encode(payload));
5317
5297
  const bytes = new Uint8Array(signature);
5318
5298
  let hex = "";
5319
5299
  for (let i = 0; i < bytes.length; i++) {
5320
- hex += bytes[i].toString(16).padStart(2, "0");
5300
+ hex += bytes[i]?.toString(16).padStart(2, "0");
5321
5301
  }
5322
5302
  return `sha256=${hex}`;
5323
5303
  }