@korajs/auth 0.3.3 → 0.5.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,2026 @@ 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
322
+ * @param accessToken - The JWT access token (without "Bearer " prefix)
323
+ * @returns Auth response with the user profile, or an error
332
324
  */
333
- async validateTokenWithRevocation(token) {
334
- const payload = this.validateToken(token);
335
- if (payload === null) {
336
- return null;
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
+ };
398
+ }
399
+ if (device.userId !== payload.sub) {
400
+ return {
401
+ status: 403,
402
+ body: { error: "You can only revoke your own devices." }
403
+ };
371
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.
518
+ * Handle device proof-of-possession verification (POST /auth/device/verify).
463
519
  *
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;
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.
523
+ *
524
+ * On success, issues fresh tokens for the device.
525
+ *
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
531
+ */
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
+ };
539
+ }
540
+ if (challengeEntry.deviceId !== body.deviceId) {
541
+ return {
542
+ status: 401,
543
+ body: { error: "Challenge was not issued for this device." }
544
+ };
545
+ }
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) {
554
+ return {
555
+ status: 403,
556
+ body: { error: "Device has been revoked and cannot authenticate." }
557
+ };
558
+ }
559
+ let publicKeyJwk;
560
+ try {
561
+ publicKeyJwk = JSON.parse(device.publicKey);
562
+ } catch {
563
+ return {
564
+ status: 500,
565
+ body: { error: "Device has an invalid stored public key." }
566
+ };
567
+ }
568
+ let isValid;
569
+ try {
570
+ isValid = await verifyChallenge(publicKeyJwk, body.challenge, body.signature);
571
+ } catch {
572
+ return {
573
+ status: 400,
574
+ body: {
575
+ error: "Signature verification failed. The signature or public key format may be invalid."
576
+ }
577
+ };
578
+ }
579
+ if (!isValid) {
580
+ return {
581
+ status: 401,
582
+ body: { error: "Invalid signature. Proof-of-possession verification failed." }
583
+ };
584
+ }
585
+ let thumbprint;
586
+ try {
587
+ thumbprint = await computePublicKeyThumbprint(publicKeyJwk);
588
+ } catch {
589
+ return {
590
+ status: 500,
591
+ body: { error: "Failed to compute public key thumbprint." }
592
+ };
593
+ }
594
+ const tokens = this.tokenManager.issueTokens(device.userId, device.id, thumbprint);
595
+ return {
596
+ status: 200,
597
+ body: { data: { tokens } }
598
+ };
469
599
  }
470
600
  /**
471
- * Find a user by ID.
601
+ * Generates a random challenge string for proof-of-possession verification.
472
602
  *
473
- * @param id - The user ID to search for
474
- * @returns The stored user record including credentials, or null if not found
603
+ * **Deprecated:** Use {@link handleDeviceChallenge} instead, which stores
604
+ * the challenge server-side with expiry and single-use semantics.
605
+ *
606
+ * @returns A 64-character hex string (32 random bytes)
475
607
  */
476
- async findById(id) {
477
- return this.usersById.get(id) ?? null;
608
+ static generateChallenge() {
609
+ return randomBytes(32).toString("hex");
478
610
  }
479
611
  /**
480
- * Register a device for a user.
612
+ * Creates a sync server auth provider compatible with `@korajs/server`.
481
613
  *
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.
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.
485
618
  *
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
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
+ * ```
492
632
  */
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
633
+ toSyncAuthProvider() {
634
+ const tokenManager = this.tokenManager;
635
+ const userStore = this.userStore;
636
+ return {
637
+ async authenticate(token) {
638
+ const payload = await tokenManager.validateTokenWithRevocation(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
+ }
507
660
  };
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);
661
+ }
662
+ };
663
+
664
+ // src/provider/built-in/quickstart-server.ts
665
+ import { randomUUID as randomUUID4 } from "crypto";
666
+
667
+ // src/tokens/token-manager.ts
668
+ import { randomBytes as randomBytes2, randomUUID } from "crypto";
669
+
670
+ // src/types.ts
671
+ import { KoraError } from "@korajs/core";
672
+ var DEFAULT_ACCESS_TOKEN_LIFETIME = 15 * 60 * 1e3;
673
+ var DEFAULT_REFRESH_TOKEN_LIFETIME = 90 * 24 * 60 * 60 * 1e3;
674
+ var DEFAULT_DEVICE_CREDENTIAL_LIFETIME = 90 * 24 * 60 * 60 * 1e3;
675
+ var DEFAULT_MAX_OFFLINE_DURATION = 30 * 24 * 60 * 60 * 1e3;
676
+ var DEFAULT_AUTO_LOCK_TIMEOUT = 15 * 60 * 1e3;
677
+
678
+ // src/tokens/jwt.ts
679
+ import { createHmac } from "crypto";
680
+ function base64urlEncode(input) {
681
+ return Buffer.from(input, "utf-8").toString("base64url");
682
+ }
683
+ function base64urlDecode(input) {
684
+ return Buffer.from(input, "base64url").toString("utf-8");
685
+ }
686
+ function hmacSha256Base64url(data, secret) {
687
+ return createHmac("sha256", secret).update(data).digest("base64url");
688
+ }
689
+ var ENCODED_HEADER = base64urlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" }));
690
+ function encodeJwt(payload, secret) {
691
+ const encodedPayload = base64urlEncode(JSON.stringify(payload));
692
+ const signingInput = `${ENCODED_HEADER}.${encodedPayload}`;
693
+ const signature = hmacSha256Base64url(signingInput, secret);
694
+ return `${signingInput}.${signature}`;
695
+ }
696
+ function decodeJwt(token) {
697
+ const parts = token.split(".");
698
+ if (parts.length !== 3) {
699
+ return null;
700
+ }
701
+ const payloadSegment = parts[1];
702
+ if (payloadSegment === void 0) {
703
+ return null;
704
+ }
705
+ try {
706
+ const decoded = base64urlDecode(payloadSegment);
707
+ const parsed = JSON.parse(decoded);
708
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
709
+ return null;
513
710
  }
514
- userDevices.add(params.id);
515
- return device;
711
+ return parsed;
712
+ } catch {
713
+ return null;
714
+ }
715
+ }
716
+ function verifyJwt(token, secret) {
717
+ const parts = token.split(".");
718
+ if (parts.length !== 3) {
719
+ return null;
720
+ }
721
+ const headerSegment = parts[0];
722
+ const payloadSegment = parts[1];
723
+ const signatureSegment = parts[2];
724
+ if (headerSegment === void 0 || payloadSegment === void 0 || signatureSegment === void 0) {
725
+ return null;
726
+ }
727
+ if (headerSegment !== ENCODED_HEADER) {
728
+ return null;
729
+ }
730
+ const signingInput = `${headerSegment}.${payloadSegment}`;
731
+ const expectedSignature = hmacSha256Base64url(signingInput, secret);
732
+ if (expectedSignature.length !== signatureSegment.length) {
733
+ return null;
734
+ }
735
+ let mismatch = 0;
736
+ for (let i = 0; i < expectedSignature.length; i++) {
737
+ mismatch |= expectedSignature.charCodeAt(i) ^ signatureSegment.charCodeAt(i);
738
+ }
739
+ if (mismatch !== 0) {
740
+ return null;
741
+ }
742
+ try {
743
+ const decoded = base64urlDecode(payloadSegment);
744
+ const parsed = JSON.parse(decoded);
745
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
746
+ return null;
747
+ }
748
+ return parsed;
749
+ } catch {
750
+ return null;
751
+ }
752
+ }
753
+ var CLOCK_SKEW_TOLERANCE_SECONDS = 5;
754
+ function isExpired(payload) {
755
+ if (typeof payload.exp !== "number") {
756
+ return false;
757
+ }
758
+ const nowSeconds = Math.floor(Date.now() / 1e3);
759
+ return nowSeconds >= payload.exp + CLOCK_SKEW_TOLERANCE_SECONDS;
760
+ }
761
+
762
+ // src/tokens/token-manager.ts
763
+ var MIN_SECRET_LENGTH = 32;
764
+ var InMemoryTokenRevocationStore = class {
765
+ revokedTokens = /* @__PURE__ */ new Map();
766
+ revokedDevices = /* @__PURE__ */ new Set();
767
+ async isRevoked(jti) {
768
+ return this.revokedTokens.has(jti);
769
+ }
770
+ async revoke(jti, expiresAt) {
771
+ this.revokedTokens.set(jti, expiresAt);
772
+ }
773
+ async revokeAllForDevice(deviceId) {
774
+ this.revokedDevices.add(deviceId);
516
775
  }
517
776
  /**
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
777
+ * Check if a device has been revoked.
522
778
  */
523
- async findDevice(deviceId) {
524
- return this.devicesById.get(deviceId) ?? null;
779
+ async isDeviceRevoked(deviceId) {
780
+ return this.revokedDevices.has(deviceId);
781
+ }
782
+ /**
783
+ * Remove expired revocations to prevent unbounded memory growth.
784
+ * Call periodically (e.g., every hour) in long-running servers.
785
+ */
786
+ cleanup() {
787
+ const nowSeconds = Math.floor(Date.now() / 1e3);
788
+ for (const [jti, expiresAt] of this.revokedTokens) {
789
+ if (nowSeconds > expiresAt) {
790
+ this.revokedTokens.delete(jti);
791
+ }
792
+ }
793
+ }
794
+ };
795
+ var TokenManager = class {
796
+ /** All signing/verification secrets (index 0 = current signing key) */
797
+ secrets;
798
+ accessTokenLifetime;
799
+ refreshTokenLifetime;
800
+ deviceCredentialLifetime;
801
+ revocationStore;
802
+ constructor(config) {
803
+ const secrets = Array.isArray(config.secret) ? config.secret : [config.secret];
804
+ if (secrets.length === 0) {
805
+ throw new Error("TokenManager requires at least one secret.");
806
+ }
807
+ for (const secret of secrets) {
808
+ if (secret.length < MIN_SECRET_LENGTH) {
809
+ throw new Error(
810
+ `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.`
811
+ );
812
+ }
813
+ }
814
+ this.secrets = secrets;
815
+ this.accessTokenLifetime = config.accessTokenLifetime ?? DEFAULT_ACCESS_TOKEN_LIFETIME;
816
+ this.refreshTokenLifetime = config.refreshTokenLifetime ?? DEFAULT_REFRESH_TOKEN_LIFETIME;
817
+ this.deviceCredentialLifetime = config.deviceCredentialLifetime ?? DEFAULT_DEVICE_CREDENTIAL_LIFETIME;
818
+ this.revocationStore = config.revocationStore;
525
819
  }
526
820
  /**
527
- * List all devices registered for a user.
821
+ * Generate a cryptographically random secret suitable for HMAC-SHA256 signing.
528
822
  *
529
- * @param userId - The user ID whose devices to list
530
- * @returns Array of device records (includes revoked devices)
823
+ * Returns a 64-character hex string (32 bytes / 256 bits of entropy).
824
+ * Store this securely (environment variable, secrets manager) — never in source code.
825
+ *
826
+ * @returns A random 256-bit hex-encoded secret
531
827
  */
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;
828
+ static generateSecret() {
829
+ return randomBytes2(32).toString("hex");
545
830
  }
546
831
  /**
547
- * Revoke a device, preventing it from being used for authentication.
832
+ * Issue a signed JWT access token.
548
833
  *
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.
834
+ * Access tokens are short-lived (default 15 minutes) and used to authorize
835
+ * API requests. When expired, use {@link refreshAccessToken} with a valid
836
+ * refresh token to obtain a new one.
551
837
  *
552
- * @param deviceId - The ID of the device to revoke
838
+ * @param userId - The subject (user ID) to encode in the token
839
+ * @param deviceId - The device ID of the requesting device
840
+ * @returns A signed JWT string with type 'access'
553
841
  */
554
- async revokeDevice(deviceId) {
555
- const device = this.devicesById.get(deviceId);
556
- if (device !== void 0) {
557
- device.revoked = true;
558
- }
842
+ issueAccessToken(userId, deviceId) {
843
+ const nowSeconds = Math.floor(Date.now() / 1e3);
844
+ const payload = {
845
+ jti: randomUUID(),
846
+ sub: userId,
847
+ dev: deviceId,
848
+ type: "access",
849
+ iat: nowSeconds,
850
+ exp: nowSeconds + Math.floor(this.accessTokenLifetime / 1e3)
851
+ };
852
+ return encodeJwt(payload, this.secrets[0]);
559
853
  }
560
854
  /**
561
- * Set a user's email verification status.
855
+ * Issue a signed JWT refresh token.
562
856
  *
563
- * @param userId - The user whose email to verify
564
- * @param verified - Whether the email is verified
857
+ * Refresh tokens are longer-lived (default 90 days) and used exclusively
858
+ * to obtain new access tokens via {@link refreshAccessToken}. They should
859
+ * be stored securely and never sent to resource APIs.
860
+ *
861
+ * @param userId - The subject (user ID) to encode in the token
862
+ * @param deviceId - The device ID of the requesting device
863
+ * @returns A signed JWT string with type 'refresh'
565
864
  */
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);
865
+ issueRefreshToken(userId, deviceId) {
866
+ const nowSeconds = Math.floor(Date.now() / 1e3);
867
+ const payload = {
868
+ jti: randomUUID(),
869
+ sub: userId,
870
+ dev: deviceId,
871
+ type: "refresh",
872
+ iat: nowSeconds,
873
+ exp: nowSeconds + Math.floor(this.refreshTokenLifetime / 1e3)
874
+ };
875
+ return encodeJwt(payload, this.secrets[0]);
572
876
  }
573
877
  /**
574
- * Update a user's password hash and salt.
878
+ * Issue a signed device credential token.
575
879
  *
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
880
+ * Device credentials are long-lived tokens bound to a device's public key.
881
+ * They include a `mustCheckinBy` deadline; if the device does not check in
882
+ * before this deadline, the credential should be treated as revoked.
883
+ *
884
+ * @param userId - The subject (user ID) to encode in the token
885
+ * @param deviceId - The device ID of the requesting device
886
+ * @param publicKeyThumbprint - SHA-256 thumbprint of the device's public key
887
+ * @returns A signed JWT string with type 'device_credential'
579
888
  */
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);
889
+ issueDeviceCredential(userId, deviceId, publicKeyThumbprint) {
890
+ const nowSeconds = Math.floor(Date.now() / 1e3);
891
+ const lifetimeSeconds = Math.floor(this.deviceCredentialLifetime / 1e3);
892
+ const payload = {
893
+ jti: randomUUID(),
894
+ sub: userId,
895
+ dev: deviceId,
896
+ type: "device_credential",
897
+ iat: nowSeconds,
898
+ exp: nowSeconds + lifetimeSeconds,
899
+ dpk: publicKeyThumbprint,
900
+ mustCheckinBy: nowSeconds + lifetimeSeconds
901
+ };
902
+ return encodeJwt(payload, this.secrets[0]);
586
903
  }
587
904
  /**
588
- * List all users. For admin/development use.
905
+ * Issue a complete set of authentication tokens.
906
+ *
907
+ * Always issues an access token and refresh token. If a `publicKeyThumbprint`
908
+ * is provided, also issues a device credential.
909
+ *
910
+ * @param userId - The subject (user ID) to encode in the tokens
911
+ * @param deviceId - The device ID of the requesting device
912
+ * @param publicKeyThumbprint - Optional SHA-256 thumbprint of the device's public key.
913
+ * When provided, a device credential is included in the returned tokens.
914
+ * @returns An {@link AuthTokens} object containing the issued tokens
589
915
  */
590
- async listAll() {
591
- return [...this.usersById.values()];
916
+ issueTokens(userId, deviceId, publicKeyThumbprint) {
917
+ const tokens = {
918
+ accessToken: this.issueAccessToken(userId, deviceId),
919
+ refreshToken: this.issueRefreshToken(userId, deviceId)
920
+ };
921
+ if (publicKeyThumbprint !== void 0) {
922
+ tokens.deviceCredential = this.issueDeviceCredential(userId, deviceId, publicKeyThumbprint);
923
+ }
924
+ return tokens;
592
925
  }
593
926
  /**
594
- * Update a stored user record.
927
+ * Validate and decode a token.
928
+ *
929
+ * Verifies the HMAC-SHA256 signature (trying all configured secrets for key rotation),
930
+ * checks that the token has not expired, and validates all required claims.
931
+ * Returns null (rather than throwing) for invalid or expired tokens, so callers
932
+ * can handle authentication failure without try/catch.
933
+ *
934
+ * @param token - The JWT string to validate
935
+ * @returns The decoded {@link TokenPayload} if valid, or null if the token is
936
+ * invalid, expired, or missing required claims
595
937
  */
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);
938
+ validateToken(token) {
939
+ let decoded = null;
940
+ for (const secret of this.secrets) {
941
+ decoded = verifyJwt(token, secret);
942
+ if (decoded !== null) {
943
+ break;
944
+ }
604
945
  }
605
- this.usersById.set(user.id, user);
946
+ if (decoded === null) {
947
+ return null;
948
+ }
949
+ if (isExpired(decoded)) {
950
+ return null;
951
+ }
952
+ 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") {
953
+ return null;
954
+ }
955
+ const type = decoded.type;
956
+ if (type !== "access" && type !== "refresh" && type !== "device_credential") {
957
+ return null;
958
+ }
959
+ return {
960
+ jti: decoded.jti,
961
+ sub: decoded.sub,
962
+ dev: decoded.dev,
963
+ type,
964
+ iat: decoded.iat,
965
+ exp: decoded.exp
966
+ };
606
967
  }
607
968
  /**
608
- * Delete a user and all associated devices.
969
+ * Validate a token and check it against the revocation store.
970
+ *
971
+ * Like {@link validateToken}, but also checks whether the token's `jti` has been
972
+ * revoked or belongs to a revoked device. Requires a revocation store to be configured.
973
+ *
974
+ * @param token - The JWT string to validate
975
+ * @returns The decoded {@link TokenPayload} if valid and not revoked, or null otherwise
609
976
  */
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);
977
+ async validateTokenWithRevocation(token) {
978
+ const payload = this.validateToken(token);
979
+ if (payload === null) {
980
+ return null;
981
+ }
982
+ if (this.revocationStore) {
983
+ const revoked = await this.revocationStore.isRevoked(payload.jti);
984
+ if (revoked) {
985
+ return null;
986
+ }
987
+ const deviceRevoked = await this.revocationStore.isDeviceRevoked(payload.dev);
988
+ if (deviceRevoked) {
989
+ return null;
619
990
  }
620
- this.devicesByUserId.delete(userId);
621
991
  }
992
+ return payload;
622
993
  }
623
994
  /**
624
- * Update the last-seen timestamp for a device.
995
+ * Revoke a specific token by its JWT ID.
625
996
  *
626
- * Called when a device authenticates or syncs to track activity.
627
- * If the device does not exist, this is a no-op.
997
+ * Requires a revocation store to be configured. After revocation, the token
998
+ * will be rejected by {@link validateTokenWithRevocation}.
628
999
  *
629
- * @param deviceId - The ID of the device to update
1000
+ * @param jti - The JWT ID of the token to revoke
1001
+ * @param expiresAt - The token's expiration time (seconds since epoch)
630
1002
  */
631
- async touchDevice(deviceId) {
632
- const device = this.devicesById.get(deviceId);
633
- if (device !== void 0) {
634
- device.lastSeenAt = Date.now();
1003
+ async revokeToken(jti, expiresAt) {
1004
+ if (this.revocationStore) {
1005
+ await this.revocationStore.revoke(jti, expiresAt);
635
1006
  }
636
1007
  }
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 });
1008
+ /**
1009
+ * Revoke all tokens for a specific device.
1010
+ *
1011
+ * Called when a device is revoked to ensure all its existing tokens
1012
+ * (access, refresh, and device credentials) are invalidated.
1013
+ *
1014
+ * @param deviceId - The device ID whose tokens should be revoked
1015
+ */
1016
+ async revokeDeviceTokens(deviceId) {
1017
+ if (this.revocationStore) {
1018
+ await this.revocationStore.revokeAllForDevice(deviceId);
1019
+ }
653
1020
  }
654
- async consume(challenge) {
655
- const entry = this.challenges.get(challenge);
656
- if (entry === void 0) {
1021
+ /**
1022
+ * Refresh an access token using a valid refresh token.
1023
+ *
1024
+ * Implements **refresh token rotation with reuse detection**: a new refresh token
1025
+ * is issued alongside the new access token. The old refresh token's `jti` is
1026
+ * recorded in the revocation store (if configured). If a previously consumed
1027
+ * refresh token is presented again, it indicates potential token theft.
1028
+ *
1029
+ * Returns null if the provided token is invalid, expired, or not a refresh token.
1030
+ *
1031
+ * @param refreshToken - The refresh token JWT string
1032
+ * @returns A new access/refresh token pair, or null if the refresh token is invalid
1033
+ */
1034
+ async refreshAccessToken(refreshToken) {
1035
+ const payload = this.validateToken(refreshToken);
1036
+ if (payload === null) {
657
1037
  return null;
658
1038
  }
659
- this.challenges.delete(challenge);
660
- if (Date.now() > entry.expiresAt) {
1039
+ if (payload.type !== "refresh") {
661
1040
  return null;
662
1041
  }
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);
1042
+ if (this.revocationStore) {
1043
+ const wasRevoked = await this.revocationStore.isRevoked(payload.jti);
1044
+ if (wasRevoked) {
1045
+ await this.revocationStore.revokeAllForDevice(payload.dev);
1046
+ return null;
673
1047
  }
1048
+ await this.revocationStore.revoke(payload.jti, payload.exp);
674
1049
  }
1050
+ return {
1051
+ accessToken: this.issueAccessToken(payload.sub, payload.dev),
1052
+ refreshToken: this.issueRefreshToken(payload.sub, payload.dev)
1053
+ };
675
1054
  }
676
1055
  };
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;
1056
+
1057
+ // src/provider/oauth/linked-identity-store.ts
1058
+ import { randomUUID as randomUUID2 } from "crypto";
1059
+ import { KoraError as KoraError2 } from "@korajs/core";
1060
+ var DuplicateLinkedIdentityError = class extends KoraError2 {
1061
+ constructor(provider) {
1062
+ super(`This ${provider} account is already linked.`, "DUPLICATE_LINKED_IDENTITY", {
1063
+ provider
1064
+ });
1065
+ this.name = "DuplicateLinkedIdentityError";
688
1066
  }
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;
1067
+ };
1068
+ var InMemoryLinkedIdentityStore = class {
1069
+ identitiesById = /* @__PURE__ */ new Map();
1070
+ identityIdByProvider = /* @__PURE__ */ new Map();
1071
+ identityIdsByUser = /* @__PURE__ */ new Map();
1072
+ identityIdByUserProvider = /* @__PURE__ */ new Map();
1073
+ async findByProvider(provider, providerUserId) {
1074
+ const id = this.identityIdByProvider.get(providerIdentityKey(provider, providerUserId));
1075
+ return id ? this.identitiesById.get(id) ?? null : null;
1076
+ }
1077
+ async findByUser(userId) {
1078
+ const ids = this.identityIdsByUser.get(userId);
1079
+ if (!ids) return [];
1080
+ return [...ids].map((id) => this.identitiesById.get(id)).filter((identity) => identity !== void 0);
694
1081
  }
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);
1082
+ async create(params) {
1083
+ const providerKey = providerIdentityKey(params.provider, params.providerUserId);
1084
+ const userProviderKeyValue = userProviderKey(params.userId, params.provider);
1085
+ if (this.identityIdByProvider.has(providerKey) || this.identityIdByUserProvider.has(userProviderKeyValue)) {
1086
+ throw new DuplicateLinkedIdentityError(params.provider);
1087
+ }
1088
+ const identity = {
1089
+ id: randomUUID2(),
1090
+ userId: params.userId,
1091
+ provider: params.provider,
1092
+ providerUserId: params.providerUserId,
1093
+ email: params.email,
1094
+ linkedAt: Date.now()
1095
+ };
1096
+ this.identitiesById.set(identity.id, identity);
1097
+ this.identityIdByProvider.set(providerKey, identity.id);
1098
+ this.identityIdByUserProvider.set(userProviderKeyValue, identity.id);
1099
+ const userIdentities = this.identityIdsByUser.get(params.userId) ?? /* @__PURE__ */ new Set();
1100
+ userIdentities.add(identity.id);
1101
+ this.identityIdsByUser.set(params.userId, userIdentities);
1102
+ return identity;
1103
+ }
1104
+ async delete(userId, provider) {
1105
+ const userProviderKeyValue = userProviderKey(userId, provider);
1106
+ const id = this.identityIdByUserProvider.get(userProviderKeyValue);
1107
+ if (!id) return;
1108
+ const identity = this.identitiesById.get(id);
1109
+ this.identitiesById.delete(id);
1110
+ this.identityIdByUserProvider.delete(userProviderKeyValue);
1111
+ if (identity) {
1112
+ this.identityIdByProvider.delete(
1113
+ providerIdentityKey(identity.provider, identity.providerUserId)
1114
+ );
1115
+ }
1116
+ const userIdentities = this.identityIdsByUser.get(userId);
1117
+ userIdentities?.delete(id);
1118
+ if (userIdentities?.size === 0) {
1119
+ this.identityIdsByUser.delete(userId);
1120
+ }
701
1121
  }
702
- async reset(key) {
703
- this.attempts.delete(key);
1122
+ };
1123
+ function providerIdentityKey(provider, providerUserId) {
1124
+ return `${provider}:${providerUserId}`;
1125
+ }
1126
+ function userProviderKey(userId, provider) {
1127
+ return `${userId}:${provider}`;
1128
+ }
1129
+
1130
+ // src/provider/oauth/oauth-types.ts
1131
+ import { KoraError as KoraError3 } from "@korajs/core";
1132
+ var OAuthError = class extends KoraError3 {
1133
+ constructor(message, code, context) {
1134
+ super(message, code, context);
1135
+ this.name = "OAuthError";
704
1136
  }
705
1137
  };
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;
1138
+ var OAuthStateMismatchError = class extends OAuthError {
1139
+ constructor() {
1140
+ super("OAuth state parameter does not match. Possible CSRF attack.", "OAUTH_STATE_MISMATCH");
713
1141
  }
714
- const atIndex = email.indexOf("@");
715
- if (atIndex < 1) {
716
- return false;
1142
+ };
1143
+ var OAuthCodeExchangeError = class extends OAuthError {
1144
+ constructor(details) {
1145
+ super(
1146
+ `Failed to exchange authorization code for tokens.${details ? ` ${details}` : ""}`,
1147
+ "OAUTH_CODE_EXCHANGE_FAILED",
1148
+ details ? { details } : void 0
1149
+ );
717
1150
  }
718
- const domain = email.slice(atIndex + 1);
719
- if (domain.length === 0 || !domain.includes(".")) {
720
- return false;
1151
+ };
1152
+ var OAuthUserInfoError = class extends OAuthError {
1153
+ constructor(details) {
1154
+ super(
1155
+ `Failed to fetch user info from OAuth provider.${details ? ` ${details}` : ""}`,
1156
+ "OAUTH_USER_INFO_FAILED",
1157
+ details ? { details } : void 0
1158
+ );
721
1159
  }
722
- if (email.indexOf("@", atIndex + 1) !== -1) {
723
- return false;
1160
+ };
1161
+ var OAuthProviderNotFoundError = class extends OAuthError {
1162
+ constructor(provider) {
1163
+ super(`OAuth provider "${provider}" is not configured.`, "OAUTH_PROVIDER_NOT_FOUND", {
1164
+ provider
1165
+ });
724
1166
  }
725
- if (email.includes(" ")) {
726
- return false;
1167
+ };
1168
+
1169
+ // src/provider/oauth/oauth-flow.ts
1170
+ var DEFAULT_STATE_TTL_MS = 10 * 60 * 1e3;
1171
+ var PKCE_VERIFIER_BYTES = 32;
1172
+ var InMemoryOAuthStateStore = class {
1173
+ states = /* @__PURE__ */ new Map();
1174
+ async store(state) {
1175
+ this.states.set(state.state, state);
727
1176
  }
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);
1177
+ async consume(stateValue) {
1178
+ const state = this.states.get(stateValue);
1179
+ if (!state) return null;
1180
+ this.states.delete(stateValue);
1181
+ if (Date.now() > state.expiresAt) return null;
1182
+ return state;
735
1183
  }
736
- return trimmed;
737
- }
738
- var BuiltInAuthRoutes = class {
739
- userStore;
740
- tokenManager;
741
- challengeStore;
742
- rateLimiter;
1184
+ async cleanExpired() {
1185
+ const now = Date.now();
1186
+ let count = 0;
1187
+ for (const [key, state] of this.states) {
1188
+ if (now > state.expiresAt) {
1189
+ this.states.delete(key);
1190
+ count++;
1191
+ }
1192
+ }
1193
+ return count;
1194
+ }
1195
+ };
1196
+ var OAuthManager = class {
1197
+ providers = /* @__PURE__ */ new Map();
1198
+ stateStore;
1199
+ stateTtlMs;
1200
+ fetchFn;
743
1201
  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();
1202
+ for (const provider of config.providers) {
1203
+ this.providers.set(provider.providerId, provider);
1204
+ }
1205
+ this.stateStore = config.stateStore ?? new InMemoryOAuthStateStore();
1206
+ this.stateTtlMs = config.stateTtlMs ?? DEFAULT_STATE_TTL_MS;
1207
+ this.fetchFn = config.fetch ?? globalThis.fetch.bind(globalThis);
748
1208
  }
749
1209
  /**
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
1210
+ * Generate an authorization URL for the user to visit.
1211
+ * Returns the URL and the state parameter for CSRF validation.
763
1212
  */
764
- async handleSignUp(body, clientIp) {
765
- const rateLimitKey = clientIp ?? "global";
766
- if (!await this.rateLimiter.isAllowed(rateLimitKey)) {
767
- return {
768
- status: 429,
769
- body: { error: "Too many requests. Please try again later." }
770
- };
1213
+ async getAuthorizationUrl(providerId, metadata) {
1214
+ const provider = this.getProvider(providerId);
1215
+ const state = generateState();
1216
+ const codeVerifier = provider.pkce ? generateCodeVerifier() : void 0;
1217
+ const codeChallenge = codeVerifier ? await createCodeChallenge(codeVerifier) : void 0;
1218
+ const now = Date.now();
1219
+ const oauthState = {
1220
+ state,
1221
+ provider: providerId,
1222
+ redirectUri: provider.redirectUri,
1223
+ createdAt: now,
1224
+ expiresAt: now + this.stateTtlMs,
1225
+ metadata,
1226
+ codeVerifier
1227
+ };
1228
+ await this.stateStore.store(oauthState);
1229
+ const params = new URLSearchParams({
1230
+ client_id: provider.clientId,
1231
+ redirect_uri: provider.redirectUri,
1232
+ response_type: "code",
1233
+ scope: provider.scopes.join(" "),
1234
+ state
1235
+ });
1236
+ if (codeChallenge) {
1237
+ params.set("code_challenge", codeChallenge);
1238
+ params.set("code_challenge_method", "S256");
771
1239
  }
772
- await this.rateLimiter.record(rateLimitKey);
773
- if (!isValidEmail(body.email)) {
774
- return {
775
- status: 400,
776
- body: {
777
- error: "Invalid email address. Please provide a valid email in the format user@domain.com."
778
- }
779
- };
1240
+ const url = `${provider.authorizationUrl}?${params.toString()}`;
1241
+ return { url, state };
1242
+ }
1243
+ /**
1244
+ * Handle the OAuth callback after the user authorizes.
1245
+ * Validates the state parameter, exchanges the code for tokens,
1246
+ * and fetches user info.
1247
+ *
1248
+ * @param providerId - The OAuth provider
1249
+ * @param code - The authorization code from the callback
1250
+ * @param state - The state parameter from the callback
1251
+ * @returns Tokens and user info from the provider
1252
+ */
1253
+ async handleCallback(providerId, code, state) {
1254
+ const provider = this.getProvider(providerId);
1255
+ const oauthState = await this.stateStore.consume(state);
1256
+ if (!oauthState || oauthState.provider !== providerId) {
1257
+ throw new OAuthStateMismatchError();
780
1258
  }
781
- if (body.password.length < MIN_PASSWORD_LENGTH) {
782
- return {
783
- status: 400,
784
- body: {
785
- error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long.`
786
- }
787
- };
1259
+ const tokens = await this.exchangeCodeForTokens(provider, code, oauthState);
1260
+ const userInfo = await this.fetchUserInfo(provider, tokens.accessToken);
1261
+ return { tokens, userInfo, stateMetadata: oauthState.metadata };
1262
+ }
1263
+ /**
1264
+ * Get a registered provider by ID.
1265
+ */
1266
+ getProvider(providerId) {
1267
+ const provider = this.providers.get(providerId);
1268
+ if (!provider) {
1269
+ throw new OAuthProviderNotFoundError(providerId);
788
1270
  }
789
- if (body.password.length > MAX_PASSWORD_LENGTH) {
790
- return {
791
- status: 400,
792
- body: {
793
- error: `Password must be at most ${MAX_PASSWORD_LENGTH} characters long.`
794
- }
795
- };
1271
+ return provider;
1272
+ }
1273
+ /**
1274
+ * List all registered provider IDs.
1275
+ */
1276
+ getProviderIds() {
1277
+ return [...this.providers.keys()];
1278
+ }
1279
+ // --- Private ---
1280
+ async exchangeCodeForTokens(provider, code, oauthState) {
1281
+ const body = new URLSearchParams({
1282
+ grant_type: "authorization_code",
1283
+ code,
1284
+ redirect_uri: provider.redirectUri,
1285
+ client_id: provider.clientId
1286
+ });
1287
+ if (provider.clientSecret) {
1288
+ body.set("client_secret", provider.clientSecret);
796
1289
  }
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;
1290
+ if (oauthState.codeVerifier) {
1291
+ body.set("code_verifier", oauthState.codeVerifier);
1292
+ }
1293
+ let response;
801
1294
  try {
802
- user = await this.userStore.createUser({
803
- email: body.email,
804
- passwordHash: hash,
805
- salt,
806
- name
1295
+ response = await this.fetchFn(provider.tokenUrl, {
1296
+ method: "POST",
1297
+ headers: {
1298
+ "Content-Type": "application/x-www-form-urlencoded",
1299
+ Accept: "application/json"
1300
+ },
1301
+ body: body.toString()
807
1302
  });
808
1303
  } 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
- };
1304
+ throw new OAuthCodeExchangeError(err instanceof Error ? err.message : "Network error");
1305
+ }
1306
+ if (!response.ok) {
1307
+ let details = `HTTP ${response.status}`;
1308
+ try {
1309
+ const errorBody = await response.text();
1310
+ details += `: ${errorBody}`;
1311
+ } catch {
814
1312
  }
815
- throw err;
1313
+ throw new OAuthCodeExchangeError(details);
816
1314
  }
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);
1315
+ const data = await response.json();
825
1316
  return {
826
- status: 201,
827
- body: { data: { user, tokens } }
1317
+ accessToken: data.access_token,
1318
+ tokenType: data.token_type ?? "Bearer",
1319
+ expiresIn: data.expires_in,
1320
+ refreshToken: data.refresh_token,
1321
+ idToken: data.id_token,
1322
+ scope: data.scope
828
1323
  };
829
1324
  }
830
- /**
831
- * Handle user sign-in (POST /auth/signin).
832
- *
833
- * Looks up the user by email, verifies the password, optionally registers
834
- * a new device, and issues tokens.
835
- *
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
843
- */
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
- };
1325
+ async fetchUserInfo(provider, accessToken) {
1326
+ let response;
1327
+ try {
1328
+ response = await this.fetchFn(provider.userInfoUrl, {
1329
+ headers: {
1330
+ Authorization: `Bearer ${accessToken}`,
1331
+ Accept: "application/json"
1332
+ }
1333
+ });
1334
+ } catch (err) {
1335
+ throw new OAuthUserInfoError(err instanceof Error ? err.message : "Network error");
851
1336
  }
852
- await this.rateLimiter.record(rateLimitKey);
853
- const storedUser = await this.userStore.findByEmail(body.email);
854
- if (storedUser === null) {
1337
+ if (!response.ok) {
1338
+ throw new OAuthUserInfoError(`HTTP ${response.status}`);
1339
+ }
1340
+ const profile = await response.json();
1341
+ return normalizeUserInfo(provider.providerId, profile);
1342
+ }
1343
+ };
1344
+ function normalizeUserInfo(providerId, profile) {
1345
+ switch (providerId) {
1346
+ case "google":
855
1347
  return {
856
- status: 401,
857
- body: { error: "Invalid email or password." }
1348
+ providerId: profile.sub,
1349
+ provider: "google",
1350
+ email: profile.email ?? null,
1351
+ emailVerified: profile.email_verified ?? false,
1352
+ name: profile.name ?? null,
1353
+ avatarUrl: profile.picture ?? null,
1354
+ rawProfile: profile
858
1355
  };
859
- }
860
- const passwordValid = await verifyPassword(
861
- body.password,
862
- storedUser.passwordHash,
863
- storedUser.salt
864
- );
865
- if (!passwordValid) {
1356
+ case "github":
866
1357
  return {
867
- status: 401,
868
- body: { error: "Invalid email or password." }
1358
+ providerId: String(profile.id),
1359
+ provider: "github",
1360
+ email: profile.email ?? null,
1361
+ emailVerified: false,
1362
+ // GitHub doesn't confirm in the profile response
1363
+ name: profile.name ?? profile.login ?? null,
1364
+ avatarUrl: profile.avatar_url ?? null,
1365
+ rawProfile: profile
869
1366
  };
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
- };
887
- return {
888
- status: 200,
889
- body: { data: { user, tokens } }
890
- };
891
- }
892
- /**
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
902
- */
903
- async handleRefresh(body) {
904
- const result = await this.tokenManager.refreshAccessToken(body.refreshToken);
905
- if (result === null) {
1367
+ case "microsoft":
906
1368
  return {
907
- status: 401,
908
- body: { error: "Invalid or expired refresh token." }
1369
+ providerId: profile.id,
1370
+ provider: "microsoft",
1371
+ email: profile.mail ?? profile.userPrincipalName ?? null,
1372
+ emailVerified: false,
1373
+ name: profile.displayName ?? null,
1374
+ avatarUrl: null,
1375
+ rawProfile: profile
909
1376
  };
910
- }
911
- return {
912
- status: 200,
913
- body: { data: result }
914
- };
915
- }
916
- /**
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
927
- */
928
- async handleSignOut(accessToken, body) {
929
- const payload = this.tokenManager.validateToken(accessToken);
930
- if (payload === null || payload.type !== "access") {
1377
+ default:
931
1378
  return {
932
- status: 401,
933
- body: { error: "Invalid or expired access token." }
1379
+ providerId: String(profile.id ?? profile.sub ?? ""),
1380
+ provider: providerId,
1381
+ email: profile.email ?? null,
1382
+ emailVerified: profile.email_verified ?? false,
1383
+ name: profile.name ?? null,
1384
+ avatarUrl: profile.picture ?? profile.avatar_url ?? null,
1385
+ rawProfile: profile
934
1386
  };
935
- }
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);
941
- }
942
- }
943
- return {
944
- status: 200,
945
- body: { data: { success: true } }
946
- };
947
1387
  }
1388
+ }
1389
+ function googleProvider(config) {
1390
+ return {
1391
+ providerId: "google",
1392
+ clientId: config.clientId,
1393
+ clientSecret: config.clientSecret,
1394
+ authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
1395
+ tokenUrl: "https://oauth2.googleapis.com/token",
1396
+ userInfoUrl: "https://openidconnect.googleapis.com/v1/userinfo",
1397
+ scopes: config.scopes ?? ["openid", "email", "profile"],
1398
+ redirectUri: config.redirectUri,
1399
+ pkce: config.pkce
1400
+ };
1401
+ }
1402
+ function githubProvider(config) {
1403
+ return {
1404
+ providerId: "github",
1405
+ clientId: config.clientId,
1406
+ clientSecret: config.clientSecret,
1407
+ authorizationUrl: "https://github.com/login/oauth/authorize",
1408
+ tokenUrl: "https://github.com/login/oauth/access_token",
1409
+ userInfoUrl: "https://api.github.com/user",
1410
+ scopes: config.scopes ?? ["read:user", "user:email"],
1411
+ redirectUri: config.redirectUri,
1412
+ pkce: config.pkce
1413
+ };
1414
+ }
1415
+ function microsoftProvider(config) {
1416
+ const tenant = config.tenantId ?? "common";
1417
+ return {
1418
+ providerId: "microsoft",
1419
+ clientId: config.clientId,
1420
+ clientSecret: config.clientSecret,
1421
+ authorizationUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`,
1422
+ tokenUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`,
1423
+ userInfoUrl: "https://graph.microsoft.com/v1.0/me",
1424
+ scopes: config.scopes ?? ["openid", "email", "profile", "User.Read"],
1425
+ redirectUri: config.redirectUri,
1426
+ pkce: config.pkce
1427
+ };
1428
+ }
1429
+ function generateState() {
1430
+ const bytes = new Uint8Array(32);
1431
+ globalThis.crypto.getRandomValues(bytes);
1432
+ return toBase64Url2(bytes);
1433
+ }
1434
+ function generateCodeVerifier() {
1435
+ const bytes = new Uint8Array(PKCE_VERIFIER_BYTES);
1436
+ globalThis.crypto.getRandomValues(bytes);
1437
+ return toBase64Url2(bytes);
1438
+ }
1439
+ async function createCodeChallenge(codeVerifier) {
1440
+ const data = new TextEncoder().encode(codeVerifier);
1441
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", data);
1442
+ return toBase64Url2(new Uint8Array(digest));
1443
+ }
1444
+ function toBase64Url2(bytes) {
1445
+ let binary = "";
1446
+ for (let i = 0; i < bytes.length; i++) {
1447
+ binary += String.fromCharCode(bytes[i]);
1448
+ }
1449
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1450
+ }
1451
+
1452
+ // src/provider/built-in/user-store.ts
1453
+ import { randomUUID as randomUUID3 } from "crypto";
1454
+ import { KoraError as KoraError4 } from "@korajs/core";
1455
+ var DuplicateEmailError = class extends KoraError4 {
1456
+ constructor() {
1457
+ super("A user with this email already exists.", "DUPLICATE_EMAIL");
1458
+ this.name = "DuplicateEmailError";
1459
+ }
1460
+ };
1461
+ var InMemoryUserStore = class {
1462
+ /** Users indexed by ID */
1463
+ usersById = /* @__PURE__ */ new Map();
1464
+ /** Users indexed by email (lowercase) for fast lookup */
1465
+ usersByEmail = /* @__PURE__ */ new Map();
1466
+ /** Devices indexed by device ID */
1467
+ devicesById = /* @__PURE__ */ new Map();
1468
+ /** Device IDs indexed by user ID for fast listing */
1469
+ devicesByUserId = /* @__PURE__ */ new Map();
948
1470
  /**
949
- * Handle get-current-user (GET /auth/me).
950
- *
951
- * Validates the access token and returns the authenticated user's profile.
1471
+ * Create a new user account.
952
1472
  *
953
- * @param accessToken - The JWT access token (without "Bearer " prefix)
954
- * @returns Auth response with the user profile, or an error
1473
+ * @param params - User creation parameters
1474
+ * @param params.email - The user's email address (must be unique, case-insensitive)
1475
+ * @param params.passwordHash - Hex-encoded PBKDF2 derived key
1476
+ * @param params.salt - Hex-encoded salt used during hashing
1477
+ * @param params.name - The user's display name
1478
+ * @returns The created user (without sensitive credential fields)
1479
+ * @throws {DuplicateEmailError} If a user with the same email already exists
955
1480
  */
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
- };
1481
+ async createUser(params) {
1482
+ const normalizedEmail = params.email.toLowerCase();
1483
+ if (this.usersByEmail.has(normalizedEmail)) {
1484
+ throw new DuplicateEmailError();
970
1485
  }
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 }
1486
+ const now = Date.now();
1487
+ const id = randomUUID3();
1488
+ const storedUser = {
1489
+ id,
1490
+ email: normalizedEmail,
1491
+ name: params.name,
1492
+ emailVerified: false,
1493
+ createdAt: now,
1494
+ passwordHash: params.passwordHash,
1495
+ salt: params.salt
981
1496
  };
1497
+ this.usersById.set(id, storedUser);
1498
+ this.usersByEmail.set(normalizedEmail, storedUser);
1499
+ return toAuthUser(storedUser);
982
1500
  }
983
1501
  /**
984
- * Handle list-devices (GET /auth/devices).
985
- *
986
- * Validates the access token and returns all devices registered for the user.
1502
+ * Find a user by email address.
987
1503
  *
988
- * @param accessToken - The JWT access token (without "Bearer " prefix)
989
- * @returns Auth response with the device list, or an error
1504
+ * @param email - The email to search for (case-insensitive)
1505
+ * @returns The stored user record including credentials, or null if not found
990
1506
  */
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 }
1003
- };
1507
+ async findByEmail(email) {
1508
+ return this.usersByEmail.get(email.toLowerCase()) ?? null;
1004
1509
  }
1005
1510
  /**
1006
- * Handle device revocation (DELETE /auth/device/:id).
1007
- *
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.
1511
+ * Find a user by ID.
1010
1512
  *
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
1513
+ * @param id - The user ID to search for
1514
+ * @returns The stored user record including credentials, or null if not found
1014
1515
  */
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 } }
1041
- };
1516
+ async findById(id) {
1517
+ return this.usersById.get(id) ?? null;
1042
1518
  }
1043
1519
  /**
1044
- * Handle device registration (POST /auth/device/register).
1520
+ * Register a device for a user.
1045
1521
  *
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.
1522
+ * If a device with the same ID already exists and is not revoked, it is
1523
+ * returned as-is (idempotent registration). If it was previously revoked,
1524
+ * it is re-activated with updated details.
1048
1525
  *
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
1526
+ * @param params - Device registration parameters
1527
+ * @param params.id - Unique device identifier
1528
+ * @param params.userId - ID of the user who owns the device
1529
+ * @param params.publicKey - Base64url-encoded device public key or thumbprint
1530
+ * @param params.name - Human-readable device name
1531
+ * @returns The registered device record
1055
1532
  */
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
- };
1533
+ async registerDevice(params) {
1534
+ const existing = this.devicesById.get(params.id);
1535
+ if (existing !== void 0 && !existing.revoked) {
1536
+ return existing;
1088
1537
  }
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 } }
1538
+ const now = Date.now();
1539
+ const device = {
1540
+ id: params.id,
1541
+ userId: params.userId,
1542
+ publicKey: params.publicKey,
1543
+ name: params.name,
1544
+ revoked: false,
1545
+ createdAt: now,
1546
+ lastSeenAt: now
1103
1547
  };
1104
- }
1105
- /**
1106
- * Generate a challenge for device proof-of-possession verification.
1107
- *
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}.
1112
- *
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
1116
- */
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
- };
1124
- }
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
- };
1131
- }
1132
- if (device.revoked) {
1133
- return {
1134
- status: 403,
1135
- body: { error: "Device has been revoked." }
1136
- };
1548
+ this.devicesById.set(params.id, device);
1549
+ let userDevices = this.devicesByUserId.get(params.userId);
1550
+ if (userDevices === void 0) {
1551
+ userDevices = /* @__PURE__ */ new Set();
1552
+ this.devicesByUserId.set(params.userId, userDevices);
1137
1553
  }
1138
- const challenge = randomBytes2(32).toString("hex");
1139
- const expiresAt = Date.now() + CHALLENGE_TTL_MS;
1140
- await this.challengeStore.store(challenge, deviceId, expiresAt);
1141
- return {
1142
- status: 200,
1143
- body: { data: { challenge } }
1144
- };
1554
+ userDevices.add(params.id);
1555
+ return device;
1145
1556
  }
1146
1557
  /**
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.
1558
+ * Find a device by its ID.
1152
1559
  *
1153
- * On success, issues fresh tokens for the device.
1560
+ * @param deviceId - The device ID to search for
1561
+ * @returns The device record, or null if not found
1562
+ */
1563
+ async findDevice(deviceId) {
1564
+ return this.devicesById.get(deviceId) ?? null;
1565
+ }
1566
+ /**
1567
+ * List all devices registered for a user.
1154
1568
  *
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
1569
+ * @param userId - The user ID whose devices to list
1570
+ * @returns Array of device records (includes revoked devices)
1160
1571
  */
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
- };
1572
+ async listDevices(userId) {
1573
+ const deviceIds = this.devicesByUserId.get(userId);
1574
+ if (deviceIds === void 0) {
1575
+ return [];
1211
1576
  }
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
- };
1577
+ const devices = [];
1578
+ for (const deviceId of deviceIds) {
1579
+ const device = this.devicesById.get(deviceId);
1580
+ if (device !== void 0) {
1581
+ devices.push(device);
1582
+ }
1220
1583
  }
1221
- const tokens = this.tokenManager.issueTokens(device.userId, device.id, thumbprint);
1222
- return {
1223
- status: 200,
1224
- body: { data: { tokens } }
1225
- };
1584
+ return devices;
1226
1585
  }
1227
1586
  /**
1228
- * Generates a random challenge string for proof-of-possession verification.
1587
+ * Revoke a device, preventing it from being used for authentication.
1229
1588
  *
1230
- * **Deprecated:** Use {@link handleDeviceChallenge} instead, which stores
1231
- * the challenge server-side with expiry and single-use semantics.
1589
+ * This is a soft revoke the device record remains but is marked as revoked.
1590
+ * If the device does not exist, this is a no-op.
1232
1591
  *
1233
- * @returns A 64-character hex string (32 random bytes)
1592
+ * @param deviceId - The ID of the device to revoke
1234
1593
  */
1235
- static generateChallenge() {
1236
- return randomBytes2(32).toString("hex");
1594
+ async revokeDevice(deviceId) {
1595
+ const device = this.devicesById.get(deviceId);
1596
+ if (device !== void 0) {
1597
+ device.revoked = true;
1598
+ }
1237
1599
  }
1238
1600
  /**
1239
- * Creates a sync server auth provider compatible with `@korajs/server`.
1601
+ * Set a user's email verification status.
1240
1602
  *
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.
1603
+ * @param userId - The user whose email to verify
1604
+ * @param verified - Whether the email is verified
1605
+ */
1606
+ async setEmailVerified(userId, verified) {
1607
+ const user = this.usersById.get(userId);
1608
+ if (!user) return;
1609
+ const updated = { ...user, emailVerified: verified };
1610
+ this.usersById.set(userId, updated);
1611
+ this.usersByEmail.set(user.email, updated);
1612
+ }
1613
+ /**
1614
+ * Update a user's password hash and salt.
1245
1615
  *
1246
- * Also checks device revocation status during authentication, ensuring
1247
- * that revoked devices are rejected even if their tokens haven't expired.
1616
+ * @param userId - The user whose password to update
1617
+ * @param passwordHash - New hex-encoded PBKDF2 derived key
1618
+ * @param salt - New hex-encoded salt
1619
+ */
1620
+ async updatePassword(userId, passwordHash, salt) {
1621
+ const user = this.usersById.get(userId);
1622
+ if (!user) return;
1623
+ const updated = { ...user, passwordHash, salt };
1624
+ this.usersById.set(userId, updated);
1625
+ this.usersByEmail.set(user.email, updated);
1626
+ }
1627
+ /**
1628
+ * List all users. For admin/development use.
1629
+ */
1630
+ async listAll() {
1631
+ return [...this.usersById.values()];
1632
+ }
1633
+ /**
1634
+ * Update a stored user record.
1635
+ */
1636
+ async update(user) {
1637
+ const existing = this.usersById.get(user.id);
1638
+ if (!existing) return;
1639
+ if (existing.email !== user.email) {
1640
+ this.usersByEmail.delete(existing.email);
1641
+ this.usersByEmail.set(user.email, user);
1642
+ } else {
1643
+ this.usersByEmail.set(user.email, user);
1644
+ }
1645
+ this.usersById.set(user.id, user);
1646
+ }
1647
+ /**
1648
+ * Delete a user and all associated devices.
1649
+ */
1650
+ async delete(userId) {
1651
+ const user = this.usersById.get(userId);
1652
+ if (!user) return;
1653
+ this.usersById.delete(userId);
1654
+ this.usersByEmail.delete(user.email);
1655
+ const deviceIds = this.devicesByUserId.get(userId);
1656
+ if (deviceIds) {
1657
+ for (const deviceId of deviceIds) {
1658
+ this.devicesById.delete(deviceId);
1659
+ }
1660
+ this.devicesByUserId.delete(userId);
1661
+ }
1662
+ }
1663
+ /**
1664
+ * Update the last-seen timestamp for a device.
1248
1665
  *
1249
- * @returns An object with an `authenticate` method suitable for KoraSyncServer's `auth` config
1666
+ * Called when a device authenticates or syncs to track activity.
1667
+ * If the device does not exist, this is a no-op.
1250
1668
  *
1251
- * @example
1252
- * ```typescript
1253
- * const routes = new BuiltInAuthRoutes({ userStore, tokenManager })
1254
- * const syncServer = new KoraSyncServer({
1255
- * store,
1256
- * auth: routes.toSyncAuthProvider(),
1257
- * })
1258
- * ```
1669
+ * @param deviceId - The ID of the device to update
1259
1670
  */
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);
1671
+ async touchDevice(deviceId) {
1672
+ const device = this.devicesById.get(deviceId);
1673
+ if (device !== void 0) {
1674
+ device.lastSeenAt = Date.now();
1675
+ }
1676
+ }
1677
+ };
1678
+ function toAuthUser(stored) {
1679
+ return {
1680
+ id: stored.id,
1681
+ email: stored.email,
1682
+ name: stored.name,
1683
+ emailVerified: stored.emailVerified,
1684
+ createdAt: stored.createdAt
1685
+ };
1686
+ }
1687
+
1688
+ // src/provider/built-in/quickstart-server.ts
1689
+ function createKoraAuthServer(options = {}) {
1690
+ const userStore = options.userStore ?? new InMemoryUserStore();
1691
+ const tokenManager = options.tokenManager ?? createDefaultTokenManager(options);
1692
+ const routes = new BuiltInAuthRoutes({
1693
+ userStore,
1694
+ tokenManager,
1695
+ challengeStore: options.challengeStore,
1696
+ rateLimiter: options.rateLimiter
1697
+ });
1698
+ const oauth = options.oauth ? createOAuthRuntime(options.oauth) : void 0;
1699
+ const path = normalizePath(options.path ?? "/auth");
1700
+ return {
1701
+ routes,
1702
+ userStore,
1703
+ tokenManager,
1704
+ oauth: oauth?.manager,
1705
+ linkedIdentityStore: oauth?.linkedIdentityStore,
1706
+ auth: routes.toSyncAuthProvider(),
1707
+ handleRequest(request) {
1708
+ return handleAuthRequest(routes, path, request, oauth, userStore, tokenManager);
1709
+ }
1710
+ };
1711
+ }
1712
+ function createDefaultTokenManager(options) {
1713
+ const secret = options.jwtSecret ?? readEnvSecret();
1714
+ if (!secret && isProduction()) {
1715
+ throw new Error(
1716
+ "createKoraAuthServer requires jwtSecret in production. Set KORA_AUTH_SECRET or pass jwtSecret."
1717
+ );
1718
+ }
1719
+ return new TokenManager({
1720
+ secret: secret ?? TokenManager.generateSecret(),
1721
+ revocationStore: new InMemoryTokenRevocationStore(),
1722
+ ...options.tokenManagerOptions
1723
+ });
1724
+ }
1725
+ function createOAuthRuntime(config) {
1726
+ return {
1727
+ manager: new OAuthManager(config),
1728
+ linkedIdentityStore: config.linkedIdentityStore ?? new InMemoryLinkedIdentityStore(),
1729
+ createNewUsers: config.createNewUsers ?? true,
1730
+ autoLinkVerifiedEmail: config.autoLinkVerifiedEmail ?? false,
1731
+ allowUnlinkLastIdentity: config.allowUnlinkLastIdentity ?? false
1732
+ };
1733
+ }
1734
+ async function handleAuthRequest(routes, pathPrefix, request, oauth, userStore, tokenManager) {
1735
+ const path = normalizePath(request.path);
1736
+ const relativePath = path === pathPrefix ? "/" : path.slice(pathPrefix.length);
1737
+ const method = request.method.toUpperCase();
1738
+ const body = isRecord(request.body) ? request.body : {};
1739
+ const token = extractBearerToken(request.headers);
1740
+ if (path !== pathPrefix && !path.startsWith(`${pathPrefix}/`)) {
1741
+ return notFound();
1742
+ }
1743
+ if (relativePath.startsWith("/oauth/")) {
1744
+ return handleOAuthRequest({
1745
+ oauth,
1746
+ userStore,
1747
+ tokenManager,
1748
+ relativePath,
1749
+ method,
1750
+ body,
1751
+ query: request.query,
1752
+ token
1753
+ });
1754
+ }
1755
+ if (method === "POST" && relativePath === "/signup") {
1756
+ return routes.handleSignUp(body, request.ip);
1757
+ }
1758
+ if (method === "POST" && relativePath === "/signin") {
1759
+ return routes.handleSignIn(body, request.ip);
1760
+ }
1761
+ if (method === "POST" && relativePath === "/refresh") {
1762
+ return routes.handleRefresh(body);
1763
+ }
1764
+ if (method === "POST" && relativePath === "/signout") {
1765
+ return routes.handleSignOut(token, body);
1766
+ }
1767
+ if (method === "GET" && relativePath === "/me") {
1768
+ return routes.handleGetMe(token);
1769
+ }
1770
+ if (method === "GET" && relativePath === "/devices") {
1771
+ return routes.handleListDevices(token);
1772
+ }
1773
+ if (method === "POST" && relativePath === "/device/register") {
1774
+ return routes.handleDeviceRegister(token, body);
1775
+ }
1776
+ if (method === "POST" && relativePath === "/device/challenge") {
1777
+ const deviceId = typeof body.deviceId === "string" ? body.deviceId : "";
1778
+ return routes.handleDeviceChallenge(token, deviceId);
1779
+ }
1780
+ if (method === "POST" && relativePath === "/device/verify") {
1781
+ return routes.handleDeviceVerify(body);
1782
+ }
1783
+ if (method === "DELETE" && relativePath.startsWith("/device/")) {
1784
+ return routes.handleRevokeDevice(token, relativePath.slice("/device/".length));
1785
+ }
1786
+ return notFound();
1787
+ }
1788
+ async function handleOAuthRequest(params) {
1789
+ const { oauth, userStore, tokenManager, relativePath, method, body, query, token } = params;
1790
+ if (!oauth) {
1791
+ return notFound();
1792
+ }
1793
+ try {
1794
+ if (method === "GET" && relativePath === "/oauth/links") {
1795
+ const authUser = await requireAuthUser(tokenManager, userStore, token);
1796
+ if ("status" in authUser) return authUser;
1797
+ const identities = await oauth.linkedIdentityStore.findByUser(authUser.id);
1798
+ return { status: 200, body: { data: identities } };
1799
+ }
1800
+ const match = /^\/oauth\/([^/]+)(?:\/(callback|link))?$/.exec(relativePath);
1801
+ if (!match) {
1802
+ return notFound();
1803
+ }
1804
+ const provider = decodeURIComponent(match[1]);
1805
+ const action = match[2];
1806
+ if (method === "GET" && !action) {
1807
+ const { url, state } = await oauth.manager.getAuthorizationUrl(
1808
+ provider,
1809
+ metadataFromQuery(query)
1810
+ );
1811
+ return { status: 200, body: { data: { url, state } } };
1812
+ }
1813
+ if ((method === "GET" || method === "POST") && action === "callback") {
1814
+ const code = readString(method === "GET" ? queryValue(query, "code") : body.code);
1815
+ const state = readString(method === "GET" ? queryValue(query, "state") : body.state);
1816
+ if (!code || !state) {
1817
+ return { status: 400, body: { error: "OAuth callback requires code and state." } };
1818
+ }
1819
+ return completeOAuthSignIn({
1820
+ oauth,
1821
+ userStore,
1822
+ tokenManager,
1823
+ provider,
1824
+ code,
1825
+ state,
1826
+ deviceId: readString(body.deviceId),
1827
+ devicePublicKey: readString(body.devicePublicKey)
1828
+ });
1829
+ }
1830
+ if (method === "POST" && action === "link") {
1831
+ const authUser = await requireAuthUser(tokenManager, userStore, token);
1832
+ if ("status" in authUser) return authUser;
1833
+ const code = readString(body.code);
1834
+ const state = readString(body.state);
1835
+ if (!code || !state) {
1836
+ return { status: 400, body: { error: "OAuth linking requires code and state." } };
1837
+ }
1838
+ return linkOAuthIdentity(oauth, authUser.id, provider, code, state);
1839
+ }
1840
+ if (method === "DELETE" && action === "link") {
1841
+ const authUser = await requireAuthUser(tokenManager, userStore, token);
1842
+ if ("status" in authUser) return authUser;
1843
+ const identities = await oauth.linkedIdentityStore.findByUser(authUser.id);
1844
+ if (!oauth.allowUnlinkLastIdentity && identities.length <= 1) {
1278
1845
  return {
1279
- userId: payload.sub,
1280
- metadata: {
1281
- deviceId: payload.dev,
1282
- email: user.email,
1283
- name: user.name
1846
+ status: 409,
1847
+ body: {
1848
+ error: "Cannot unlink the last OAuth identity unless allowUnlinkLastIdentity is enabled."
1284
1849
  }
1285
1850
  };
1286
1851
  }
1852
+ await oauth.linkedIdentityStore.delete(authUser.id, provider);
1853
+ return { status: 200, body: { data: { ok: true } } };
1854
+ }
1855
+ } catch (error) {
1856
+ return oauthErrorResponse(error);
1857
+ }
1858
+ return notFound();
1859
+ }
1860
+ async function completeOAuthSignIn(params) {
1861
+ const { oauth, userStore, tokenManager, provider, code, state, deviceId, devicePublicKey } = params;
1862
+ const { userInfo, stateMetadata } = await oauth.manager.handleCallback(provider, code, state);
1863
+ const linkedIdentity = await oauth.linkedIdentityStore.findByProvider(
1864
+ userInfo.provider,
1865
+ userInfo.providerId
1866
+ );
1867
+ let user;
1868
+ let identity;
1869
+ if (linkedIdentity) {
1870
+ const storedUser = await userStore.findById(linkedIdentity.userId);
1871
+ if (!storedUser) {
1872
+ return { status: 409, body: { error: "Linked OAuth account has no matching user." } };
1873
+ }
1874
+ user = toAuthUser2(storedUser);
1875
+ identity = linkedIdentity;
1876
+ } else {
1877
+ const resolved = await resolveOAuthUser(userStore, oauth, userInfo);
1878
+ if ("status" in resolved) return resolved;
1879
+ user = resolved;
1880
+ identity = await oauth.linkedIdentityStore.create({
1881
+ userId: user.id,
1882
+ provider: userInfo.provider,
1883
+ providerUserId: userInfo.providerId,
1884
+ email: userInfo.email
1885
+ });
1886
+ }
1887
+ const resolvedDeviceId = deviceId ?? readString(stateMetadata?.deviceId) ?? `device-${user.id}`;
1888
+ await userStore.registerDevice({
1889
+ id: resolvedDeviceId,
1890
+ userId: user.id,
1891
+ publicKey: devicePublicKey ?? readString(stateMetadata?.devicePublicKey) ?? "",
1892
+ name: deviceId ? "Device" : "Browser"
1893
+ });
1894
+ const tokens = tokenManager.issueTokens(user.id, resolvedDeviceId);
1895
+ return { status: 200, body: { data: { user, tokens, identity } } };
1896
+ }
1897
+ async function resolveOAuthUser(userStore, oauth, userInfo) {
1898
+ if (!userInfo.email) {
1899
+ return { status: 400, body: { error: "OAuth provider did not return an email address." } };
1900
+ }
1901
+ const existingUser = await userStore.findByEmail(userInfo.email);
1902
+ if (existingUser) {
1903
+ if (oauth.autoLinkVerifiedEmail && userInfo.emailVerified) {
1904
+ return toAuthUser2(existingUser);
1905
+ }
1906
+ return {
1907
+ status: 409,
1908
+ body: { error: "OAuth account is not linked. Sign in and link this provider first." }
1287
1909
  };
1288
1910
  }
1289
- };
1911
+ if (!oauth.createNewUsers) {
1912
+ return { status: 403, body: { error: "OAuth sign-up is disabled for this application." } };
1913
+ }
1914
+ const credential = await hashPassword(randomUUID4());
1915
+ const user = await userStore.createUser({
1916
+ email: userInfo.email,
1917
+ passwordHash: credential.hash,
1918
+ salt: credential.salt,
1919
+ name: userInfo.name ?? userInfo.email.split("@")[0] ?? userInfo.email
1920
+ });
1921
+ if (userInfo.emailVerified) {
1922
+ await userStore.setEmailVerified(user.id, true);
1923
+ return { ...user, emailVerified: true };
1924
+ }
1925
+ return user;
1926
+ }
1927
+ async function linkOAuthIdentity(oauth, userId, provider, code, state) {
1928
+ const { userInfo } = await oauth.manager.handleCallback(provider, code, state);
1929
+ const existing = await oauth.linkedIdentityStore.findByProvider(
1930
+ userInfo.provider,
1931
+ userInfo.providerId
1932
+ );
1933
+ if (existing && existing.userId !== userId) {
1934
+ return { status: 409, body: { error: "This OAuth account is already linked to another user." } };
1935
+ }
1936
+ if (existing) {
1937
+ return { status: 200, body: { data: existing } };
1938
+ }
1939
+ const identity = await oauth.linkedIdentityStore.create({
1940
+ userId,
1941
+ provider: userInfo.provider,
1942
+ providerUserId: userInfo.providerId,
1943
+ email: userInfo.email
1944
+ });
1945
+ return { status: 201, body: { data: identity } };
1946
+ }
1947
+ async function requireAuthUser(tokenManager, userStore, token) {
1948
+ if (!token) {
1949
+ return { status: 401, body: { error: "Authorization token required." } };
1950
+ }
1951
+ const payload = await tokenManager.validateToken(token);
1952
+ if (!payload || payload.type !== "access") {
1953
+ return { status: 401, body: { error: "Invalid or expired token." } };
1954
+ }
1955
+ const user = await userStore.findById(payload.sub);
1956
+ if (!user) {
1957
+ return { status: 401, body: { error: "User not found." } };
1958
+ }
1959
+ return toAuthUser2(user);
1960
+ }
1961
+ function extractBearerToken(headers) {
1962
+ const authorization = headers?.authorization ?? headers?.Authorization;
1963
+ const value = Array.isArray(authorization) ? authorization[0] : authorization;
1964
+ if (!value?.startsWith("Bearer ")) {
1965
+ return "";
1966
+ }
1967
+ return value.slice("Bearer ".length).trim();
1968
+ }
1969
+ function normalizePath(path) {
1970
+ const withoutQuery = path.split("?")[0] || "/";
1971
+ const normalized = withoutQuery.startsWith("/") ? withoutQuery : `/${withoutQuery}`;
1972
+ return normalized.length > 1 ? normalized.replace(/\/+$/, "") : normalized;
1973
+ }
1974
+ function metadataFromQuery(query) {
1975
+ if (!query) return void 0;
1976
+ const metadata = {};
1977
+ for (const [key, value] of Object.entries(query)) {
1978
+ if (key === "code" || key === "state") continue;
1979
+ if (value !== void 0) {
1980
+ metadata[key] = value;
1981
+ }
1982
+ }
1983
+ return Object.keys(metadata).length > 0 ? metadata : void 0;
1984
+ }
1985
+ function queryValue(query, key) {
1986
+ return query?.[key];
1987
+ }
1988
+ function readString(value) {
1989
+ if (typeof value === "string" && value.length > 0) return value;
1990
+ if (Array.isArray(value) && typeof value[0] === "string" && value[0].length > 0) {
1991
+ return value[0];
1992
+ }
1993
+ return void 0;
1994
+ }
1995
+ function oauthErrorResponse(error) {
1996
+ if (error instanceof DuplicateLinkedIdentityError) {
1997
+ return { status: 409, body: { error: error.message } };
1998
+ }
1999
+ if (error instanceof OAuthError) {
2000
+ const status = error.code === "OAUTH_PROVIDER_NOT_FOUND" ? 404 : 400;
2001
+ return { status, body: { error: error.message } };
2002
+ }
2003
+ throw error;
2004
+ }
2005
+ function toAuthUser2(user) {
2006
+ return {
2007
+ id: user.id,
2008
+ email: user.email,
2009
+ name: user.name,
2010
+ emailVerified: user.emailVerified,
2011
+ createdAt: user.createdAt
2012
+ };
2013
+ }
2014
+ function isRecord(value) {
2015
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2016
+ }
2017
+ function notFound() {
2018
+ return { status: 404, body: { error: "Not found" } };
2019
+ }
2020
+ function readEnvSecret() {
2021
+ return typeof process !== "undefined" ? process.env.KORA_AUTH_SECRET : void 0;
2022
+ }
2023
+ function isProduction() {
2024
+ return typeof process !== "undefined" && process.env.NODE_ENV === "production";
2025
+ }
1290
2026
 
1291
2027
  // src/provider/built-in/password-reset.ts
1292
- import { KoraError as KoraError3 } from "@korajs/core";
1293
- var PasswordResetError = class extends KoraError3 {
2028
+ import { KoraError as KoraError5 } from "@korajs/core";
2029
+ var PasswordResetError = class extends KoraError5 {
1294
2030
  constructor(message, code, context) {
1295
2031
  super(message, code, context);
1296
2032
  this.name = "PasswordResetError";
@@ -1375,7 +2111,11 @@ var PasswordResetManager = class {
1375
2111
  const normalizedEmail = email.toLowerCase().trim();
1376
2112
  const successResponse = {
1377
2113
  status: 200,
1378
- body: { data: { message: "If an account with that email exists, a password reset link has been sent." } }
2114
+ body: {
2115
+ data: {
2116
+ message: "If an account with that email exists, a password reset link has been sent."
2117
+ }
2118
+ }
1379
2119
  };
1380
2120
  const user = await this.userStore.findByEmail(normalizedEmail);
1381
2121
  if (!user) {
@@ -1456,7 +2196,7 @@ var PasswordResetManager = class {
1456
2196
  if (!user) {
1457
2197
  return { status: 404, body: { error: "User not found." } };
1458
2198
  }
1459
- const { verifyPassword: verifyPassword2 } = await import("./password-hash-HDH6VQCQ.js");
2199
+ const { verifyPassword: verifyPassword2 } = await import("./password-hash-QRBG6BNJ.js");
1460
2200
  const isValid = await verifyPassword2(currentPassword, user.passwordHash, user.salt);
1461
2201
  if (!isValid) {
1462
2202
  return { status: 401, body: { error: "Current password is incorrect." } };
@@ -1477,8 +2217,8 @@ function generateSecureToken() {
1477
2217
  }
1478
2218
 
1479
2219
  // src/provider/built-in/email-verification.ts
1480
- import { KoraError as KoraError4 } from "@korajs/core";
1481
- var EmailVerificationError = class extends KoraError4 {
2220
+ import { KoraError as KoraError6 } from "@korajs/core";
2221
+ var EmailVerificationError = class extends KoraError6 {
1482
2222
  constructor(message, code, context) {
1483
2223
  super(message, code, context);
1484
2224
  this.name = "EmailVerificationError";
@@ -1553,7 +2293,10 @@ var EmailVerificationManager = class {
1553
2293
  const normalizedEmail = email.toLowerCase().trim();
1554
2294
  const activeCount = await this.verificationStore.countActiveForUser(userId);
1555
2295
  if (activeCount >= this.maxRequestsPerUser) {
1556
- return { status: 429, body: { error: "Too many verification requests. Please try again later." } };
2296
+ return {
2297
+ status: 429,
2298
+ body: { error: "Too many verification requests. Please try again later." }
2299
+ };
1557
2300
  }
1558
2301
  const token = generateSecureToken2();
1559
2302
  const now = Date.now();
@@ -1628,7 +2371,7 @@ function generateSecureToken2() {
1628
2371
  }
1629
2372
 
1630
2373
  // src/provider/built-in/sqlite-user-store.ts
1631
- import { randomUUID as randomUUID3 } from "crypto";
2374
+ import { randomUUID as randomUUID5 } from "crypto";
1632
2375
  var SqliteUserStore = class {
1633
2376
  db;
1634
2377
  constructor(db) {
@@ -1665,7 +2408,7 @@ var SqliteUserStore = class {
1665
2408
  async createUser(params) {
1666
2409
  const normalizedEmail = params.email.toLowerCase();
1667
2410
  const now = Date.now();
1668
- const id = randomUUID3();
2411
+ const id = randomUUID5();
1669
2412
  try {
1670
2413
  this.db.prepare(`
1671
2414
  INSERT INTO auth_users (id, email, name, email_verified, created_at, password_hash, salt)
@@ -1680,21 +2423,15 @@ var SqliteUserStore = class {
1680
2423
  return { id, email: normalizedEmail, name: params.name, emailVerified: false, createdAt: now };
1681
2424
  }
1682
2425
  async findByEmail(email) {
1683
- const row = this.db.prepare(
1684
- "SELECT * FROM auth_users WHERE email = ?"
1685
- ).get(email.toLowerCase());
2426
+ const row = this.db.prepare("SELECT * FROM auth_users WHERE email = ?").get(email.toLowerCase());
1686
2427
  return row ? rowToStoredUser(row) : null;
1687
2428
  }
1688
2429
  async findById(id) {
1689
- const row = this.db.prepare(
1690
- "SELECT * FROM auth_users WHERE id = ?"
1691
- ).get(id);
2430
+ const row = this.db.prepare("SELECT * FROM auth_users WHERE id = ?").get(id);
1692
2431
  return row ? rowToStoredUser(row) : null;
1693
2432
  }
1694
2433
  async registerDevice(params) {
1695
- const existing = this.db.prepare(
1696
- "SELECT * FROM auth_devices WHERE id = ?"
1697
- ).get(params.id);
2434
+ const existing = this.db.prepare("SELECT * FROM auth_devices WHERE id = ?").get(params.id);
1698
2435
  if (existing && !existing.revoked) {
1699
2436
  return rowToDevice(existing);
1700
2437
  }
@@ -1721,29 +2458,21 @@ var SqliteUserStore = class {
1721
2458
  };
1722
2459
  }
1723
2460
  async findDevice(deviceId) {
1724
- const row = this.db.prepare(
1725
- "SELECT * FROM auth_devices WHERE id = ?"
1726
- ).get(deviceId);
2461
+ const row = this.db.prepare("SELECT * FROM auth_devices WHERE id = ?").get(deviceId);
1727
2462
  return row ? rowToDevice(row) : null;
1728
2463
  }
1729
2464
  async listDevices(userId) {
1730
- const rows = this.db.prepare(
1731
- "SELECT * FROM auth_devices WHERE user_id = ?"
1732
- ).all(userId);
2465
+ const rows = this.db.prepare("SELECT * FROM auth_devices WHERE user_id = ?").all(userId);
1733
2466
  return rows.map(rowToDevice);
1734
2467
  }
1735
2468
  async revokeDevice(deviceId) {
1736
2469
  this.db.prepare("UPDATE auth_devices SET revoked = 1 WHERE id = ?").run(deviceId);
1737
2470
  }
1738
2471
  async setEmailVerified(userId, verified) {
1739
- this.db.prepare(
1740
- "UPDATE auth_users SET email_verified = ? WHERE id = ?"
1741
- ).run(verified ? 1 : 0, userId);
2472
+ this.db.prepare("UPDATE auth_users SET email_verified = ? WHERE id = ?").run(verified ? 1 : 0, userId);
1742
2473
  }
1743
2474
  async updatePassword(userId, passwordHash, salt) {
1744
- this.db.prepare(
1745
- "UPDATE auth_users SET password_hash = ?, salt = ? WHERE id = ?"
1746
- ).run(passwordHash, salt, userId);
2475
+ this.db.prepare("UPDATE auth_users SET password_hash = ?, salt = ? WHERE id = ?").run(passwordHash, salt, userId);
1747
2476
  }
1748
2477
  async listAll() {
1749
2478
  const rows = this.db.prepare("SELECT * FROM auth_users").all();
@@ -1764,9 +2493,7 @@ var SqliteUserStore = class {
1764
2493
  deleteInTransaction();
1765
2494
  }
1766
2495
  async touchDevice(deviceId) {
1767
- this.db.prepare(
1768
- "UPDATE auth_devices SET last_seen_at = ? WHERE id = ?"
1769
- ).run(Date.now(), deviceId);
2496
+ this.db.prepare("UPDATE auth_devices SET last_seen_at = ? WHERE id = ?").run(Date.now(), deviceId);
1770
2497
  }
1771
2498
  };
1772
2499
  async function createSqliteUserStore(options) {
@@ -1809,7 +2536,7 @@ function rowToDevice(row) {
1809
2536
  }
1810
2537
 
1811
2538
  // src/provider/built-in/postgres-user-store.ts
1812
- import { randomUUID as randomUUID4 } from "crypto";
2539
+ import { randomUUID as randomUUID6 } from "crypto";
1813
2540
  var PostgresUserStore = class {
1814
2541
  sql;
1815
2542
  ready;
@@ -1851,7 +2578,7 @@ var PostgresUserStore = class {
1851
2578
  await this.ready;
1852
2579
  const normalizedEmail = params.email.toLowerCase();
1853
2580
  const now = Date.now();
1854
- const id = randomUUID4();
2581
+ const id = randomUUID6();
1855
2582
  try {
1856
2583
  await this.sql`
1857
2584
  INSERT INTO auth_users (id, email, name, email_verified, created_at, password_hash, salt)
@@ -1884,7 +2611,7 @@ var PostgresUserStore = class {
1884
2611
  const existingRows = await this.sql`
1885
2612
  SELECT * FROM auth_devices WHERE id = ${params.id}
1886
2613
  `;
1887
- if (existingRows.length > 0 && !existingRows[0].revoked) {
2614
+ if (existingRows.length > 0 && !existingRows[0]?.revoked) {
1888
2615
  return rowToDevice2(existingRows[0]);
1889
2616
  }
1890
2617
  const now = Date.now();
@@ -1905,7 +2632,7 @@ var PostgresUserStore = class {
1905
2632
  publicKey: params.publicKey,
1906
2633
  name: params.name,
1907
2634
  revoked: false,
1908
- createdAt: existingRows.length > 0 ? Number(existingRows[0].created_at) : now,
2635
+ createdAt: existingRows.length > 0 ? Number(existingRows[0]?.created_at) : now,
1909
2636
  lastSeenAt: now
1910
2637
  };
1911
2638
  }
@@ -2087,8 +2814,8 @@ var BuiltInProvider = class {
2087
2814
  };
2088
2815
 
2089
2816
  // src/provider/external/external-jwt-provider.ts
2090
- import { KoraError as KoraError5 } from "@korajs/core";
2091
- var ExternalAuthOperationNotSupportedError = class extends KoraError5 {
2817
+ import { KoraError as KoraError7 } from "@korajs/core";
2818
+ var ExternalAuthOperationNotSupportedError = class extends KoraError7 {
2092
2819
  constructor(operation, provider) {
2093
2820
  super(
2094
2821
  `The "${operation}" operation is not supported by the external auth provider "${provider}". Perform this operation through your external auth provider's SDK or dashboard instead.`,
@@ -2098,18 +2825,14 @@ var ExternalAuthOperationNotSupportedError = class extends KoraError5 {
2098
2825
  this.name = "ExternalAuthOperationNotSupportedError";
2099
2826
  }
2100
2827
  };
2101
- var ExternalTokenValidationError = class extends KoraError5 {
2828
+ var ExternalTokenValidationError = class extends KoraError7 {
2102
2829
  constructor(reason, context) {
2103
- super(
2104
- `External token validation failed: ${reason}`,
2105
- "AUTH_EXTERNAL_TOKEN_INVALID",
2106
- context
2107
- );
2830
+ super(`External token validation failed: ${reason}`, "AUTH_EXTERNAL_TOKEN_INVALID", context);
2108
2831
  this.name = "ExternalTokenValidationError";
2109
2832
  }
2110
2833
  };
2111
2834
  function defaultMapClaims(claims) {
2112
- const sub = claims["sub"];
2835
+ const sub = claims.sub;
2113
2836
  if (typeof sub !== "string" || sub.length === 0) {
2114
2837
  throw new ExternalTokenValidationError(
2115
2838
  'JWT is missing a valid "sub" (subject) claim. The "sub" claim must be a non-empty string identifying the user.',
@@ -2118,8 +2841,8 @@ function defaultMapClaims(claims) {
2118
2841
  }
2119
2842
  return {
2120
2843
  userId: sub,
2121
- email: typeof claims["email"] === "string" ? claims["email"] : void 0,
2122
- name: typeof claims["name"] === "string" ? claims["name"] : void 0,
2844
+ email: typeof claims.email === "string" ? claims.email : void 0,
2845
+ name: typeof claims.name === "string" ? claims.name : void 0,
2123
2846
  metadata: void 0
2124
2847
  };
2125
2848
  }
@@ -2318,23 +3041,23 @@ var ExternalJwtProvider = class {
2318
3041
 
2319
3042
  // src/provider/external/clerk-adapter.ts
2320
3043
  function defaultClerkClaimMapping(claims) {
2321
- const sub = claims["sub"];
3044
+ const sub = claims.sub;
2322
3045
  if (typeof sub !== "string" || sub.length === 0) {
2323
3046
  return { userId: "" };
2324
3047
  }
2325
- const firstName = typeof claims["first_name"] === "string" ? claims["first_name"] : "";
2326
- const lastName = typeof claims["last_name"] === "string" ? claims["last_name"] : "";
3048
+ const firstName = typeof claims.first_name === "string" ? claims.first_name : "";
3049
+ const lastName = typeof claims.last_name === "string" ? claims.last_name : "";
2327
3050
  const fullName = [firstName, lastName].filter(Boolean).join(" ");
2328
- const email = typeof claims["email"] === "string" ? claims["email"] : void 0;
3051
+ const email = typeof claims.email === "string" ? claims.email : void 0;
2329
3052
  const metadata = {};
2330
- if (typeof claims["org_id"] === "string") {
2331
- metadata["orgId"] = claims["org_id"];
3053
+ if (typeof claims.org_id === "string") {
3054
+ metadata.orgId = claims.org_id;
2332
3055
  }
2333
- if (typeof claims["org_slug"] === "string") {
2334
- metadata["orgSlug"] = claims["org_slug"];
3056
+ if (typeof claims.org_slug === "string") {
3057
+ metadata.orgSlug = claims.org_slug;
2335
3058
  }
2336
- if (typeof claims["org_role"] === "string") {
2337
- metadata["orgRole"] = claims["org_role"];
3059
+ if (typeof claims.org_role === "string") {
3060
+ metadata.orgRole = claims.org_role;
2338
3061
  }
2339
3062
  return {
2340
3063
  userId: sub,
@@ -2353,30 +3076,30 @@ function createClerkAdapter(config) {
2353
3076
 
2354
3077
  // src/provider/external/supabase-adapter.ts
2355
3078
  function defaultSupabaseClaimMapping(claims) {
2356
- const sub = claims["sub"];
3079
+ const sub = claims.sub;
2357
3080
  if (typeof sub !== "string" || sub.length === 0) {
2358
3081
  return { userId: "" };
2359
3082
  }
2360
- const email = typeof claims["email"] === "string" ? claims["email"] : void 0;
3083
+ const email = typeof claims.email === "string" ? claims.email : void 0;
2361
3084
  let name;
2362
- const userMetadata = claims["user_metadata"];
3085
+ const userMetadata = claims.user_metadata;
2363
3086
  if (typeof userMetadata === "object" && userMetadata !== null && !Array.isArray(userMetadata)) {
2364
3087
  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"];
3088
+ if (typeof meta.full_name === "string" && meta.full_name.length > 0) {
3089
+ name = meta.full_name;
3090
+ } else if (typeof meta.name === "string" && meta.name.length > 0) {
3091
+ name = meta.name;
2369
3092
  }
2370
3093
  }
2371
3094
  const metadata = {};
2372
- if (typeof claims["role"] === "string") {
2373
- metadata["role"] = claims["role"];
3095
+ if (typeof claims.role === "string") {
3096
+ metadata.role = claims.role;
2374
3097
  }
2375
- if (typeof claims["aud"] === "string") {
2376
- metadata["aud"] = claims["aud"];
3098
+ if (typeof claims.aud === "string") {
3099
+ metadata.aud = claims.aud;
2377
3100
  }
2378
- if (typeof claims["app_metadata"] === "object" && claims["app_metadata"] !== null && !Array.isArray(claims["app_metadata"])) {
2379
- metadata["appMetadata"] = claims["app_metadata"];
3101
+ if (typeof claims.app_metadata === "object" && claims.app_metadata !== null && !Array.isArray(claims.app_metadata)) {
3102
+ metadata.appMetadata = claims.app_metadata;
2380
3103
  }
2381
3104
  return {
2382
3105
  userId: sub,
@@ -2395,8 +3118,8 @@ function createSupabaseAdapter(config) {
2395
3118
 
2396
3119
  // src/passkey/passkey-server.ts
2397
3120
  import { randomBytes as randomBytes3 } from "crypto";
2398
- import { KoraError as KoraError6 } from "@korajs/core";
2399
- var PasskeyVerificationError = class extends KoraError6 {
3121
+ import { KoraError as KoraError8 } from "@korajs/core";
3122
+ var PasskeyVerificationError = class extends KoraError8 {
2400
3123
  constructor(message, context) {
2401
3124
  super(message, "PASSKEY_VERIFICATION_ERROR", context);
2402
3125
  this.name = "PasskeyVerificationError";
@@ -2404,10 +3127,12 @@ var PasskeyVerificationError = class extends KoraError6 {
2404
3127
  };
2405
3128
  function generateRegistrationOptions(params) {
2406
3129
  const challengeBytes = randomBytes3(32);
2407
- const challenge = toBase64Url(challengeBytes.buffer.slice(
2408
- challengeBytes.byteOffset,
2409
- challengeBytes.byteOffset + challengeBytes.byteLength
2410
- ));
3130
+ const challenge = toBase64Url(
3131
+ challengeBytes.buffer.slice(
3132
+ challengeBytes.byteOffset,
3133
+ challengeBytes.byteOffset + challengeBytes.byteLength
3134
+ )
3135
+ );
2411
3136
  return {
2412
3137
  challenge,
2413
3138
  rpId: params.rpId,
@@ -2517,10 +3242,12 @@ async function verifyRegistrationResponse(params) {
2517
3242
  }
2518
3243
  function generateAuthenticationOptions(params) {
2519
3244
  const challengeBytes = randomBytes3(32);
2520
- const challenge = toBase64Url(challengeBytes.buffer.slice(
2521
- challengeBytes.byteOffset,
2522
- challengeBytes.byteOffset + challengeBytes.byteLength
2523
- ));
3245
+ const challenge = toBase64Url(
3246
+ challengeBytes.buffer.slice(
3247
+ challengeBytes.byteOffset,
3248
+ challengeBytes.byteOffset + challengeBytes.byteLength
3249
+ )
3250
+ );
2524
3251
  return {
2525
3252
  challenge,
2526
3253
  rpId: params.rpId,
@@ -2575,9 +3302,7 @@ async function verifyAuthenticationResponse(params) {
2575
3302
  }
2576
3303
  const flags = authDataBytes[32];
2577
3304
  if ((flags & 1) === 0) {
2578
- throw new PasskeyVerificationError(
2579
- "User Present flag is not set in authenticator data."
2580
- );
3305
+ throw new PasskeyVerificationError("User Present flag is not set in authenticator data.");
2581
3306
  }
2582
3307
  const signCount = (authDataBytes[33] << 24 | authDataBytes[34] << 16 | authDataBytes[35] << 8 | authDataBytes[36]) >>> 0;
2583
3308
  if (previousSignCount > 0 || signCount > 0) {
@@ -2592,9 +3317,7 @@ async function verifyAuthenticationResponse(params) {
2592
3317
  }
2593
3318
  }
2594
3319
  const clientDataHash = await sha256(clientDataBytes);
2595
- const signedData = new Uint8Array(
2596
- authDataBytes.length + clientDataHash.byteLength
2597
- );
3320
+ const signedData = new Uint8Array(authDataBytes.length + clientDataHash.byteLength);
2598
3321
  signedData.set(authDataBytes, 0);
2599
3322
  signedData.set(new Uint8Array(clientDataHash), authDataBytes.length);
2600
3323
  const coseKeyBytes = fromBase64Url(publicKey);
@@ -2651,10 +3374,9 @@ async function verifyAuthenticationResponse(params) {
2651
3374
  signedData.buffer
2652
3375
  );
2653
3376
  } catch (error) {
2654
- throw new PasskeyVerificationError(
2655
- "Signature verification operation failed.",
2656
- { cause: error instanceof Error ? error.message : String(error) }
2657
- );
3377
+ throw new PasskeyVerificationError("Signature verification operation failed.", {
3378
+ cause: error instanceof Error ? error.message : String(error)
3379
+ });
2658
3380
  }
2659
3381
  if (!verified) {
2660
3382
  return { verified: false, newSignCount: signCount };
@@ -2677,9 +3399,7 @@ function constantTimeEqual(a, b) {
2677
3399
  function derToP1363(derSignature, componentLength) {
2678
3400
  let offset = 0;
2679
3401
  if (derSignature[offset] !== 48) {
2680
- throw new PasskeyVerificationError(
2681
- "Invalid DER signature: expected SEQUENCE tag (0x30)."
2682
- );
3402
+ throw new PasskeyVerificationError("Invalid DER signature: expected SEQUENCE tag (0x30).");
2683
3403
  }
2684
3404
  offset += 1;
2685
3405
  if (derSignature[offset] & 128) {
@@ -2725,7 +3445,7 @@ function copyComponentToP1363(component, target, targetOffset, componentLength)
2725
3445
  }
2726
3446
 
2727
3447
  // src/org/org-types.ts
2728
- import { KoraError as KoraError7 } from "@korajs/core";
3448
+ import { KoraError as KoraError9 } from "@korajs/core";
2729
3449
  var ORG_ROLES = ["owner", "admin", "member", "viewer", "billing"];
2730
3450
  var ROLE_HIERARCHY = {
2731
3451
  viewer: 10,
@@ -2738,7 +3458,7 @@ function hasRoleLevel(userRole, requiredRole) {
2738
3458
  return ROLE_HIERARCHY[userRole] >= ROLE_HIERARCHY[requiredRole];
2739
3459
  }
2740
3460
  var INVITATION_STATUSES = ["pending", "accepted", "revoked", "expired"];
2741
- var OrgError = class extends KoraError7 {
3461
+ var OrgError = class extends KoraError9 {
2742
3462
  constructor(message, code, context) {
2743
3463
  super(message, code, context);
2744
3464
  this.name = "OrgError";
@@ -2766,11 +3486,9 @@ var MemberAlreadyExistsError = class extends OrgError {
2766
3486
  };
2767
3487
  var InsufficientRoleError = class extends OrgError {
2768
3488
  constructor(required) {
2769
- super(
2770
- `This action requires at least the "${required}" role.`,
2771
- "INSUFFICIENT_ROLE",
2772
- { requiredRole: required }
2773
- );
3489
+ super(`This action requires at least the "${required}" role.`, "INSUFFICIENT_ROLE", {
3490
+ requiredRole: required
3491
+ });
2774
3492
  }
2775
3493
  };
2776
3494
  var CannotRemoveOwnerError = class extends OrgError {
@@ -2980,17 +3698,28 @@ var OrgRoutes = class {
2980
3698
  return { status: 400, body: { error: "Target user ID is required." } };
2981
3699
  }
2982
3700
  if (typeof params.role !== "string" || !isValidRole(params.role)) {
2983
- return { status: 400, body: { error: "A valid role is required (admin, member, viewer, billing)." } };
3701
+ return {
3702
+ status: 400,
3703
+ body: { error: "A valid role is required (admin, member, viewer, billing)." }
3704
+ };
2984
3705
  }
2985
3706
  if (params.role === "owner") {
2986
- return { status: 400, body: { error: "Cannot assign owner role directly. Use ownership transfer." } };
3707
+ return {
3708
+ status: 400,
3709
+ body: { error: "Cannot assign owner role directly. Use ownership transfer." }
3710
+ };
2987
3711
  }
2988
3712
  const callerMembership = await this.store.getMembership(orgId, userId);
2989
3713
  if (callerMembership && callerMembership.role !== "owner" && params.role === "admin") {
2990
3714
  return { status: 403, body: { error: "Only the owner can add admin members." } };
2991
3715
  }
2992
3716
  try {
2993
- const membership = await this.store.addMember(orgId, params.targetUserId, params.role, userId);
3717
+ const membership = await this.store.addMember(
3718
+ orgId,
3719
+ params.targetUserId,
3720
+ params.role,
3721
+ userId
3722
+ );
2994
3723
  return { status: 201, body: { data: membership } };
2995
3724
  } catch (err) {
2996
3725
  if (err instanceof OrgNotFoundError) {
@@ -3043,17 +3772,27 @@ var OrgRoutes = class {
3043
3772
  return { status: 400, body: { error: "Target user ID is required." } };
3044
3773
  }
3045
3774
  if (typeof params.role !== "string" || !isValidRole(params.role)) {
3046
- return { status: 400, body: { error: "A valid role is required (admin, member, viewer, billing)." } };
3775
+ return {
3776
+ status: 400,
3777
+ body: { error: "A valid role is required (admin, member, viewer, billing)." }
3778
+ };
3047
3779
  }
3048
3780
  if (params.role === "owner") {
3049
- return { status: 400, body: { error: "Cannot assign owner role directly. Use ownership transfer." } };
3781
+ return {
3782
+ status: 400,
3783
+ body: { error: "Cannot assign owner role directly. Use ownership transfer." }
3784
+ };
3050
3785
  }
3051
3786
  const callerMembership = await this.store.getMembership(orgId, userId);
3052
3787
  if (callerMembership && callerMembership.role !== "owner" && params.role === "admin") {
3053
3788
  return { status: 403, body: { error: "Only the owner can assign admin role." } };
3054
3789
  }
3055
3790
  try {
3056
- const membership = await this.store.updateMemberRole(orgId, params.targetUserId, params.role);
3791
+ const membership = await this.store.updateMemberRole(
3792
+ orgId,
3793
+ params.targetUserId,
3794
+ params.role
3795
+ );
3057
3796
  return { status: 200, body: { data: membership } };
3058
3797
  } catch (err) {
3059
3798
  if (err instanceof MembershipNotFoundError) {
@@ -3116,10 +3855,16 @@ var OrgRoutes = class {
3116
3855
  return { status: 400, body: { error: "A valid email address is required." } };
3117
3856
  }
3118
3857
  if (typeof params.role !== "string" || !isValidRole(params.role)) {
3119
- return { status: 400, body: { error: "A valid role is required (admin, member, viewer, billing)." } };
3858
+ return {
3859
+ status: 400,
3860
+ body: { error: "A valid role is required (admin, member, viewer, billing)." }
3861
+ };
3120
3862
  }
3121
3863
  if (params.role === "owner") {
3122
- return { status: 400, body: { error: "Cannot invite with owner role. Use ownership transfer." } };
3864
+ return {
3865
+ status: 400,
3866
+ body: { error: "Cannot invite with owner role. Use ownership transfer." }
3867
+ };
3123
3868
  }
3124
3869
  const callerMembership = await this.store.getMembership(orgId, userId);
3125
3870
  if (callerMembership && callerMembership.role !== "owner" && params.role === "admin") {
@@ -3505,7 +4250,7 @@ function slugify(name) {
3505
4250
  }
3506
4251
 
3507
4252
  // src/rbac/rbac-types.ts
3508
- import { KoraError as KoraError8 } from "@korajs/core";
4253
+ import { KoraError as KoraError10 } from "@korajs/core";
3509
4254
  function parsePermission(permission) {
3510
4255
  const colonIndex = permission.indexOf(":");
3511
4256
  if (colonIndex === -1) {
@@ -3549,7 +4294,7 @@ var BUILT_IN_ROLES = [
3549
4294
  permissions: ["*:*"]
3550
4295
  }
3551
4296
  ];
3552
- var RbacError = class extends KoraError8 {
4297
+ var RbacError = class extends KoraError10 {
3553
4298
  constructor(message, code, context) {
3554
4299
  super(message, code, context);
3555
4300
  this.name = "RbacError";
@@ -3566,20 +4311,14 @@ var InvalidPermissionError = class extends RbacError {
3566
4311
  };
3567
4312
  var RoleNotFoundError = class extends RbacError {
3568
4313
  constructor(role) {
3569
- super(
3570
- `Role "${role}" is not defined.`,
3571
- "ROLE_NOT_FOUND",
3572
- { role }
3573
- );
4314
+ super(`Role "${role}" is not defined.`, "ROLE_NOT_FOUND", { role });
3574
4315
  }
3575
4316
  };
3576
4317
  var CircularInheritanceError = class extends RbacError {
3577
4318
  constructor(chain) {
3578
- super(
3579
- `Circular role inheritance detected: ${chain.join(" \u2192 ")}.`,
3580
- "CIRCULAR_INHERITANCE",
3581
- { chain }
3582
- );
4319
+ super(`Circular role inheritance detected: ${chain.join(" \u2192 ")}.`, "CIRCULAR_INHERITANCE", {
4320
+ chain
4321
+ });
3583
4322
  }
3584
4323
  };
3585
4324
 
@@ -3680,15 +4419,11 @@ var RbacEngine = class {
3680
4419
  permissions
3681
4420
  };
3682
4421
  const scopes = {};
3683
- const hasAnyRead = permissions.some(
3684
- (p) => permissionCovers(p, "*:read")
3685
- );
4422
+ const hasAnyRead = permissions.some((p) => permissionCovers(p, "*:read"));
3686
4423
  if (!hasAnyRead) {
3687
4424
  return scopes;
3688
4425
  }
3689
- const isReadOnly = !permissions.some(
3690
- (p) => permissionCovers(p, "*:write")
3691
- );
4426
+ const isReadOnly = !permissions.some((p) => permissionCovers(p, "*:write"));
3692
4427
  const collectionsToResolve = collections ?? [...this.collectionResolvers.keys()];
3693
4428
  for (const collection of collectionsToResolve) {
3694
4429
  const customResolver = this.collectionResolvers.get(collection);
@@ -3849,21 +4584,13 @@ var OrgScopeResolver = class {
3849
4584
  * Check if a user can write to a specific collection in an org.
3850
4585
  */
3851
4586
  async canWrite(userId, orgId, collection) {
3852
- return this.rbac.hasPermission(
3853
- userId,
3854
- orgId,
3855
- `${collection}:write`
3856
- );
4587
+ return this.rbac.hasPermission(userId, orgId, `${collection}:write`);
3857
4588
  }
3858
4589
  /**
3859
4590
  * Check if a user can read from a specific collection in an org.
3860
4591
  */
3861
4592
  async canRead(userId, orgId, collection) {
3862
- return this.rbac.hasPermission(
3863
- userId,
3864
- orgId,
3865
- `${collection}:read`
3866
- );
4593
+ return this.rbac.hasPermission(userId, orgId, `${collection}:read`);
3867
4594
  }
3868
4595
  // --- Private ---
3869
4596
  resolveCollectionScope(ctx, collection) {
@@ -3886,304 +4613,409 @@ var OrgScopeResolver = class {
3886
4613
  }
3887
4614
  };
3888
4615
 
3889
- // src/provider/oauth/oauth-types.ts
3890
- import { KoraError as KoraError9 } from "@korajs/core";
3891
- var OAuthError = class extends KoraError9 {
3892
- constructor(message, code, context) {
3893
- super(message, code, context);
3894
- this.name = "OAuthError";
3895
- }
3896
- };
3897
- var OAuthStateMismatchError = class extends OAuthError {
3898
- constructor() {
3899
- super("OAuth state parameter does not match. Possible CSRF attack.", "OAUTH_STATE_MISMATCH");
4616
+ // src/provider/oauth/sqlite-oauth-store.ts
4617
+ import { randomUUID as randomUUID7 } from "crypto";
4618
+ var SqliteOAuthStateStore = class {
4619
+ db;
4620
+ constructor(db) {
4621
+ this.db = db;
4622
+ this.db.pragma("journal_mode = WAL");
4623
+ this.ensureTables();
3900
4624
  }
3901
- };
3902
- var OAuthCodeExchangeError = class extends OAuthError {
3903
- constructor(details) {
3904
- super(
3905
- `Failed to exchange authorization code for tokens.${details ? ` ${details}` : ""}`,
3906
- "OAUTH_CODE_EXCHANGE_FAILED",
3907
- details ? { details } : void 0
4625
+ async store(state) {
4626
+ this.db.prepare(`
4627
+ INSERT OR REPLACE INTO auth_oauth_states
4628
+ (state, provider, redirect_uri, created_at, expires_at, metadata_json, code_verifier)
4629
+ VALUES (?, ?, ?, ?, ?, ?, ?)
4630
+ `).run(
4631
+ state.state,
4632
+ state.provider,
4633
+ state.redirectUri,
4634
+ state.createdAt,
4635
+ state.expiresAt,
4636
+ state.metadata ? JSON.stringify(state.metadata) : null,
4637
+ state.codeVerifier ?? null
3908
4638
  );
3909
4639
  }
3910
- };
3911
- var OAuthUserInfoError = class extends OAuthError {
3912
- constructor(details) {
3913
- super(
3914
- `Failed to fetch user info from OAuth provider.${details ? ` ${details}` : ""}`,
3915
- "OAUTH_USER_INFO_FAILED",
3916
- details ? { details } : void 0
3917
- );
4640
+ async consume(stateValue) {
4641
+ const consumeInTransaction = this.db.transaction(() => {
4642
+ const row = this.db.prepare("SELECT * FROM auth_oauth_states WHERE state = ?").get(stateValue);
4643
+ if (!row) return null;
4644
+ this.db.prepare("DELETE FROM auth_oauth_states WHERE state = ?").run(stateValue);
4645
+ if (Date.now() > row.expires_at) return null;
4646
+ return rowToOAuthState(row);
4647
+ });
4648
+ return consumeInTransaction();
4649
+ }
4650
+ async cleanExpired() {
4651
+ const result = this.db.prepare("DELETE FROM auth_oauth_states WHERE expires_at < ?").run(Date.now());
4652
+ return result.changes ?? 0;
4653
+ }
4654
+ ensureTables() {
4655
+ this.db.exec(`
4656
+ CREATE TABLE IF NOT EXISTS auth_oauth_states (
4657
+ state TEXT PRIMARY KEY,
4658
+ provider TEXT NOT NULL,
4659
+ redirect_uri TEXT NOT NULL,
4660
+ created_at INTEGER NOT NULL,
4661
+ expires_at INTEGER NOT NULL,
4662
+ metadata_json TEXT,
4663
+ code_verifier TEXT
4664
+ );
4665
+
4666
+ CREATE INDEX IF NOT EXISTS idx_auth_oauth_states_expires_at
4667
+ ON auth_oauth_states(expires_at);
4668
+ `);
3918
4669
  }
3919
4670
  };
3920
- var OAuthProviderNotFoundError = class extends OAuthError {
3921
- constructor(provider) {
3922
- super(`OAuth provider "${provider}" is not configured.`, "OAUTH_PROVIDER_NOT_FOUND", { provider });
4671
+ var SqliteLinkedIdentityStore = class {
4672
+ db;
4673
+ constructor(db) {
4674
+ this.db = db;
4675
+ this.db.pragma("journal_mode = WAL");
4676
+ this.ensureTables();
4677
+ }
4678
+ async findByProvider(provider, providerUserId) {
4679
+ const row = this.db.prepare(`
4680
+ SELECT * FROM auth_linked_identities
4681
+ WHERE provider = ? AND provider_user_id = ?
4682
+ `).get(provider, providerUserId);
4683
+ return row ? rowToLinkedIdentity(row) : null;
4684
+ }
4685
+ async findByUser(userId) {
4686
+ const rows = this.db.prepare(`
4687
+ SELECT * FROM auth_linked_identities
4688
+ WHERE user_id = ?
4689
+ ORDER BY linked_at ASC
4690
+ `).all(userId);
4691
+ return rows.map(rowToLinkedIdentity);
4692
+ }
4693
+ async create(params) {
4694
+ const identity = {
4695
+ id: randomUUID7(),
4696
+ userId: params.userId,
4697
+ provider: params.provider,
4698
+ providerUserId: params.providerUserId,
4699
+ email: params.email,
4700
+ linkedAt: Date.now()
4701
+ };
4702
+ try {
4703
+ this.db.prepare(`
4704
+ INSERT INTO auth_linked_identities
4705
+ (id, user_id, provider, provider_user_id, email, linked_at)
4706
+ VALUES (?, ?, ?, ?, ?, ?)
4707
+ `).run(
4708
+ identity.id,
4709
+ identity.userId,
4710
+ identity.provider,
4711
+ identity.providerUserId,
4712
+ identity.email,
4713
+ identity.linkedAt
4714
+ );
4715
+ } catch (error) {
4716
+ if (error instanceof Error && error.message.includes("UNIQUE constraint failed")) {
4717
+ throw new DuplicateLinkedIdentityError(params.provider);
4718
+ }
4719
+ throw error;
4720
+ }
4721
+ return identity;
4722
+ }
4723
+ async delete(userId, provider) {
4724
+ this.db.prepare("DELETE FROM auth_linked_identities WHERE user_id = ? AND provider = ?").run(userId, provider);
4725
+ }
4726
+ ensureTables() {
4727
+ this.db.exec(`
4728
+ CREATE TABLE IF NOT EXISTS auth_linked_identities (
4729
+ id TEXT PRIMARY KEY,
4730
+ user_id TEXT NOT NULL,
4731
+ provider TEXT NOT NULL,
4732
+ provider_user_id TEXT NOT NULL,
4733
+ email TEXT,
4734
+ linked_at INTEGER NOT NULL,
4735
+ UNIQUE(provider, provider_user_id),
4736
+ UNIQUE(user_id, provider)
4737
+ );
4738
+
4739
+ CREATE INDEX IF NOT EXISTS idx_auth_linked_identities_user_id
4740
+ ON auth_linked_identities(user_id);
4741
+ `);
3923
4742
  }
3924
4743
  };
4744
+ async function createSqliteOAuthStateStore(options) {
4745
+ const Database = await loadBetterSqlite32();
4746
+ const db = new Database(options.filename);
4747
+ return new SqliteOAuthStateStore(db);
4748
+ }
4749
+ async function createSqliteLinkedIdentityStore(options) {
4750
+ const Database = await loadBetterSqlite32();
4751
+ const db = new Database(options.filename);
4752
+ return new SqliteLinkedIdentityStore(db);
4753
+ }
4754
+ async function createSqliteOAuthStores(options) {
4755
+ const Database = await loadBetterSqlite32();
4756
+ const db = new Database(options.filename);
4757
+ return {
4758
+ stateStore: new SqliteOAuthStateStore(db),
4759
+ linkedIdentityStore: new SqliteLinkedIdentityStore(db)
4760
+ };
4761
+ }
4762
+ async function loadBetterSqlite32() {
4763
+ try {
4764
+ const { createRequire } = await import("module");
4765
+ const require2 = createRequire(import.meta.url);
4766
+ return require2("better-sqlite3");
4767
+ } catch {
4768
+ throw new Error(
4769
+ 'SQLite OAuth stores require the "better-sqlite3" package. Install it in your project dependencies.'
4770
+ );
4771
+ }
4772
+ }
4773
+ function rowToOAuthState(row) {
4774
+ return {
4775
+ state: row.state,
4776
+ provider: row.provider,
4777
+ redirectUri: row.redirect_uri,
4778
+ createdAt: row.created_at,
4779
+ expiresAt: row.expires_at,
4780
+ metadata: parseMetadata(row.metadata_json),
4781
+ codeVerifier: row.code_verifier ?? void 0
4782
+ };
4783
+ }
4784
+ function parseMetadata(value) {
4785
+ if (!value) return void 0;
4786
+ const parsed = JSON.parse(value);
4787
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : void 0;
4788
+ }
4789
+ function rowToLinkedIdentity(row) {
4790
+ return {
4791
+ id: row.id,
4792
+ userId: row.user_id,
4793
+ provider: row.provider,
4794
+ providerUserId: row.provider_user_id,
4795
+ email: row.email,
4796
+ linkedAt: row.linked_at
4797
+ };
4798
+ }
3925
4799
 
3926
- // src/provider/oauth/oauth-flow.ts
3927
- var DEFAULT_STATE_TTL_MS = 10 * 60 * 1e3;
3928
- var InMemoryOAuthStateStore = class {
3929
- states = /* @__PURE__ */ new Map();
3930
- async store(state) {
3931
- this.states.set(state.state, state);
4800
+ // src/provider/oauth/postgres-oauth-store.ts
4801
+ import { randomUUID as randomUUID8 } from "crypto";
4802
+ var PostgresOAuthStateStore = class {
4803
+ sql;
4804
+ ready;
4805
+ constructor(sql) {
4806
+ this.sql = sql;
4807
+ this.ready = this.ensureTables();
3932
4808
  }
3933
- async consume(stateValue) {
3934
- const state = this.states.get(stateValue);
3935
- if (!state) return null;
3936
- this.states.delete(stateValue);
3937
- if (Date.now() > state.expiresAt) return null;
3938
- return state;
4809
+ async store(state) {
4810
+ await this.ready;
4811
+ await this.sql`
4812
+ INSERT INTO auth_oauth_states
4813
+ (state, provider, redirect_uri, created_at, expires_at, metadata_json, code_verifier)
4814
+ VALUES (
4815
+ ${state.state},
4816
+ ${state.provider},
4817
+ ${state.redirectUri},
4818
+ ${state.createdAt},
4819
+ ${state.expiresAt},
4820
+ ${state.metadata ? JSON.stringify(state.metadata) : null},
4821
+ ${state.codeVerifier ?? null}
4822
+ )
4823
+ ON CONFLICT (state) DO UPDATE SET
4824
+ provider = EXCLUDED.provider,
4825
+ redirect_uri = EXCLUDED.redirect_uri,
4826
+ created_at = EXCLUDED.created_at,
4827
+ expires_at = EXCLUDED.expires_at,
4828
+ metadata_json = EXCLUDED.metadata_json,
4829
+ code_verifier = EXCLUDED.code_verifier
4830
+ `;
3939
4831
  }
3940
- async cleanExpired() {
3941
- const now = Date.now();
3942
- let count = 0;
3943
- for (const [key, state] of this.states) {
3944
- if (now > state.expiresAt) {
3945
- this.states.delete(key);
3946
- count++;
4832
+ async consume(stateValue) {
4833
+ await this.ready;
4834
+ return this.sql.begin(async (tx) => {
4835
+ const rows = await tx`
4836
+ DELETE FROM auth_oauth_states
4837
+ WHERE state = ${stateValue}
4838
+ RETURNING *
4839
+ `;
4840
+ const row = rows[0];
4841
+ if (!row || Date.now() > Number(row.expires_at)) {
4842
+ return null;
3947
4843
  }
3948
- }
3949
- return count;
4844
+ return rowToOAuthState2(row);
4845
+ });
3950
4846
  }
3951
- };
3952
- var OAuthManager = class {
3953
- providers = /* @__PURE__ */ new Map();
3954
- stateStore;
3955
- stateTtlMs;
3956
- fetchFn;
3957
- constructor(config) {
3958
- for (const provider of config.providers) {
3959
- this.providers.set(provider.providerId, provider);
3960
- }
3961
- this.stateStore = config.stateStore ?? new InMemoryOAuthStateStore();
3962
- this.stateTtlMs = config.stateTtlMs ?? DEFAULT_STATE_TTL_MS;
3963
- this.fetchFn = config.fetch ?? globalThis.fetch.bind(globalThis);
4847
+ async cleanExpired() {
4848
+ await this.ready;
4849
+ const rows = await this.sql`
4850
+ DELETE FROM auth_oauth_states
4851
+ WHERE expires_at < ${Date.now()}
4852
+ RETURNING state
4853
+ `;
4854
+ return rows.length;
3964
4855
  }
3965
- /**
3966
- * Generate an authorization URL for the user to visit.
3967
- * Returns the URL and the state parameter for CSRF validation.
3968
- */
3969
- async getAuthorizationUrl(providerId, metadata) {
3970
- const provider = this.getProvider(providerId);
3971
- const state = generateState();
3972
- const now = Date.now();
3973
- const oauthState = {
3974
- state,
3975
- provider: providerId,
3976
- redirectUri: provider.redirectUri,
3977
- createdAt: now,
3978
- expiresAt: now + this.stateTtlMs,
3979
- metadata
3980
- };
3981
- await this.stateStore.store(oauthState);
3982
- const params = new URLSearchParams({
3983
- client_id: provider.clientId,
3984
- redirect_uri: provider.redirectUri,
3985
- response_type: "code",
3986
- scope: provider.scopes.join(" "),
3987
- state
3988
- });
3989
- const url = `${provider.authorizationUrl}?${params.toString()}`;
3990
- return { url, state };
4856
+ async ensureTables() {
4857
+ await this.sql`
4858
+ CREATE TABLE IF NOT EXISTS auth_oauth_states (
4859
+ state TEXT PRIMARY KEY,
4860
+ provider TEXT NOT NULL,
4861
+ redirect_uri TEXT NOT NULL,
4862
+ created_at BIGINT NOT NULL,
4863
+ expires_at BIGINT NOT NULL,
4864
+ metadata_json TEXT,
4865
+ code_verifier TEXT
4866
+ )
4867
+ `;
4868
+ await this.sql`
4869
+ CREATE INDEX IF NOT EXISTS idx_auth_oauth_states_expires_at
4870
+ ON auth_oauth_states(expires_at)
4871
+ `;
3991
4872
  }
3992
- /**
3993
- * Handle the OAuth callback after the user authorizes.
3994
- * Validates the state parameter, exchanges the code for tokens,
3995
- * and fetches user info.
3996
- *
3997
- * @param providerId - The OAuth provider
3998
- * @param code - The authorization code from the callback
3999
- * @param state - The state parameter from the callback
4000
- * @returns Tokens and user info from the provider
4001
- */
4002
- async handleCallback(providerId, code, state) {
4003
- const provider = this.getProvider(providerId);
4004
- const oauthState = await this.stateStore.consume(state);
4005
- if (!oauthState || oauthState.provider !== providerId) {
4006
- throw new OAuthStateMismatchError();
4007
- }
4008
- const tokens = await this.exchangeCodeForTokens(provider, code);
4009
- const userInfo = await this.fetchUserInfo(provider, tokens.accessToken);
4010
- return { tokens, userInfo, stateMetadata: oauthState.metadata };
4873
+ };
4874
+ var PostgresLinkedIdentityStore = class {
4875
+ sql;
4876
+ ready;
4877
+ constructor(sql) {
4878
+ this.sql = sql;
4879
+ this.ready = this.ensureTables();
4011
4880
  }
4012
- /**
4013
- * Get a registered provider by ID.
4014
- */
4015
- getProvider(providerId) {
4016
- const provider = this.providers.get(providerId);
4017
- if (!provider) {
4018
- throw new OAuthProviderNotFoundError(providerId);
4019
- }
4020
- return provider;
4881
+ async findByProvider(provider, providerUserId) {
4882
+ await this.ready;
4883
+ const rows = await this.sql`
4884
+ SELECT * FROM auth_linked_identities
4885
+ WHERE provider = ${provider} AND provider_user_id = ${providerUserId}
4886
+ `;
4887
+ return rows[0] ? rowToLinkedIdentity2(rows[0]) : null;
4021
4888
  }
4022
- /**
4023
- * List all registered provider IDs.
4024
- */
4025
- getProviderIds() {
4026
- return [...this.providers.keys()];
4889
+ async findByUser(userId) {
4890
+ await this.ready;
4891
+ const rows = await this.sql`
4892
+ SELECT * FROM auth_linked_identities
4893
+ WHERE user_id = ${userId}
4894
+ ORDER BY linked_at ASC
4895
+ `;
4896
+ return rows.map(rowToLinkedIdentity2);
4027
4897
  }
4028
- // --- Private ---
4029
- async exchangeCodeForTokens(provider, code) {
4030
- const body = new URLSearchParams({
4031
- grant_type: "authorization_code",
4032
- code,
4033
- redirect_uri: provider.redirectUri,
4034
- client_id: provider.clientId,
4035
- client_secret: provider.clientSecret
4036
- });
4037
- let response;
4898
+ async create(params) {
4899
+ await this.ready;
4900
+ const identity = {
4901
+ id: randomUUID8(),
4902
+ userId: params.userId,
4903
+ provider: params.provider,
4904
+ providerUserId: params.providerUserId,
4905
+ email: params.email,
4906
+ linkedAt: Date.now()
4907
+ };
4038
4908
  try {
4039
- response = await this.fetchFn(provider.tokenUrl, {
4040
- method: "POST",
4041
- headers: {
4042
- "Content-Type": "application/x-www-form-urlencoded",
4043
- Accept: "application/json"
4044
- },
4045
- body: body.toString()
4046
- });
4047
- } catch (err) {
4048
- throw new OAuthCodeExchangeError(
4049
- err instanceof Error ? err.message : "Network error"
4050
- );
4051
- }
4052
- if (!response.ok) {
4053
- let details = `HTTP ${response.status}`;
4054
- try {
4055
- const errorBody = await response.text();
4056
- details += `: ${errorBody}`;
4057
- } catch {
4909
+ await this.sql`
4910
+ INSERT INTO auth_linked_identities
4911
+ (id, user_id, provider, provider_user_id, email, linked_at)
4912
+ VALUES (
4913
+ ${identity.id},
4914
+ ${identity.userId},
4915
+ ${identity.provider},
4916
+ ${identity.providerUserId},
4917
+ ${identity.email},
4918
+ ${identity.linkedAt}
4919
+ )
4920
+ `;
4921
+ } catch (error) {
4922
+ if (isUniqueViolation(error)) {
4923
+ throw new DuplicateLinkedIdentityError(params.provider);
4058
4924
  }
4059
- throw new OAuthCodeExchangeError(details);
4925
+ throw error;
4060
4926
  }
4061
- const data = await response.json();
4062
- 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"]
4069
- };
4927
+ return identity;
4070
4928
  }
4071
- async fetchUserInfo(provider, accessToken) {
4072
- let response;
4073
- try {
4074
- response = await this.fetchFn(provider.userInfoUrl, {
4075
- headers: {
4076
- Authorization: `Bearer ${accessToken}`,
4077
- Accept: "application/json"
4078
- }
4079
- });
4080
- } catch (err) {
4081
- throw new OAuthUserInfoError(
4082
- err instanceof Error ? err.message : "Network error"
4083
- );
4084
- }
4085
- if (!response.ok) {
4086
- throw new OAuthUserInfoError(`HTTP ${response.status}`);
4087
- }
4088
- const profile = await response.json();
4089
- return normalizeUserInfo(provider.providerId, profile);
4929
+ async delete(userId, provider) {
4930
+ await this.ready;
4931
+ await this.sql`
4932
+ DELETE FROM auth_linked_identities
4933
+ WHERE user_id = ${userId} AND provider = ${provider}
4934
+ `;
4090
4935
  }
4091
- };
4092
- function normalizeUserInfo(providerId, profile) {
4093
- switch (providerId) {
4094
- case "google":
4095
- return {
4096
- providerId: profile["sub"],
4097
- provider: "google",
4098
- email: profile["email"] ?? null,
4099
- emailVerified: profile["email_verified"] ?? false,
4100
- name: profile["name"] ?? null,
4101
- avatarUrl: profile["picture"] ?? null,
4102
- rawProfile: profile
4103
- };
4104
- case "github":
4105
- return {
4106
- providerId: String(profile["id"]),
4107
- provider: "github",
4108
- email: profile["email"] ?? null,
4109
- emailVerified: false,
4110
- // GitHub doesn't confirm in the profile response
4111
- name: profile["name"] ?? profile["login"] ?? null,
4112
- avatarUrl: profile["avatar_url"] ?? null,
4113
- rawProfile: profile
4114
- };
4115
- case "microsoft":
4116
- return {
4117
- providerId: profile["id"],
4118
- provider: "microsoft",
4119
- email: profile["mail"] ?? profile["userPrincipalName"] ?? null,
4120
- emailVerified: false,
4121
- name: profile["displayName"] ?? null,
4122
- avatarUrl: null,
4123
- rawProfile: profile
4124
- };
4125
- default:
4126
- return {
4127
- providerId: String(profile["id"] ?? profile["sub"] ?? ""),
4128
- 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,
4133
- rawProfile: profile
4134
- };
4936
+ async ensureTables() {
4937
+ await this.sql`
4938
+ CREATE TABLE IF NOT EXISTS auth_linked_identities (
4939
+ id TEXT PRIMARY KEY,
4940
+ user_id TEXT NOT NULL,
4941
+ provider TEXT NOT NULL,
4942
+ provider_user_id TEXT NOT NULL,
4943
+ email TEXT,
4944
+ linked_at BIGINT NOT NULL,
4945
+ UNIQUE(provider, provider_user_id),
4946
+ UNIQUE(user_id, provider)
4947
+ )
4948
+ `;
4949
+ await this.sql`
4950
+ CREATE INDEX IF NOT EXISTS idx_auth_linked_identities_user_id
4951
+ ON auth_linked_identities(user_id)
4952
+ `;
4135
4953
  }
4954
+ };
4955
+ async function createPostgresOAuthStateStore(options) {
4956
+ const postgresClient = await loadPostgresDeps2();
4957
+ const sql = postgresClient(options.connectionString);
4958
+ return new PostgresOAuthStateStore(sql);
4136
4959
  }
4137
- function googleProvider(config) {
4960
+ async function createPostgresLinkedIdentityStore(options) {
4961
+ const postgresClient = await loadPostgresDeps2();
4962
+ const sql = postgresClient(options.connectionString);
4963
+ return new PostgresLinkedIdentityStore(sql);
4964
+ }
4965
+ async function createPostgresOAuthStores(options) {
4966
+ const postgresClient = await loadPostgresDeps2();
4967
+ const sql = postgresClient(options.connectionString);
4138
4968
  return {
4139
- providerId: "google",
4140
- clientId: config.clientId,
4141
- clientSecret: config.clientSecret,
4142
- authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
4143
- tokenUrl: "https://oauth2.googleapis.com/token",
4144
- userInfoUrl: "https://openidconnect.googleapis.com/v1/userinfo",
4145
- scopes: config.scopes ?? ["openid", "email", "profile"],
4146
- redirectUri: config.redirectUri
4969
+ stateStore: new PostgresOAuthStateStore(sql),
4970
+ linkedIdentityStore: new PostgresLinkedIdentityStore(sql)
4147
4971
  };
4148
4972
  }
4149
- function githubProvider(config) {
4973
+ async function loadPostgresDeps2() {
4974
+ try {
4975
+ const dynamicImport = new Function("specifier", "return import(specifier)");
4976
+ const postgresMod = await dynamicImport("postgres");
4977
+ return postgresMod.default;
4978
+ } catch {
4979
+ throw new Error(
4980
+ 'PostgreSQL OAuth stores require the "postgres" package. Install it in your project dependencies.'
4981
+ );
4982
+ }
4983
+ }
4984
+ function rowToOAuthState2(row) {
4150
4985
  return {
4151
- providerId: "github",
4152
- clientId: config.clientId,
4153
- clientSecret: config.clientSecret,
4154
- authorizationUrl: "https://github.com/login/oauth/authorize",
4155
- tokenUrl: "https://github.com/login/oauth/access_token",
4156
- userInfoUrl: "https://api.github.com/user",
4157
- scopes: config.scopes ?? ["read:user", "user:email"],
4158
- redirectUri: config.redirectUri
4986
+ state: row.state,
4987
+ provider: row.provider,
4988
+ redirectUri: row.redirect_uri,
4989
+ createdAt: Number(row.created_at),
4990
+ expiresAt: Number(row.expires_at),
4991
+ metadata: parseMetadata2(row.metadata_json),
4992
+ codeVerifier: row.code_verifier ?? void 0
4159
4993
  };
4160
4994
  }
4161
- function microsoftProvider(config) {
4162
- const tenant = config.tenantId ?? "common";
4995
+ function parseMetadata2(value) {
4996
+ if (!value) return void 0;
4997
+ const parsed = JSON.parse(value);
4998
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : void 0;
4999
+ }
5000
+ function rowToLinkedIdentity2(row) {
4163
5001
  return {
4164
- providerId: "microsoft",
4165
- clientId: config.clientId,
4166
- clientSecret: config.clientSecret,
4167
- authorizationUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`,
4168
- tokenUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`,
4169
- userInfoUrl: "https://graph.microsoft.com/v1.0/me",
4170
- scopes: config.scopes ?? ["openid", "email", "profile", "User.Read"],
4171
- redirectUri: config.redirectUri
5002
+ id: row.id,
5003
+ userId: row.user_id,
5004
+ provider: row.provider,
5005
+ providerUserId: row.provider_user_id,
5006
+ email: row.email,
5007
+ linkedAt: Number(row.linked_at)
4172
5008
  };
4173
5009
  }
4174
- function generateState() {
4175
- const bytes = new Uint8Array(32);
4176
- globalThis.crypto.getRandomValues(bytes);
4177
- let binary = "";
4178
- for (let i = 0; i < bytes.length; i++) {
4179
- binary += String.fromCharCode(bytes[i]);
4180
- }
4181
- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
5010
+ function isUniqueViolation(error) {
5011
+ if (!(error instanceof Error)) return false;
5012
+ const code = error.code;
5013
+ return code === "23505" || error.message.includes("unique constraint") || error.message.includes("duplicate key");
4182
5014
  }
4183
5015
 
4184
5016
  // src/session/session.ts
4185
- import { KoraError as KoraError10 } from "@korajs/core";
4186
- var SessionError = class extends KoraError10 {
5017
+ import { KoraError as KoraError11 } from "@korajs/core";
5018
+ var SessionError = class extends KoraError11 {
4187
5019
  constructor(message, code, context) {
4188
5020
  super(message, code, context);
4189
5021
  this.name = "SessionError";
@@ -4406,14 +5238,14 @@ function generateSessionId() {
4406
5238
  globalThis.crypto.getRandomValues(bytes);
4407
5239
  let hex = "";
4408
5240
  for (let i = 0; i < bytes.length; i++) {
4409
- hex += bytes[i].toString(16).padStart(2, "0");
5241
+ hex += bytes[i]?.toString(16).padStart(2, "0");
4410
5242
  }
4411
5243
  return hex;
4412
5244
  }
4413
5245
 
4414
5246
  // src/mfa/totp.ts
4415
- import { KoraError as KoraError11 } from "@korajs/core";
4416
- var TotpError = class extends KoraError11 {
5247
+ import { KoraError as KoraError12 } from "@korajs/core";
5248
+ var TotpError = class extends KoraError12 {
4417
5249
  constructor(message, code, context) {
4418
5250
  super(message, code, context);
4419
5251
  this.name = "TotpError";
@@ -4621,7 +5453,7 @@ var TotpManager = class {
4621
5453
  */
4622
5454
  async isEnabled(userId) {
4623
5455
  const stored = await this.store.getByUserId(userId);
4624
- return stored !== null && stored.verified;
5456
+ return stored?.verified ?? false;
4625
5457
  }
4626
5458
  /**
4627
5459
  * Get the number of remaining recovery codes for a user.
@@ -4655,7 +5487,7 @@ function generateTotpCode(secret, counter, digits, algorithm) {
4655
5487
  const hash = hmacSha(algorithm, secret, counterBytes);
4656
5488
  const offset = hash[hash.length - 1] & 15;
4657
5489
  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);
5490
+ const otp = binary % 10 ** digits;
4659
5491
  return otp.toString().padStart(digits, "0");
4660
5492
  }
4661
5493
  function hmacSha(algorithm, key, message) {
@@ -4699,7 +5531,10 @@ function sha1(data) {
4699
5531
  w[i] = view.getInt32(offset + i * 4, false);
4700
5532
  }
4701
5533
  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);
5534
+ w[i] = rotl32(
5535
+ w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16] | 0,
5536
+ 1
5537
+ );
4703
5538
  }
4704
5539
  let a = h0;
4705
5540
  let b = h1;
@@ -4808,7 +5643,7 @@ async function hashRecoveryCode(code) {
4808
5643
  const bytes = new Uint8Array(hash);
4809
5644
  let hex = "";
4810
5645
  for (let i = 0; i < bytes.length; i++) {
4811
- hex += bytes[i].toString(16).padStart(2, "0");
5646
+ hex += bytes[i]?.toString(16).padStart(2, "0");
4812
5647
  }
4813
5648
  return hex;
4814
5649
  }
@@ -4833,8 +5668,8 @@ function timingSafeEqual(a, b) {
4833
5668
  }
4834
5669
 
4835
5670
  // src/admin/admin-api.ts
4836
- import { KoraError as KoraError12 } from "@korajs/core";
4837
- var AdminApiError = class extends KoraError12 {
5671
+ import { KoraError as KoraError13 } from "@korajs/core";
5672
+ var AdminApiError = class extends KoraError13 {
4838
5673
  constructor(message, code, context) {
4839
5674
  super(message, code, context);
4840
5675
  this.name = "AdminApiError";
@@ -4868,7 +5703,7 @@ var AdminApi = class {
4868
5703
  throw new AdminUserNotFoundError(userId);
4869
5704
  }
4870
5705
  await this.audit("admin.user_lookup", adminId, userId, "user");
4871
- return toAuthUser2(user);
5706
+ return toAuthUser3(user);
4872
5707
  }
4873
5708
  /**
4874
5709
  * List users with optional filtering and pagination.
@@ -4886,7 +5721,7 @@ var AdminApi = class {
4886
5721
  filtered = filtered.filter((u) => u.emailVerified === query.emailVerified);
4887
5722
  }
4888
5723
  const total = filtered.length;
4889
- const data = filtered.slice(offset, offset + limit).map(toAuthUser2);
5724
+ const data = filtered.slice(offset, offset + limit).map(toAuthUser3);
4890
5725
  return { data, total, limit, offset };
4891
5726
  }
4892
5727
  /**
@@ -4908,7 +5743,7 @@ var AdminApi = class {
4908
5743
  }
4909
5744
  await this.userStore.update(user);
4910
5745
  await this.audit("user.update", adminId, userId, "user", { updates });
4911
- return toAuthUser2(user);
5746
+ return toAuthUser3(user);
4912
5747
  }
4913
5748
  /**
4914
5749
  * Delete a user and all associated sessions.
@@ -4973,7 +5808,7 @@ var AdminApi = class {
4973
5808
  });
4974
5809
  }
4975
5810
  };
4976
- function toAuthUser2(stored) {
5811
+ function toAuthUser3(stored) {
4977
5812
  return {
4978
5813
  id: stored.id,
4979
5814
  email: stored.email,
@@ -4984,8 +5819,8 @@ function toAuthUser2(stored) {
4984
5819
  }
4985
5820
 
4986
5821
  // src/admin/audit-log.ts
4987
- import { KoraError as KoraError13 } from "@korajs/core";
4988
- var AuditLogError = class extends KoraError13 {
5822
+ import { KoraError as KoraError14 } from "@korajs/core";
5823
+ var AuditLogError = class extends KoraError14 {
4989
5824
  constructor(message, code, context) {
4990
5825
  super(message, code, context);
4991
5826
  this.name = "AuditLogError";
@@ -5011,8 +5846,9 @@ var InMemoryAuditLogStore = class {
5011
5846
  const initialLength = this.entries.length;
5012
5847
  let writeIndex = 0;
5013
5848
  for (let i = 0; i < this.entries.length; i++) {
5014
- if (this.entries[i].timestamp >= timestamp) {
5015
- this.entries[writeIndex] = this.entries[i];
5849
+ const entry = this.entries[i];
5850
+ if (entry.timestamp >= timestamp) {
5851
+ this.entries[writeIndex] = entry;
5016
5852
  writeIndex++;
5017
5853
  }
5018
5854
  }
@@ -5103,14 +5939,14 @@ function generateAuditId() {
5103
5939
  globalThis.crypto.getRandomValues(bytes);
5104
5940
  let hex = "";
5105
5941
  for (let i = 0; i < bytes.length; i++) {
5106
- hex += bytes[i].toString(16).padStart(2, "0");
5942
+ hex += bytes[i]?.toString(16).padStart(2, "0");
5107
5943
  }
5108
5944
  return hex;
5109
5945
  }
5110
5946
 
5111
5947
  // src/admin/webhooks.ts
5112
- import { KoraError as KoraError14 } from "@korajs/core";
5113
- var WebhookError = class extends KoraError14 {
5948
+ import { KoraError as KoraError15 } from "@korajs/core";
5949
+ var WebhookError = class extends KoraError15 {
5114
5950
  constructor(message, code, context) {
5115
5951
  super(message, code, context);
5116
5952
  this.name = "WebhookError";
@@ -5222,9 +6058,7 @@ var WebhookManager = class {
5222
6058
  */
5223
6059
  async dispatch(event, data) {
5224
6060
  const endpoints = await this.store.listEndpoints();
5225
- const matching = endpoints.filter(
5226
- (ep) => ep.active && ep.events.includes(event)
5227
- );
6061
+ const matching = endpoints.filter((ep) => ep.active && ep.events.includes(event));
5228
6062
  const payload = {
5229
6063
  id: generateId2(),
5230
6064
  event,
@@ -5232,9 +6066,7 @@ var WebhookManager = class {
5232
6066
  data
5233
6067
  };
5234
6068
  const payloadJson = JSON.stringify(payload);
5235
- await Promise.allSettled(
5236
- matching.map((ep) => this.deliverToEndpoint(ep, payloadJson, event))
5237
- );
6069
+ await Promise.allSettled(matching.map((ep) => this.deliverToEndpoint(ep, payloadJson, event)));
5238
6070
  }
5239
6071
  // --- Private ---
5240
6072
  async deliverToEndpoint(endpoint, payloadJson, event) {
@@ -5287,7 +6119,7 @@ function generateId2() {
5287
6119
  globalThis.crypto.getRandomValues(bytes);
5288
6120
  let hex = "";
5289
6121
  for (let i = 0; i < bytes.length; i++) {
5290
- hex += bytes[i].toString(16).padStart(2, "0");
6122
+ hex += bytes[i]?.toString(16).padStart(2, "0");
5291
6123
  }
5292
6124
  return hex;
5293
6125
  }
@@ -5296,7 +6128,7 @@ function generateSecret2() {
5296
6128
  globalThis.crypto.getRandomValues(bytes);
5297
6129
  let hex = "";
5298
6130
  for (let i = 0; i < bytes.length; i++) {
5299
- hex += bytes[i].toString(16).padStart(2, "0");
6131
+ hex += bytes[i]?.toString(16).padStart(2, "0");
5300
6132
  }
5301
6133
  return `whsec_${hex}`;
5302
6134
  }
@@ -5309,15 +6141,11 @@ async function signPayload(payload, secret) {
5309
6141
  false,
5310
6142
  ["sign"]
5311
6143
  );
5312
- const signature = await globalThis.crypto.subtle.sign(
5313
- "HMAC",
5314
- key,
5315
- encoder.encode(payload)
5316
- );
6144
+ const signature = await globalThis.crypto.subtle.sign("HMAC", key, encoder.encode(payload));
5317
6145
  const bytes = new Uint8Array(signature);
5318
6146
  let hex = "";
5319
6147
  for (let i = 0; i < bytes.length; i++) {
5320
- hex += bytes[i].toString(16).padStart(2, "0");
6148
+ hex += bytes[i]?.toString(16).padStart(2, "0");
5321
6149
  }
5322
6150
  return `sha256=${hex}`;
5323
6151
  }
@@ -5347,6 +6175,7 @@ export {
5347
6175
  CannotRemoveOwnerError,
5348
6176
  CircularInheritanceError,
5349
6177
  DuplicateEmailError,
6178
+ DuplicateLinkedIdentityError,
5350
6179
  EmailVerificationError,
5351
6180
  EmailVerificationManager,
5352
6181
  ExternalAuthOperationNotSupportedError,
@@ -5356,6 +6185,7 @@ export {
5356
6185
  InMemoryAuditLogStore,
5357
6186
  InMemoryChallengeStore,
5358
6187
  InMemoryEmailVerificationStore,
6188
+ InMemoryLinkedIdentityStore,
5359
6189
  InMemoryOAuthStateStore,
5360
6190
  InMemoryOrgStore,
5361
6191
  InMemoryPasswordResetStore,
@@ -5388,6 +6218,8 @@ export {
5388
6218
  PasskeyVerificationError,
5389
6219
  PasswordResetError,
5390
6220
  PasswordResetManager,
6221
+ PostgresLinkedIdentityStore,
6222
+ PostgresOAuthStateStore,
5391
6223
  PostgresUserStore,
5392
6224
  ROLE_HIERARCHY,
5393
6225
  RbacEngine,
@@ -5402,6 +6234,8 @@ export {
5402
6234
  SessionManager,
5403
6235
  SessionMfaRequiredError,
5404
6236
  SessionNotFoundError,
6237
+ SqliteLinkedIdentityStore,
6238
+ SqliteOAuthStateStore,
5405
6239
  SqliteUserStore,
5406
6240
  TokenManager,
5407
6241
  TotpAlreadyEnabledError,
@@ -5420,7 +6254,14 @@ export {
5420
6254
  base32Encode,
5421
6255
  computePublicKeyThumbprint,
5422
6256
  createClerkAdapter,
6257
+ createKoraAuthServer,
6258
+ createPostgresLinkedIdentityStore,
6259
+ createPostgresOAuthStateStore,
6260
+ createPostgresOAuthStores,
5423
6261
  createPostgresUserStore,
6262
+ createSqliteLinkedIdentityStore,
6263
+ createSqliteOAuthStateStore,
6264
+ createSqliteOAuthStores,
5424
6265
  createSqliteUserStore,
5425
6266
  createSupabaseAdapter,
5426
6267
  decodeJwt,