@korajs/auth 0.1.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -30
- package/dist/chunk-FSU4SK32.js +786 -0
- package/dist/chunk-FSU4SK32.js.map +1 -0
- package/dist/chunk-HOZXDR6Y.js +52 -0
- package/dist/chunk-HOZXDR6Y.js.map +1 -0
- package/dist/index.cjs +1546 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +708 -5
- package/dist/index.d.ts +708 -5
- package/dist/index.js +934 -8
- package/dist/index.js.map +1 -1
- package/dist/{device-identity-DiwdLsUB.d.cts → operation-encryptor-DRmKNWpF.d.cts} +148 -2
- package/dist/{device-identity-DiwdLsUB.d.ts → operation-encryptor-DRmKNWpF.d.ts} +148 -2
- package/dist/{auth-client-CrDNuh10.d.cts → org-client-BVTLKcIk.d.cts} +177 -3
- package/dist/{auth-client-CrDNuh10.d.ts → org-client-BVTLKcIk.d.ts} +177 -3
- package/dist/password-hash-HDH6VQCQ.js +9 -0
- package/dist/password-hash-HDH6VQCQ.js.map +1 -0
- package/dist/react.cjs +183 -2
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +107 -2
- package/dist/react.d.ts +107 -2
- package/dist/react.js +186 -1
- package/dist/react.js.map +1 -1
- package/dist/server.cjs +5354 -233
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +3304 -97
- package/dist/server.d.ts +3304 -97
- package/dist/server.js +4653 -92
- package/dist/server.js.map +1 -1
- package/package.json +15 -6
- package/dist/chunk-L554ZDPY.js +0 -174
- package/dist/chunk-L554ZDPY.js.map +0 -1
package/dist/server.js
CHANGED
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
import {
|
|
2
|
+
OperationEncryptionError,
|
|
3
|
+
OperationEncryptor,
|
|
2
4
|
computePublicKeyThumbprint,
|
|
5
|
+
decodeCbor,
|
|
6
|
+
fromBase64Url,
|
|
7
|
+
isEncryptedField,
|
|
8
|
+
toBase64Url,
|
|
3
9
|
verifyChallenge
|
|
4
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-FSU4SK32.js";
|
|
11
|
+
import {
|
|
12
|
+
hashPassword,
|
|
13
|
+
verifyPassword
|
|
14
|
+
} from "./chunk-HOZXDR6Y.js";
|
|
15
|
+
|
|
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";
|
|
5
21
|
|
|
6
22
|
// src/types.ts
|
|
7
23
|
import { KoraError } from "@korajs/core";
|
|
@@ -60,6 +76,9 @@ function verifyJwt(token, secret) {
|
|
|
60
76
|
if (headerSegment === void 0 || payloadSegment === void 0 || signatureSegment === void 0) {
|
|
61
77
|
return null;
|
|
62
78
|
}
|
|
79
|
+
if (headerSegment !== ENCODED_HEADER) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
63
82
|
const signingInput = `${headerSegment}.${payloadSegment}`;
|
|
64
83
|
const expectedSignature = hmacSha256Base64url(signingInput, secret);
|
|
65
84
|
if (expectedSignature.length !== signatureSegment.length) {
|
|
@@ -83,25 +102,83 @@ function verifyJwt(token, secret) {
|
|
|
83
102
|
return null;
|
|
84
103
|
}
|
|
85
104
|
}
|
|
105
|
+
var CLOCK_SKEW_TOLERANCE_SECONDS = 5;
|
|
86
106
|
function isExpired(payload) {
|
|
87
107
|
if (typeof payload.exp !== "number") {
|
|
88
108
|
return false;
|
|
89
109
|
}
|
|
90
110
|
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
91
|
-
return nowSeconds >= payload.exp;
|
|
111
|
+
return nowSeconds >= payload.exp + CLOCK_SKEW_TOLERANCE_SECONDS;
|
|
92
112
|
}
|
|
93
113
|
|
|
94
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);
|
|
121
|
+
}
|
|
122
|
+
async revoke(jti, expiresAt) {
|
|
123
|
+
this.revokedTokens.set(jti, expiresAt);
|
|
124
|
+
}
|
|
125
|
+
async revokeAllForDevice(deviceId) {
|
|
126
|
+
this.revokedDevices.add(deviceId);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Check if a device has been revoked.
|
|
130
|
+
*/
|
|
131
|
+
isDeviceRevoked(deviceId) {
|
|
132
|
+
return this.revokedDevices.has(deviceId);
|
|
133
|
+
}
|
|
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
|
+
}
|
|
145
|
+
}
|
|
146
|
+
};
|
|
95
147
|
var TokenManager = class {
|
|
96
|
-
|
|
148
|
+
/** All signing/verification secrets (index 0 = current signing key) */
|
|
149
|
+
secrets;
|
|
97
150
|
accessTokenLifetime;
|
|
98
151
|
refreshTokenLifetime;
|
|
99
152
|
deviceCredentialLifetime;
|
|
153
|
+
revocationStore;
|
|
100
154
|
constructor(config) {
|
|
101
|
-
|
|
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;
|
|
102
167
|
this.accessTokenLifetime = config.accessTokenLifetime ?? DEFAULT_ACCESS_TOKEN_LIFETIME;
|
|
103
168
|
this.refreshTokenLifetime = config.refreshTokenLifetime ?? DEFAULT_REFRESH_TOKEN_LIFETIME;
|
|
104
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");
|
|
105
182
|
}
|
|
106
183
|
/**
|
|
107
184
|
* Issue a signed JWT access token.
|
|
@@ -117,13 +194,14 @@ var TokenManager = class {
|
|
|
117
194
|
issueAccessToken(userId, deviceId) {
|
|
118
195
|
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
119
196
|
const payload = {
|
|
197
|
+
jti: randomUUID(),
|
|
120
198
|
sub: userId,
|
|
121
199
|
dev: deviceId,
|
|
122
200
|
type: "access",
|
|
123
201
|
iat: nowSeconds,
|
|
124
202
|
exp: nowSeconds + Math.floor(this.accessTokenLifetime / 1e3)
|
|
125
203
|
};
|
|
126
|
-
return encodeJwt(payload, this.
|
|
204
|
+
return encodeJwt(payload, this.secrets[0]);
|
|
127
205
|
}
|
|
128
206
|
/**
|
|
129
207
|
* Issue a signed JWT refresh token.
|
|
@@ -139,13 +217,14 @@ var TokenManager = class {
|
|
|
139
217
|
issueRefreshToken(userId, deviceId) {
|
|
140
218
|
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
141
219
|
const payload = {
|
|
220
|
+
jti: randomUUID(),
|
|
142
221
|
sub: userId,
|
|
143
222
|
dev: deviceId,
|
|
144
223
|
type: "refresh",
|
|
145
224
|
iat: nowSeconds,
|
|
146
225
|
exp: nowSeconds + Math.floor(this.refreshTokenLifetime / 1e3)
|
|
147
226
|
};
|
|
148
|
-
return encodeJwt(payload, this.
|
|
227
|
+
return encodeJwt(payload, this.secrets[0]);
|
|
149
228
|
}
|
|
150
229
|
/**
|
|
151
230
|
* Issue a signed device credential token.
|
|
@@ -163,6 +242,7 @@ var TokenManager = class {
|
|
|
163
242
|
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
164
243
|
const lifetimeSeconds = Math.floor(this.deviceCredentialLifetime / 1e3);
|
|
165
244
|
const payload = {
|
|
245
|
+
jti: randomUUID(),
|
|
166
246
|
sub: userId,
|
|
167
247
|
dev: deviceId,
|
|
168
248
|
type: "device_credential",
|
|
@@ -171,7 +251,7 @@ var TokenManager = class {
|
|
|
171
251
|
dpk: publicKeyThumbprint,
|
|
172
252
|
mustCheckinBy: nowSeconds + lifetimeSeconds
|
|
173
253
|
};
|
|
174
|
-
return encodeJwt(payload, this.
|
|
254
|
+
return encodeJwt(payload, this.secrets[0]);
|
|
175
255
|
}
|
|
176
256
|
/**
|
|
177
257
|
* Issue a complete set of authentication tokens.
|
|
@@ -202,7 +282,8 @@ var TokenManager = class {
|
|
|
202
282
|
/**
|
|
203
283
|
* Validate and decode a token.
|
|
204
284
|
*
|
|
205
|
-
* Verifies the HMAC-SHA256 signature
|
|
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.
|
|
206
287
|
* Returns null (rather than throwing) for invalid or expired tokens, so callers
|
|
207
288
|
* can handle authentication failure without try/catch.
|
|
208
289
|
*
|
|
@@ -211,14 +292,20 @@ var TokenManager = class {
|
|
|
211
292
|
* invalid, expired, or missing required claims
|
|
212
293
|
*/
|
|
213
294
|
validateToken(token) {
|
|
214
|
-
|
|
295
|
+
let decoded = null;
|
|
296
|
+
for (const secret of this.secrets) {
|
|
297
|
+
decoded = verifyJwt(token, secret);
|
|
298
|
+
if (decoded !== null) {
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
215
302
|
if (decoded === null) {
|
|
216
303
|
return null;
|
|
217
304
|
}
|
|
218
305
|
if (isExpired(decoded)) {
|
|
219
306
|
return null;
|
|
220
307
|
}
|
|
221
|
-
if (typeof decoded["sub"] !== "string" || typeof decoded["dev"] !== "string" || typeof decoded["type"] !== "string" || typeof decoded["iat"] !== "number" || typeof decoded["exp"] !== "number") {
|
|
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") {
|
|
222
309
|
return null;
|
|
223
310
|
}
|
|
224
311
|
const type = decoded["type"];
|
|
@@ -226,6 +313,7 @@ var TokenManager = class {
|
|
|
226
313
|
return null;
|
|
227
314
|
}
|
|
228
315
|
return {
|
|
316
|
+
jti: decoded["jti"],
|
|
229
317
|
sub: decoded["sub"],
|
|
230
318
|
dev: decoded["dev"],
|
|
231
319
|
type,
|
|
@@ -233,19 +321,69 @@ var TokenManager = class {
|
|
|
233
321
|
exp: decoded["exp"]
|
|
234
322
|
};
|
|
235
323
|
}
|
|
324
|
+
/**
|
|
325
|
+
* Validate a token and check it against the revocation store.
|
|
326
|
+
*
|
|
327
|
+
* Like {@link validateToken}, but also checks whether the token's `jti` has been
|
|
328
|
+
* revoked. Requires a revocation store to be configured.
|
|
329
|
+
*
|
|
330
|
+
* @param token - The JWT string to validate
|
|
331
|
+
* @returns The decoded {@link TokenPayload} if valid and not revoked, or null otherwise
|
|
332
|
+
*/
|
|
333
|
+
async validateTokenWithRevocation(token) {
|
|
334
|
+
const payload = this.validateToken(token);
|
|
335
|
+
if (payload === null) {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
if (this.revocationStore) {
|
|
339
|
+
const revoked = await this.revocationStore.isRevoked(payload.jti);
|
|
340
|
+
if (revoked) {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return payload;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Revoke a specific token by its JWT ID.
|
|
348
|
+
*
|
|
349
|
+
* Requires a revocation store to be configured. After revocation, the token
|
|
350
|
+
* will be rejected by {@link validateTokenWithRevocation}.
|
|
351
|
+
*
|
|
352
|
+
* @param jti - The JWT ID of the token to revoke
|
|
353
|
+
* @param expiresAt - The token's expiration time (seconds since epoch)
|
|
354
|
+
*/
|
|
355
|
+
async revokeToken(jti, expiresAt) {
|
|
356
|
+
if (this.revocationStore) {
|
|
357
|
+
await this.revocationStore.revoke(jti, expiresAt);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Revoke all tokens for a specific device.
|
|
362
|
+
*
|
|
363
|
+
* Called when a device is revoked to ensure all its existing tokens
|
|
364
|
+
* (access, refresh, and device credentials) are invalidated.
|
|
365
|
+
*
|
|
366
|
+
* @param deviceId - The device ID whose tokens should be revoked
|
|
367
|
+
*/
|
|
368
|
+
async revokeDeviceTokens(deviceId) {
|
|
369
|
+
if (this.revocationStore) {
|
|
370
|
+
await this.revocationStore.revokeAllForDevice(deviceId);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
236
373
|
/**
|
|
237
374
|
* Refresh an access token using a valid refresh token.
|
|
238
375
|
*
|
|
239
|
-
* Implements **refresh token rotation**: a new refresh token
|
|
240
|
-
* the new access token
|
|
241
|
-
*
|
|
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.
|
|
242
380
|
*
|
|
243
381
|
* Returns null if the provided token is invalid, expired, or not a refresh token.
|
|
244
382
|
*
|
|
245
383
|
* @param refreshToken - The refresh token JWT string
|
|
246
384
|
* @returns A new access/refresh token pair, or null if the refresh token is invalid
|
|
247
385
|
*/
|
|
248
|
-
refreshAccessToken(refreshToken) {
|
|
386
|
+
async refreshAccessToken(refreshToken) {
|
|
249
387
|
const payload = this.validateToken(refreshToken);
|
|
250
388
|
if (payload === null) {
|
|
251
389
|
return null;
|
|
@@ -253,6 +391,14 @@ var TokenManager = class {
|
|
|
253
391
|
if (payload.type !== "refresh") {
|
|
254
392
|
return null;
|
|
255
393
|
}
|
|
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);
|
|
401
|
+
}
|
|
256
402
|
return {
|
|
257
403
|
accessToken: this.issueAccessToken(payload.sub, payload.dev),
|
|
258
404
|
refreshToken: this.issueRefreshToken(payload.sub, payload.dev)
|
|
@@ -260,62 +406,14 @@ var TokenManager = class {
|
|
|
260
406
|
}
|
|
261
407
|
};
|
|
262
408
|
|
|
263
|
-
// src/provider/built-in/password-hash.ts
|
|
264
|
-
import { randomBytes, pbkdf2, timingSafeEqual } from "crypto";
|
|
265
|
-
var PBKDF2_ITERATIONS = 6e5;
|
|
266
|
-
var PBKDF2_DIGEST = "sha512";
|
|
267
|
-
var KEY_LENGTH = 64;
|
|
268
|
-
var SALT_LENGTH = 32;
|
|
269
|
-
async function hashPassword(password) {
|
|
270
|
-
const salt = randomBytes(SALT_LENGTH);
|
|
271
|
-
const derivedKey = await pbkdf2Async(
|
|
272
|
-
password,
|
|
273
|
-
salt,
|
|
274
|
-
PBKDF2_ITERATIONS,
|
|
275
|
-
KEY_LENGTH,
|
|
276
|
-
PBKDF2_DIGEST
|
|
277
|
-
);
|
|
278
|
-
return {
|
|
279
|
-
hash: derivedKey.toString("hex"),
|
|
280
|
-
salt: salt.toString("hex")
|
|
281
|
-
};
|
|
282
|
-
}
|
|
283
|
-
async function verifyPassword(password, hash, salt) {
|
|
284
|
-
const saltBuffer = Buffer.from(salt, "hex");
|
|
285
|
-
const derivedKey = await pbkdf2Async(
|
|
286
|
-
password,
|
|
287
|
-
saltBuffer,
|
|
288
|
-
PBKDF2_ITERATIONS,
|
|
289
|
-
KEY_LENGTH,
|
|
290
|
-
PBKDF2_DIGEST
|
|
291
|
-
);
|
|
292
|
-
const hashBuffer = Buffer.from(hash, "hex");
|
|
293
|
-
if (derivedKey.length !== hashBuffer.length) {
|
|
294
|
-
return false;
|
|
295
|
-
}
|
|
296
|
-
return timingSafeEqual(derivedKey, hashBuffer);
|
|
297
|
-
}
|
|
298
|
-
function pbkdf2Async(password, salt, iterations, keyLength, digest) {
|
|
299
|
-
return new Promise((resolve, reject) => {
|
|
300
|
-
pbkdf2(password, salt, iterations, keyLength, digest, (err, derivedKey) => {
|
|
301
|
-
if (err) {
|
|
302
|
-
reject(err);
|
|
303
|
-
} else {
|
|
304
|
-
resolve(derivedKey);
|
|
305
|
-
}
|
|
306
|
-
});
|
|
307
|
-
});
|
|
308
|
-
}
|
|
309
|
-
|
|
310
409
|
// src/provider/built-in/user-store.ts
|
|
311
410
|
import { KoraError as KoraError2 } from "@korajs/core";
|
|
312
|
-
import { randomUUID } from "crypto";
|
|
411
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
313
412
|
var DuplicateEmailError = class extends KoraError2 {
|
|
314
|
-
constructor(
|
|
413
|
+
constructor() {
|
|
315
414
|
super(
|
|
316
|
-
|
|
317
|
-
"DUPLICATE_EMAIL"
|
|
318
|
-
{ email }
|
|
415
|
+
"A user with this email already exists.",
|
|
416
|
+
"DUPLICATE_EMAIL"
|
|
319
417
|
);
|
|
320
418
|
this.name = "DuplicateEmailError";
|
|
321
419
|
}
|
|
@@ -343,14 +441,15 @@ var InMemoryUserStore = class {
|
|
|
343
441
|
async createUser(params) {
|
|
344
442
|
const normalizedEmail = params.email.toLowerCase();
|
|
345
443
|
if (this.usersByEmail.has(normalizedEmail)) {
|
|
346
|
-
throw new DuplicateEmailError(
|
|
444
|
+
throw new DuplicateEmailError();
|
|
347
445
|
}
|
|
348
446
|
const now = Date.now();
|
|
349
|
-
const id =
|
|
447
|
+
const id = randomUUID2();
|
|
350
448
|
const storedUser = {
|
|
351
449
|
id,
|
|
352
450
|
email: normalizedEmail,
|
|
353
451
|
name: params.name,
|
|
452
|
+
emailVerified: false,
|
|
354
453
|
createdAt: now,
|
|
355
454
|
passwordHash: params.passwordHash,
|
|
356
455
|
salt: params.salt
|
|
@@ -458,6 +557,69 @@ var InMemoryUserStore = class {
|
|
|
458
557
|
device.revoked = true;
|
|
459
558
|
}
|
|
460
559
|
}
|
|
560
|
+
/**
|
|
561
|
+
* Set a user's email verification status.
|
|
562
|
+
*
|
|
563
|
+
* @param userId - The user whose email to verify
|
|
564
|
+
* @param verified - Whether the email is verified
|
|
565
|
+
*/
|
|
566
|
+
async setEmailVerified(userId, verified) {
|
|
567
|
+
const user = this.usersById.get(userId);
|
|
568
|
+
if (!user) return;
|
|
569
|
+
const updated = { ...user, emailVerified: verified };
|
|
570
|
+
this.usersById.set(userId, updated);
|
|
571
|
+
this.usersByEmail.set(user.email, updated);
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Update a user's password hash and salt.
|
|
575
|
+
*
|
|
576
|
+
* @param userId - The user whose password to update
|
|
577
|
+
* @param passwordHash - New hex-encoded PBKDF2 derived key
|
|
578
|
+
* @param salt - New hex-encoded salt
|
|
579
|
+
*/
|
|
580
|
+
async updatePassword(userId, passwordHash, salt) {
|
|
581
|
+
const user = this.usersById.get(userId);
|
|
582
|
+
if (!user) return;
|
|
583
|
+
const updated = { ...user, passwordHash, salt };
|
|
584
|
+
this.usersById.set(userId, updated);
|
|
585
|
+
this.usersByEmail.set(user.email, updated);
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* List all users. For admin/development use.
|
|
589
|
+
*/
|
|
590
|
+
async listAll() {
|
|
591
|
+
return [...this.usersById.values()];
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Update a stored user record.
|
|
595
|
+
*/
|
|
596
|
+
async update(user) {
|
|
597
|
+
const existing = this.usersById.get(user.id);
|
|
598
|
+
if (!existing) return;
|
|
599
|
+
if (existing.email !== user.email) {
|
|
600
|
+
this.usersByEmail.delete(existing.email);
|
|
601
|
+
this.usersByEmail.set(user.email, user);
|
|
602
|
+
} else {
|
|
603
|
+
this.usersByEmail.set(user.email, user);
|
|
604
|
+
}
|
|
605
|
+
this.usersById.set(user.id, user);
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Delete a user and all associated devices.
|
|
609
|
+
*/
|
|
610
|
+
async delete(userId) {
|
|
611
|
+
const user = this.usersById.get(userId);
|
|
612
|
+
if (!user) return;
|
|
613
|
+
this.usersById.delete(userId);
|
|
614
|
+
this.usersByEmail.delete(user.email);
|
|
615
|
+
const deviceIds = this.devicesByUserId.get(userId);
|
|
616
|
+
if (deviceIds) {
|
|
617
|
+
for (const deviceId of deviceIds) {
|
|
618
|
+
this.devicesById.delete(deviceId);
|
|
619
|
+
}
|
|
620
|
+
this.devicesByUserId.delete(userId);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
461
623
|
/**
|
|
462
624
|
* Update the last-seen timestamp for a device.
|
|
463
625
|
*
|
|
@@ -478,12 +640,73 @@ function toAuthUser(stored) {
|
|
|
478
640
|
id: stored.id,
|
|
479
641
|
email: stored.email,
|
|
480
642
|
name: stored.name,
|
|
643
|
+
emailVerified: stored.emailVerified,
|
|
481
644
|
createdAt: stored.createdAt
|
|
482
645
|
};
|
|
483
646
|
}
|
|
484
647
|
|
|
485
648
|
// src/provider/built-in/auth-routes.ts
|
|
649
|
+
var InMemoryChallengeStore = class {
|
|
650
|
+
challenges = /* @__PURE__ */ new Map();
|
|
651
|
+
async store(challenge, deviceId, expiresAt) {
|
|
652
|
+
this.challenges.set(challenge, { deviceId, expiresAt });
|
|
653
|
+
}
|
|
654
|
+
async consume(challenge) {
|
|
655
|
+
const entry = this.challenges.get(challenge);
|
|
656
|
+
if (entry === void 0) {
|
|
657
|
+
return null;
|
|
658
|
+
}
|
|
659
|
+
this.challenges.delete(challenge);
|
|
660
|
+
if (Date.now() > entry.expiresAt) {
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
663
|
+
return { deviceId: entry.deviceId };
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Remove expired challenges to prevent unbounded memory growth.
|
|
667
|
+
*/
|
|
668
|
+
cleanup() {
|
|
669
|
+
const now = Date.now();
|
|
670
|
+
for (const [challenge, entry] of this.challenges) {
|
|
671
|
+
if (now > entry.expiresAt) {
|
|
672
|
+
this.challenges.delete(challenge);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
};
|
|
677
|
+
var InMemoryRateLimiter = class {
|
|
678
|
+
attempts = /* @__PURE__ */ new Map();
|
|
679
|
+
maxAttempts;
|
|
680
|
+
windowMs;
|
|
681
|
+
/**
|
|
682
|
+
* @param maxAttempts - Maximum number of attempts within the time window (default: 10)
|
|
683
|
+
* @param windowMs - Time window in milliseconds (default: 60,000 = 1 minute)
|
|
684
|
+
*/
|
|
685
|
+
constructor(maxAttempts = 10, windowMs = 6e4) {
|
|
686
|
+
this.maxAttempts = maxAttempts;
|
|
687
|
+
this.windowMs = windowMs;
|
|
688
|
+
}
|
|
689
|
+
async isAllowed(key) {
|
|
690
|
+
const now = Date.now();
|
|
691
|
+
const attempts = this.attempts.get(key) ?? [];
|
|
692
|
+
const recentAttempts = attempts.filter((t) => now - t < this.windowMs);
|
|
693
|
+
return recentAttempts.length < this.maxAttempts;
|
|
694
|
+
}
|
|
695
|
+
async record(key) {
|
|
696
|
+
const now = Date.now();
|
|
697
|
+
const attempts = this.attempts.get(key) ?? [];
|
|
698
|
+
const recentAttempts = attempts.filter((t) => now - t < this.windowMs);
|
|
699
|
+
recentAttempts.push(now);
|
|
700
|
+
this.attempts.set(key, recentAttempts);
|
|
701
|
+
}
|
|
702
|
+
async reset(key) {
|
|
703
|
+
this.attempts.delete(key);
|
|
704
|
+
}
|
|
705
|
+
};
|
|
486
706
|
var MIN_PASSWORD_LENGTH = 8;
|
|
707
|
+
var MAX_PASSWORD_LENGTH = 128;
|
|
708
|
+
var MAX_NAME_LENGTH = 200;
|
|
709
|
+
var CHALLENGE_TTL_MS = 6e4;
|
|
487
710
|
function isValidEmail(email) {
|
|
488
711
|
if (email.length === 0 || email.length > 254) {
|
|
489
712
|
return false;
|
|
@@ -504,12 +727,24 @@ function isValidEmail(email) {
|
|
|
504
727
|
}
|
|
505
728
|
return true;
|
|
506
729
|
}
|
|
730
|
+
function sanitizeName(name) {
|
|
731
|
+
const cleaned = name.replace(/[\x00-\x1f\x7f]/g, "");
|
|
732
|
+
const trimmed = cleaned.trim();
|
|
733
|
+
if (trimmed.length > MAX_NAME_LENGTH) {
|
|
734
|
+
return trimmed.slice(0, MAX_NAME_LENGTH);
|
|
735
|
+
}
|
|
736
|
+
return trimmed;
|
|
737
|
+
}
|
|
507
738
|
var BuiltInAuthRoutes = class {
|
|
508
739
|
userStore;
|
|
509
740
|
tokenManager;
|
|
741
|
+
challengeStore;
|
|
742
|
+
rateLimiter;
|
|
510
743
|
constructor(config) {
|
|
511
744
|
this.userStore = config.userStore;
|
|
512
745
|
this.tokenManager = config.tokenManager;
|
|
746
|
+
this.challengeStore = config.challengeStore ?? new InMemoryChallengeStore();
|
|
747
|
+
this.rateLimiter = config.rateLimiter ?? new InMemoryRateLimiter();
|
|
513
748
|
}
|
|
514
749
|
/**
|
|
515
750
|
* Handle user sign-up (POST /auth/signup).
|
|
@@ -519,13 +754,22 @@ var BuiltInAuthRoutes = class {
|
|
|
519
754
|
*
|
|
520
755
|
* @param body - Sign-up request body
|
|
521
756
|
* @param body.email - The user's email address
|
|
522
|
-
* @param body.password - The plaintext password (
|
|
757
|
+
* @param body.password - The plaintext password (8-128 characters)
|
|
523
758
|
* @param body.name - Optional display name (defaults to email local part)
|
|
524
759
|
* @param body.deviceId - Optional device ID to register
|
|
525
760
|
* @param body.devicePublicKey - Optional device public key (base64url)
|
|
761
|
+
* @param clientIp - Optional client IP for rate limiting
|
|
526
762
|
* @returns Auth response with the created user and tokens, or an error
|
|
527
763
|
*/
|
|
528
|
-
async handleSignUp(body) {
|
|
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
|
+
};
|
|
771
|
+
}
|
|
772
|
+
await this.rateLimiter.record(rateLimitKey);
|
|
529
773
|
if (!isValidEmail(body.email)) {
|
|
530
774
|
return {
|
|
531
775
|
status: 400,
|
|
@@ -542,14 +786,24 @@ var BuiltInAuthRoutes = class {
|
|
|
542
786
|
}
|
|
543
787
|
};
|
|
544
788
|
}
|
|
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
|
+
};
|
|
796
|
+
}
|
|
545
797
|
const { hash, salt } = await hashPassword(body.password);
|
|
798
|
+
const rawName = body.name ?? body.email.split("@")[0] ?? body.email;
|
|
799
|
+
const name = sanitizeName(rawName);
|
|
546
800
|
let user;
|
|
547
801
|
try {
|
|
548
802
|
user = await this.userStore.createUser({
|
|
549
803
|
email: body.email,
|
|
550
804
|
passwordHash: hash,
|
|
551
805
|
salt,
|
|
552
|
-
name
|
|
806
|
+
name
|
|
553
807
|
});
|
|
554
808
|
} catch (err) {
|
|
555
809
|
if (err instanceof Error && err.name === "DuplicateEmailError") {
|
|
@@ -586,9 +840,18 @@ var BuiltInAuthRoutes = class {
|
|
|
586
840
|
* @param body.password - The plaintext password
|
|
587
841
|
* @param body.deviceId - Optional device ID to register
|
|
588
842
|
* @param body.devicePublicKey - Optional device public key (base64url)
|
|
843
|
+
* @param clientIp - Optional client IP for rate limiting
|
|
589
844
|
* @returns Auth response with the user and tokens, or an error
|
|
590
845
|
*/
|
|
591
|
-
async handleSignIn(body) {
|
|
846
|
+
async handleSignIn(body, clientIp) {
|
|
847
|
+
const rateLimitKey = clientIp ? `signin:${body.email.toLowerCase()}:${clientIp}` : `signin:${body.email.toLowerCase()}`;
|
|
848
|
+
if (!await this.rateLimiter.isAllowed(rateLimitKey)) {
|
|
849
|
+
return {
|
|
850
|
+
status: 429,
|
|
851
|
+
body: { error: "Too many sign-in attempts. Please try again later." }
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
await this.rateLimiter.record(rateLimitKey);
|
|
592
855
|
const storedUser = await this.userStore.findByEmail(body.email);
|
|
593
856
|
if (storedUser === null) {
|
|
594
857
|
return {
|
|
@@ -607,6 +870,7 @@ var BuiltInAuthRoutes = class {
|
|
|
607
870
|
body: { error: "Invalid email or password." }
|
|
608
871
|
};
|
|
609
872
|
}
|
|
873
|
+
await this.rateLimiter.reset(rateLimitKey);
|
|
610
874
|
const deviceId = body.deviceId ?? `device-${storedUser.id}`;
|
|
611
875
|
if (body.deviceId !== void 0 && body.devicePublicKey !== void 0) {
|
|
612
876
|
await this.userStore.registerDevice({
|
|
@@ -621,6 +885,7 @@ var BuiltInAuthRoutes = class {
|
|
|
621
885
|
id: storedUser.id,
|
|
622
886
|
email: storedUser.email,
|
|
623
887
|
name: storedUser.name,
|
|
888
|
+
emailVerified: storedUser.emailVerified,
|
|
624
889
|
createdAt: storedUser.createdAt
|
|
625
890
|
};
|
|
626
891
|
return {
|
|
@@ -632,15 +897,15 @@ var BuiltInAuthRoutes = class {
|
|
|
632
897
|
* Handle token refresh (POST /auth/refresh).
|
|
633
898
|
*
|
|
634
899
|
* Validates the provided refresh token and issues a new token pair
|
|
635
|
-
* (refresh token rotation). The old refresh token
|
|
636
|
-
* consumed
|
|
900
|
+
* (refresh token rotation with reuse detection). The old refresh token
|
|
901
|
+
* is marked as consumed in the revocation store.
|
|
637
902
|
*
|
|
638
903
|
* @param body - Refresh request body
|
|
639
904
|
* @param body.refreshToken - The current refresh token
|
|
640
905
|
* @returns Auth response with new tokens, or an error
|
|
641
906
|
*/
|
|
642
907
|
async handleRefresh(body) {
|
|
643
|
-
const result = this.tokenManager.refreshAccessToken(body.refreshToken);
|
|
908
|
+
const result = await this.tokenManager.refreshAccessToken(body.refreshToken);
|
|
644
909
|
if (result === null) {
|
|
645
910
|
return {
|
|
646
911
|
status: 401,
|
|
@@ -652,6 +917,38 @@ var BuiltInAuthRoutes = class {
|
|
|
652
917
|
body: { data: result }
|
|
653
918
|
};
|
|
654
919
|
}
|
|
920
|
+
/**
|
|
921
|
+
* Handle sign-out (POST /auth/signout).
|
|
922
|
+
*
|
|
923
|
+
* Validates the access token and revokes the current refresh token
|
|
924
|
+
* (if a revocation store is configured). This ensures that stolen
|
|
925
|
+
* refresh tokens cannot be used after the user signs out.
|
|
926
|
+
*
|
|
927
|
+
* @param accessToken - The JWT access token (without "Bearer " prefix)
|
|
928
|
+
* @param body - Sign-out request body
|
|
929
|
+
* @param body.refreshToken - The current refresh token to revoke
|
|
930
|
+
* @returns Auth response with success flag, or an error
|
|
931
|
+
*/
|
|
932
|
+
async handleSignOut(accessToken, body) {
|
|
933
|
+
const payload = this.tokenManager.validateToken(accessToken);
|
|
934
|
+
if (payload === null || payload.type !== "access") {
|
|
935
|
+
return {
|
|
936
|
+
status: 401,
|
|
937
|
+
body: { error: "Invalid or expired access token." }
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
await this.tokenManager.revokeToken(payload.jti, payload.exp);
|
|
941
|
+
if (body.refreshToken) {
|
|
942
|
+
const refreshPayload = this.tokenManager.validateToken(body.refreshToken);
|
|
943
|
+
if (refreshPayload !== null && refreshPayload.type === "refresh") {
|
|
944
|
+
await this.tokenManager.revokeToken(refreshPayload.jti, refreshPayload.exp);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
return {
|
|
948
|
+
status: 200,
|
|
949
|
+
body: { data: { success: true } }
|
|
950
|
+
};
|
|
951
|
+
}
|
|
655
952
|
/**
|
|
656
953
|
* Handle get-current-user (GET /auth/me).
|
|
657
954
|
*
|
|
@@ -679,6 +976,7 @@ var BuiltInAuthRoutes = class {
|
|
|
679
976
|
id: storedUser.id,
|
|
680
977
|
email: storedUser.email,
|
|
681
978
|
name: storedUser.name,
|
|
979
|
+
emailVerified: storedUser.emailVerified,
|
|
682
980
|
createdAt: storedUser.createdAt
|
|
683
981
|
};
|
|
684
982
|
return {
|
|
@@ -711,8 +1009,8 @@ var BuiltInAuthRoutes = class {
|
|
|
711
1009
|
/**
|
|
712
1010
|
* Handle device revocation (DELETE /auth/device/:id).
|
|
713
1011
|
*
|
|
714
|
-
* Validates the access token
|
|
715
|
-
* Only the device's owner can revoke it.
|
|
1012
|
+
* Validates the access token, revokes the specified device, and invalidates
|
|
1013
|
+
* all tokens issued to that device. Only the device's owner can revoke it.
|
|
716
1014
|
*
|
|
717
1015
|
* @param accessToken - The JWT access token (without "Bearer " prefix)
|
|
718
1016
|
* @param deviceId - The ID of the device to revoke
|
|
@@ -740,11 +1038,207 @@ var BuiltInAuthRoutes = class {
|
|
|
740
1038
|
};
|
|
741
1039
|
}
|
|
742
1040
|
await this.userStore.revokeDevice(deviceId);
|
|
1041
|
+
await this.tokenManager.revokeDeviceTokens(deviceId);
|
|
743
1042
|
return {
|
|
744
1043
|
status: 200,
|
|
745
1044
|
body: { data: { success: true } }
|
|
746
1045
|
};
|
|
747
1046
|
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Handle device registration (POST /auth/device/register).
|
|
1049
|
+
*
|
|
1050
|
+
* Requires a valid access token. Registers a new device for the authenticated
|
|
1051
|
+
* user and issues a device credential token bound to the device's public key.
|
|
1052
|
+
*
|
|
1053
|
+
* @param accessToken - The JWT access token (without "Bearer " prefix)
|
|
1054
|
+
* @param body - Device registration request body
|
|
1055
|
+
* @param body.deviceId - Unique identifier for the device
|
|
1056
|
+
* @param body.publicKey - The device's public key as a JWK JSON string
|
|
1057
|
+
* @param body.name - Human-readable device name (e.g., "Chrome on MacBook")
|
|
1058
|
+
* @returns Auth response with the registered device and device credential, or an error
|
|
1059
|
+
*/
|
|
1060
|
+
async handleDeviceRegister(accessToken, body) {
|
|
1061
|
+
const payload = this.tokenManager.validateToken(accessToken);
|
|
1062
|
+
if (payload === null || payload.type !== "access") {
|
|
1063
|
+
return {
|
|
1064
|
+
status: 401,
|
|
1065
|
+
body: { error: "Invalid or expired access token." }
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
const deviceName = sanitizeName(body.name);
|
|
1069
|
+
if (deviceName.length === 0) {
|
|
1070
|
+
return {
|
|
1071
|
+
status: 400,
|
|
1072
|
+
body: { error: "Device name must not be empty." }
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
let publicKeyJwk;
|
|
1076
|
+
try {
|
|
1077
|
+
publicKeyJwk = JSON.parse(body.publicKey);
|
|
1078
|
+
} catch {
|
|
1079
|
+
return {
|
|
1080
|
+
status: 400,
|
|
1081
|
+
body: { error: "Invalid public key format. Expected a JSON-encoded JWK string." }
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
let thumbprint;
|
|
1085
|
+
try {
|
|
1086
|
+
thumbprint = await computePublicKeyThumbprint(publicKeyJwk);
|
|
1087
|
+
} catch {
|
|
1088
|
+
return {
|
|
1089
|
+
status: 400,
|
|
1090
|
+
body: { error: "Failed to compute public key thumbprint. Ensure the key is a valid EC P-256 JWK." }
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
const device = await this.userStore.registerDevice({
|
|
1094
|
+
id: body.deviceId,
|
|
1095
|
+
userId: payload.sub,
|
|
1096
|
+
publicKey: body.publicKey,
|
|
1097
|
+
name: deviceName
|
|
1098
|
+
});
|
|
1099
|
+
const deviceCredential = this.tokenManager.issueDeviceCredential(
|
|
1100
|
+
payload.sub,
|
|
1101
|
+
body.deviceId,
|
|
1102
|
+
thumbprint
|
|
1103
|
+
);
|
|
1104
|
+
return {
|
|
1105
|
+
status: 201,
|
|
1106
|
+
body: { data: { device, deviceCredential } }
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* Generate a challenge for device proof-of-possession verification.
|
|
1111
|
+
*
|
|
1112
|
+
* Creates a cryptographically random challenge, stores it server-side with
|
|
1113
|
+
* a 60-second TTL and the target device ID, and returns the challenge string.
|
|
1114
|
+
* The client signs this challenge with its private key and submits it via
|
|
1115
|
+
* {@link handleDeviceVerify}.
|
|
1116
|
+
*
|
|
1117
|
+
* @param accessToken - The JWT access token (without "Bearer " prefix)
|
|
1118
|
+
* @param deviceId - The device this challenge is intended for
|
|
1119
|
+
* @returns Auth response with the challenge string, or an error
|
|
1120
|
+
*/
|
|
1121
|
+
async handleDeviceChallenge(accessToken, deviceId) {
|
|
1122
|
+
const payload = this.tokenManager.validateToken(accessToken);
|
|
1123
|
+
if (payload === null || payload.type !== "access") {
|
|
1124
|
+
return {
|
|
1125
|
+
status: 401,
|
|
1126
|
+
body: { error: "Invalid or expired access token." }
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
const device = await this.userStore.findDevice(deviceId);
|
|
1130
|
+
if (device === null || device.userId !== payload.sub) {
|
|
1131
|
+
return {
|
|
1132
|
+
status: 404,
|
|
1133
|
+
body: { error: "Device not found." }
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
if (device.revoked) {
|
|
1137
|
+
return {
|
|
1138
|
+
status: 403,
|
|
1139
|
+
body: { error: "Device has been revoked." }
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
const challenge = randomBytes2(32).toString("hex");
|
|
1143
|
+
const expiresAt = Date.now() + CHALLENGE_TTL_MS;
|
|
1144
|
+
await this.challengeStore.store(challenge, deviceId, expiresAt);
|
|
1145
|
+
return {
|
|
1146
|
+
status: 200,
|
|
1147
|
+
body: { data: { challenge } }
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Handle device proof-of-possession verification (POST /auth/device/verify).
|
|
1152
|
+
*
|
|
1153
|
+
* Verifies that the device holds the private key corresponding to its registered
|
|
1154
|
+
* public key by checking a signed challenge. The challenge must have been previously
|
|
1155
|
+
* issued via {@link handleDeviceChallenge} and is single-use.
|
|
1156
|
+
*
|
|
1157
|
+
* On success, issues fresh tokens for the device.
|
|
1158
|
+
*
|
|
1159
|
+
* @param body - Device verification request body
|
|
1160
|
+
* @param body.deviceId - The ID of the device to verify
|
|
1161
|
+
* @param body.challenge - The challenge string (from handleDeviceChallenge)
|
|
1162
|
+
* @param body.signature - The base64url-encoded ECDSA signature of the challenge
|
|
1163
|
+
* @returns Auth response with fresh tokens on success, or an error
|
|
1164
|
+
*/
|
|
1165
|
+
async handleDeviceVerify(body) {
|
|
1166
|
+
const challengeEntry = await this.challengeStore.consume(body.challenge);
|
|
1167
|
+
if (challengeEntry === null) {
|
|
1168
|
+
return {
|
|
1169
|
+
status: 401,
|
|
1170
|
+
body: { error: "Invalid or expired challenge. Request a new challenge and try again." }
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
if (challengeEntry.deviceId !== body.deviceId) {
|
|
1174
|
+
return {
|
|
1175
|
+
status: 401,
|
|
1176
|
+
body: { error: "Challenge was not issued for this device." }
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
const device = await this.userStore.findDevice(body.deviceId);
|
|
1180
|
+
if (device === null) {
|
|
1181
|
+
return {
|
|
1182
|
+
status: 404,
|
|
1183
|
+
body: { error: "Device not found." }
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
if (device.revoked) {
|
|
1187
|
+
return {
|
|
1188
|
+
status: 403,
|
|
1189
|
+
body: { error: "Device has been revoked and cannot authenticate." }
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
let publicKeyJwk;
|
|
1193
|
+
try {
|
|
1194
|
+
publicKeyJwk = JSON.parse(device.publicKey);
|
|
1195
|
+
} catch {
|
|
1196
|
+
return {
|
|
1197
|
+
status: 500,
|
|
1198
|
+
body: { error: "Device has an invalid stored public key." }
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
let isValid;
|
|
1202
|
+
try {
|
|
1203
|
+
isValid = await verifyChallenge(publicKeyJwk, body.challenge, body.signature);
|
|
1204
|
+
} catch {
|
|
1205
|
+
return {
|
|
1206
|
+
status: 400,
|
|
1207
|
+
body: { error: "Signature verification failed. The signature or public key format may be invalid." }
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
if (!isValid) {
|
|
1211
|
+
return {
|
|
1212
|
+
status: 401,
|
|
1213
|
+
body: { error: "Invalid signature. Proof-of-possession verification failed." }
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
let thumbprint;
|
|
1217
|
+
try {
|
|
1218
|
+
thumbprint = await computePublicKeyThumbprint(publicKeyJwk);
|
|
1219
|
+
} catch {
|
|
1220
|
+
return {
|
|
1221
|
+
status: 500,
|
|
1222
|
+
body: { error: "Failed to compute public key thumbprint." }
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
const tokens = this.tokenManager.issueTokens(device.userId, device.id, thumbprint);
|
|
1226
|
+
return {
|
|
1227
|
+
status: 200,
|
|
1228
|
+
body: { data: { tokens } }
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* Generates a random challenge string for proof-of-possession verification.
|
|
1233
|
+
*
|
|
1234
|
+
* **Deprecated:** Use {@link handleDeviceChallenge} instead, which stores
|
|
1235
|
+
* the challenge server-side with expiry and single-use semantics.
|
|
1236
|
+
*
|
|
1237
|
+
* @returns A 64-character hex string (32 random bytes)
|
|
1238
|
+
*/
|
|
1239
|
+
static generateChallenge() {
|
|
1240
|
+
return randomBytes2(32).toString("hex");
|
|
1241
|
+
}
|
|
748
1242
|
/**
|
|
749
1243
|
* Creates a sync server auth provider compatible with `@korajs/server`.
|
|
750
1244
|
*
|
|
@@ -753,6 +1247,9 @@ var BuiltInAuthRoutes = class {
|
|
|
753
1247
|
* context containing the user ID and device metadata. This bridges
|
|
754
1248
|
* the built-in auth system with the sync server's authentication layer.
|
|
755
1249
|
*
|
|
1250
|
+
* Also checks device revocation status during authentication, ensuring
|
|
1251
|
+
* that revoked devices are rejected even if their tokens haven't expired.
|
|
1252
|
+
*
|
|
756
1253
|
* @returns An object with an `authenticate` method suitable for KoraSyncServer's `auth` config
|
|
757
1254
|
*
|
|
758
1255
|
* @example
|
|
@@ -777,6 +1274,10 @@ var BuiltInAuthRoutes = class {
|
|
|
777
1274
|
if (user === null) {
|
|
778
1275
|
return null;
|
|
779
1276
|
}
|
|
1277
|
+
const device = await userStore.findDevice(payload.dev);
|
|
1278
|
+
if (device !== null && device.revoked) {
|
|
1279
|
+
return null;
|
|
1280
|
+
}
|
|
780
1281
|
await userStore.touchDevice(payload.dev);
|
|
781
1282
|
return {
|
|
782
1283
|
userId: payload.sub,
|
|
@@ -791,6 +1292,722 @@ var BuiltInAuthRoutes = class {
|
|
|
791
1292
|
}
|
|
792
1293
|
};
|
|
793
1294
|
|
|
1295
|
+
// src/provider/built-in/password-reset.ts
|
|
1296
|
+
import { KoraError as KoraError3 } from "@korajs/core";
|
|
1297
|
+
var PasswordResetError = class extends KoraError3 {
|
|
1298
|
+
constructor(message, code, context) {
|
|
1299
|
+
super(message, code, context);
|
|
1300
|
+
this.name = "PasswordResetError";
|
|
1301
|
+
}
|
|
1302
|
+
};
|
|
1303
|
+
var ResetTokenExpiredError = class extends PasswordResetError {
|
|
1304
|
+
constructor() {
|
|
1305
|
+
super("Password reset token has expired.", "RESET_TOKEN_EXPIRED");
|
|
1306
|
+
}
|
|
1307
|
+
};
|
|
1308
|
+
var ResetTokenNotFoundError = class extends PasswordResetError {
|
|
1309
|
+
constructor() {
|
|
1310
|
+
super("Password reset token not found or already used.", "RESET_TOKEN_NOT_FOUND");
|
|
1311
|
+
}
|
|
1312
|
+
};
|
|
1313
|
+
var ResetRateLimitedError = class extends PasswordResetError {
|
|
1314
|
+
constructor() {
|
|
1315
|
+
super("Too many password reset requests. Please try again later.", "RESET_RATE_LIMITED");
|
|
1316
|
+
}
|
|
1317
|
+
};
|
|
1318
|
+
var InMemoryPasswordResetStore = class {
|
|
1319
|
+
tokens = /* @__PURE__ */ new Map();
|
|
1320
|
+
async store(token) {
|
|
1321
|
+
this.tokens.set(token.token, token);
|
|
1322
|
+
}
|
|
1323
|
+
async get(token) {
|
|
1324
|
+
return this.tokens.get(token) ?? null;
|
|
1325
|
+
}
|
|
1326
|
+
async consume(token) {
|
|
1327
|
+
const entry = this.tokens.get(token);
|
|
1328
|
+
if (entry) {
|
|
1329
|
+
this.tokens.set(token, { ...entry, consumed: true });
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
async countActiveForEmail(email) {
|
|
1333
|
+
const now = Date.now();
|
|
1334
|
+
let count = 0;
|
|
1335
|
+
for (const token of this.tokens.values()) {
|
|
1336
|
+
if (token.email === email && !token.consumed && now < token.expiresAt) {
|
|
1337
|
+
count++;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
return count;
|
|
1341
|
+
}
|
|
1342
|
+
async cleanExpired() {
|
|
1343
|
+
const now = Date.now();
|
|
1344
|
+
let count = 0;
|
|
1345
|
+
for (const [key, token] of this.tokens) {
|
|
1346
|
+
if (now > token.expiresAt) {
|
|
1347
|
+
this.tokens.delete(key);
|
|
1348
|
+
count++;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
return count;
|
|
1352
|
+
}
|
|
1353
|
+
};
|
|
1354
|
+
var DEFAULT_TOKEN_TTL_MS = 60 * 60 * 1e3;
|
|
1355
|
+
var DEFAULT_MAX_REQUESTS = 3;
|
|
1356
|
+
var MIN_PASSWORD_LENGTH2 = 8;
|
|
1357
|
+
var MAX_PASSWORD_LENGTH2 = 128;
|
|
1358
|
+
var PasswordResetManager = class {
|
|
1359
|
+
userStore;
|
|
1360
|
+
resetStore;
|
|
1361
|
+
tokenTtlMs;
|
|
1362
|
+
maxRequestsPerEmail;
|
|
1363
|
+
onResetRequested;
|
|
1364
|
+
constructor(config) {
|
|
1365
|
+
this.userStore = config.userStore;
|
|
1366
|
+
this.resetStore = config.resetStore ?? new InMemoryPasswordResetStore();
|
|
1367
|
+
this.tokenTtlMs = config.tokenTtlMs ?? DEFAULT_TOKEN_TTL_MS;
|
|
1368
|
+
this.maxRequestsPerEmail = config.maxRequestsPerEmail ?? DEFAULT_MAX_REQUESTS;
|
|
1369
|
+
this.onResetRequested = config.onResetRequested;
|
|
1370
|
+
}
|
|
1371
|
+
/**
|
|
1372
|
+
* Request a password reset for an email.
|
|
1373
|
+
* Always returns success to prevent email enumeration.
|
|
1374
|
+
*
|
|
1375
|
+
* If a callback is configured, invokes it with the token.
|
|
1376
|
+
* In development mode (no callback), returns the token in the response.
|
|
1377
|
+
*/
|
|
1378
|
+
async requestReset(email) {
|
|
1379
|
+
const normalizedEmail = email.toLowerCase().trim();
|
|
1380
|
+
const successResponse = {
|
|
1381
|
+
status: 200,
|
|
1382
|
+
body: { data: { message: "If an account with that email exists, a password reset link has been sent." } }
|
|
1383
|
+
};
|
|
1384
|
+
const user = await this.userStore.findByEmail(normalizedEmail);
|
|
1385
|
+
if (!user) {
|
|
1386
|
+
return successResponse;
|
|
1387
|
+
}
|
|
1388
|
+
const activeCount = await this.resetStore.countActiveForEmail(normalizedEmail);
|
|
1389
|
+
if (activeCount >= this.maxRequestsPerEmail) {
|
|
1390
|
+
return successResponse;
|
|
1391
|
+
}
|
|
1392
|
+
const token = generateSecureToken();
|
|
1393
|
+
const now = Date.now();
|
|
1394
|
+
const resetToken = {
|
|
1395
|
+
token,
|
|
1396
|
+
userId: user.id,
|
|
1397
|
+
email: normalizedEmail,
|
|
1398
|
+
createdAt: now,
|
|
1399
|
+
expiresAt: now + this.tokenTtlMs,
|
|
1400
|
+
consumed: false
|
|
1401
|
+
};
|
|
1402
|
+
await this.resetStore.store(resetToken);
|
|
1403
|
+
if (this.onResetRequested) {
|
|
1404
|
+
try {
|
|
1405
|
+
await this.onResetRequested(normalizedEmail, token, resetToken.expiresAt);
|
|
1406
|
+
} catch {
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
if (!this.onResetRequested) {
|
|
1410
|
+
successResponse.body = { data: { message: "Password reset token generated.", token } };
|
|
1411
|
+
}
|
|
1412
|
+
return successResponse;
|
|
1413
|
+
}
|
|
1414
|
+
/**
|
|
1415
|
+
* Consume a reset token and set a new password.
|
|
1416
|
+
*/
|
|
1417
|
+
async resetPassword(token, newPassword) {
|
|
1418
|
+
if (typeof newPassword !== "string" || newPassword.length < MIN_PASSWORD_LENGTH2) {
|
|
1419
|
+
return {
|
|
1420
|
+
status: 400,
|
|
1421
|
+
body: { error: `Password must be at least ${MIN_PASSWORD_LENGTH2} characters.` }
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
if (newPassword.length > MAX_PASSWORD_LENGTH2) {
|
|
1425
|
+
return {
|
|
1426
|
+
status: 400,
|
|
1427
|
+
body: { error: `Password must be at most ${MAX_PASSWORD_LENGTH2} characters.` }
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1430
|
+
const resetToken = await this.resetStore.get(token);
|
|
1431
|
+
if (!resetToken || resetToken.consumed) {
|
|
1432
|
+
return { status: 404, body: { error: "Password reset token not found or already used." } };
|
|
1433
|
+
}
|
|
1434
|
+
if (Date.now() > resetToken.expiresAt) {
|
|
1435
|
+
await this.resetStore.consume(token);
|
|
1436
|
+
return { status: 410, body: { error: "Password reset token has expired." } };
|
|
1437
|
+
}
|
|
1438
|
+
await this.resetStore.consume(token);
|
|
1439
|
+
const hashed = await hashPassword(newPassword);
|
|
1440
|
+
await this.userStore.updatePassword(resetToken.userId, hashed.hash, hashed.salt);
|
|
1441
|
+
return { status: 200, body: { data: { message: "Password has been reset successfully." } } };
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* Change password for an authenticated user (requires current password verification).
|
|
1445
|
+
*/
|
|
1446
|
+
async changePassword(userId, currentPassword, newPassword) {
|
|
1447
|
+
if (typeof newPassword !== "string" || newPassword.length < MIN_PASSWORD_LENGTH2) {
|
|
1448
|
+
return {
|
|
1449
|
+
status: 400,
|
|
1450
|
+
body: { error: `New password must be at least ${MIN_PASSWORD_LENGTH2} characters.` }
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
if (newPassword.length > MAX_PASSWORD_LENGTH2) {
|
|
1454
|
+
return {
|
|
1455
|
+
status: 400,
|
|
1456
|
+
body: { error: `New password must be at most ${MAX_PASSWORD_LENGTH2} characters.` }
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
const user = await this.userStore.findById(userId);
|
|
1460
|
+
if (!user) {
|
|
1461
|
+
return { status: 404, body: { error: "User not found." } };
|
|
1462
|
+
}
|
|
1463
|
+
const { verifyPassword: verifyPassword2 } = await import("./password-hash-HDH6VQCQ.js");
|
|
1464
|
+
const isValid = await verifyPassword2(currentPassword, user.passwordHash, user.salt);
|
|
1465
|
+
if (!isValid) {
|
|
1466
|
+
return { status: 401, body: { error: "Current password is incorrect." } };
|
|
1467
|
+
}
|
|
1468
|
+
const hashed = await hashPassword(newPassword);
|
|
1469
|
+
await this.userStore.updatePassword(userId, hashed.hash, hashed.salt);
|
|
1470
|
+
return { status: 200, body: { data: { message: "Password changed successfully." } } };
|
|
1471
|
+
}
|
|
1472
|
+
};
|
|
1473
|
+
function generateSecureToken() {
|
|
1474
|
+
const bytes = new Uint8Array(32);
|
|
1475
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
1476
|
+
let binary = "";
|
|
1477
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
1478
|
+
binary += String.fromCharCode(bytes[i]);
|
|
1479
|
+
}
|
|
1480
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
// src/provider/built-in/email-verification.ts
|
|
1484
|
+
import { KoraError as KoraError4 } from "@korajs/core";
|
|
1485
|
+
var EmailVerificationError = class extends KoraError4 {
|
|
1486
|
+
constructor(message, code, context) {
|
|
1487
|
+
super(message, code, context);
|
|
1488
|
+
this.name = "EmailVerificationError";
|
|
1489
|
+
}
|
|
1490
|
+
};
|
|
1491
|
+
var VerificationTokenExpiredError = class extends EmailVerificationError {
|
|
1492
|
+
constructor() {
|
|
1493
|
+
super("Email verification token has expired.", "VERIFICATION_TOKEN_EXPIRED");
|
|
1494
|
+
}
|
|
1495
|
+
};
|
|
1496
|
+
var VerificationTokenNotFoundError = class extends EmailVerificationError {
|
|
1497
|
+
constructor() {
|
|
1498
|
+
super("Email verification token not found or already used.", "VERIFICATION_TOKEN_NOT_FOUND");
|
|
1499
|
+
}
|
|
1500
|
+
};
|
|
1501
|
+
var InMemoryEmailVerificationStore = class {
|
|
1502
|
+
tokens = /* @__PURE__ */ new Map();
|
|
1503
|
+
async store(token) {
|
|
1504
|
+
this.tokens.set(token.token, token);
|
|
1505
|
+
}
|
|
1506
|
+
async get(token) {
|
|
1507
|
+
return this.tokens.get(token) ?? null;
|
|
1508
|
+
}
|
|
1509
|
+
async consume(token) {
|
|
1510
|
+
const entry = this.tokens.get(token);
|
|
1511
|
+
if (entry) {
|
|
1512
|
+
this.tokens.set(token, { ...entry, consumed: true });
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
async countActiveForUser(userId) {
|
|
1516
|
+
const now = Date.now();
|
|
1517
|
+
let count = 0;
|
|
1518
|
+
for (const token of this.tokens.values()) {
|
|
1519
|
+
if (token.userId === userId && !token.consumed && now < token.expiresAt) {
|
|
1520
|
+
count++;
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
return count;
|
|
1524
|
+
}
|
|
1525
|
+
async cleanExpired() {
|
|
1526
|
+
const now = Date.now();
|
|
1527
|
+
let count = 0;
|
|
1528
|
+
for (const [key, token] of this.tokens) {
|
|
1529
|
+
if (now > token.expiresAt) {
|
|
1530
|
+
this.tokens.delete(key);
|
|
1531
|
+
count++;
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
return count;
|
|
1535
|
+
}
|
|
1536
|
+
};
|
|
1537
|
+
var DEFAULT_TOKEN_TTL_MS2 = 24 * 60 * 60 * 1e3;
|
|
1538
|
+
var DEFAULT_MAX_REQUESTS2 = 3;
|
|
1539
|
+
var EmailVerificationManager = class {
|
|
1540
|
+
userStore;
|
|
1541
|
+
verificationStore;
|
|
1542
|
+
tokenTtlMs;
|
|
1543
|
+
maxRequestsPerUser;
|
|
1544
|
+
onVerificationRequired;
|
|
1545
|
+
constructor(config) {
|
|
1546
|
+
this.userStore = config.userStore;
|
|
1547
|
+
this.verificationStore = config.verificationStore ?? new InMemoryEmailVerificationStore();
|
|
1548
|
+
this.tokenTtlMs = config.tokenTtlMs ?? DEFAULT_TOKEN_TTL_MS2;
|
|
1549
|
+
this.maxRequestsPerUser = config.maxRequestsPerUser ?? DEFAULT_MAX_REQUESTS2;
|
|
1550
|
+
this.onVerificationRequired = config.onVerificationRequired;
|
|
1551
|
+
}
|
|
1552
|
+
/**
|
|
1553
|
+
* Send a verification email for a user.
|
|
1554
|
+
* Rate-limited to prevent abuse.
|
|
1555
|
+
*/
|
|
1556
|
+
async sendVerification(userId, email) {
|
|
1557
|
+
const normalizedEmail = email.toLowerCase().trim();
|
|
1558
|
+
const activeCount = await this.verificationStore.countActiveForUser(userId);
|
|
1559
|
+
if (activeCount >= this.maxRequestsPerUser) {
|
|
1560
|
+
return { status: 429, body: { error: "Too many verification requests. Please try again later." } };
|
|
1561
|
+
}
|
|
1562
|
+
const token = generateSecureToken2();
|
|
1563
|
+
const now = Date.now();
|
|
1564
|
+
const verificationToken = {
|
|
1565
|
+
token,
|
|
1566
|
+
userId,
|
|
1567
|
+
email: normalizedEmail,
|
|
1568
|
+
createdAt: now,
|
|
1569
|
+
expiresAt: now + this.tokenTtlMs,
|
|
1570
|
+
consumed: false
|
|
1571
|
+
};
|
|
1572
|
+
await this.verificationStore.store(verificationToken);
|
|
1573
|
+
if (this.onVerificationRequired) {
|
|
1574
|
+
try {
|
|
1575
|
+
await this.onVerificationRequired(normalizedEmail, token, verificationToken.expiresAt);
|
|
1576
|
+
} catch {
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
const responseData = {
|
|
1580
|
+
message: "Verification email sent."
|
|
1581
|
+
};
|
|
1582
|
+
if (!this.onVerificationRequired) {
|
|
1583
|
+
responseData.token = token;
|
|
1584
|
+
}
|
|
1585
|
+
return { status: 200, body: { data: responseData } };
|
|
1586
|
+
}
|
|
1587
|
+
/**
|
|
1588
|
+
* Verify an email using a verification token.
|
|
1589
|
+
*/
|
|
1590
|
+
async verifyEmail(token) {
|
|
1591
|
+
const verificationToken = await this.verificationStore.get(token);
|
|
1592
|
+
if (!verificationToken || verificationToken.consumed) {
|
|
1593
|
+
return { status: 404, body: { error: "Verification token not found or already used." } };
|
|
1594
|
+
}
|
|
1595
|
+
if (Date.now() > verificationToken.expiresAt) {
|
|
1596
|
+
await this.verificationStore.consume(token);
|
|
1597
|
+
return { status: 410, body: { error: "Verification token has expired." } };
|
|
1598
|
+
}
|
|
1599
|
+
await this.verificationStore.consume(token);
|
|
1600
|
+
await this.userStore.setEmailVerified(verificationToken.userId, true);
|
|
1601
|
+
return {
|
|
1602
|
+
status: 200,
|
|
1603
|
+
body: {
|
|
1604
|
+
data: {
|
|
1605
|
+
message: "Email verified successfully.",
|
|
1606
|
+
userId: verificationToken.userId,
|
|
1607
|
+
email: verificationToken.email
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
/**
|
|
1613
|
+
* Resend verification email for a user.
|
|
1614
|
+
* Delegates to sendVerification with rate limiting.
|
|
1615
|
+
*/
|
|
1616
|
+
async resendVerification(userId) {
|
|
1617
|
+
const user = await this.userStore.findById(userId);
|
|
1618
|
+
if (!user) {
|
|
1619
|
+
return { status: 404, body: { error: "User not found." } };
|
|
1620
|
+
}
|
|
1621
|
+
return this.sendVerification(userId, user.email);
|
|
1622
|
+
}
|
|
1623
|
+
};
|
|
1624
|
+
function generateSecureToken2() {
|
|
1625
|
+
const bytes = new Uint8Array(32);
|
|
1626
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
1627
|
+
let binary = "";
|
|
1628
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
1629
|
+
binary += String.fromCharCode(bytes[i]);
|
|
1630
|
+
}
|
|
1631
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// src/provider/built-in/sqlite-user-store.ts
|
|
1635
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
1636
|
+
var SqliteUserStore = class {
|
|
1637
|
+
db;
|
|
1638
|
+
constructor(db) {
|
|
1639
|
+
this.db = db;
|
|
1640
|
+
this.db.pragma("journal_mode = WAL");
|
|
1641
|
+
this.ensureTables();
|
|
1642
|
+
}
|
|
1643
|
+
ensureTables() {
|
|
1644
|
+
this.db.exec(`
|
|
1645
|
+
CREATE TABLE IF NOT EXISTS auth_users (
|
|
1646
|
+
id TEXT PRIMARY KEY,
|
|
1647
|
+
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
|
1648
|
+
name TEXT NOT NULL,
|
|
1649
|
+
email_verified INTEGER NOT NULL DEFAULT 0,
|
|
1650
|
+
created_at INTEGER NOT NULL,
|
|
1651
|
+
password_hash TEXT NOT NULL,
|
|
1652
|
+
salt TEXT NOT NULL
|
|
1653
|
+
);
|
|
1654
|
+
|
|
1655
|
+
CREATE TABLE IF NOT EXISTS auth_devices (
|
|
1656
|
+
id TEXT PRIMARY KEY,
|
|
1657
|
+
user_id TEXT NOT NULL,
|
|
1658
|
+
public_key TEXT NOT NULL,
|
|
1659
|
+
name TEXT NOT NULL,
|
|
1660
|
+
revoked INTEGER NOT NULL DEFAULT 0,
|
|
1661
|
+
created_at INTEGER NOT NULL,
|
|
1662
|
+
last_seen_at INTEGER NOT NULL,
|
|
1663
|
+
FOREIGN KEY (user_id) REFERENCES auth_users(id) ON DELETE CASCADE
|
|
1664
|
+
);
|
|
1665
|
+
|
|
1666
|
+
CREATE INDEX IF NOT EXISTS idx_auth_devices_user_id ON auth_devices(user_id);
|
|
1667
|
+
`);
|
|
1668
|
+
}
|
|
1669
|
+
async createUser(params) {
|
|
1670
|
+
const normalizedEmail = params.email.toLowerCase();
|
|
1671
|
+
const now = Date.now();
|
|
1672
|
+
const id = randomUUID3();
|
|
1673
|
+
try {
|
|
1674
|
+
this.db.prepare(`
|
|
1675
|
+
INSERT INTO auth_users (id, email, name, email_verified, created_at, password_hash, salt)
|
|
1676
|
+
VALUES (?, ?, ?, 0, ?, ?, ?)
|
|
1677
|
+
`).run(id, normalizedEmail, params.name, now, params.passwordHash, params.salt);
|
|
1678
|
+
} catch (err) {
|
|
1679
|
+
if (err instanceof Error && err.message.includes("UNIQUE constraint failed")) {
|
|
1680
|
+
throw new DuplicateEmailError();
|
|
1681
|
+
}
|
|
1682
|
+
throw err;
|
|
1683
|
+
}
|
|
1684
|
+
return { id, email: normalizedEmail, name: params.name, emailVerified: false, createdAt: now };
|
|
1685
|
+
}
|
|
1686
|
+
async findByEmail(email) {
|
|
1687
|
+
const row = this.db.prepare(
|
|
1688
|
+
"SELECT * FROM auth_users WHERE email = ?"
|
|
1689
|
+
).get(email.toLowerCase());
|
|
1690
|
+
return row ? rowToStoredUser(row) : null;
|
|
1691
|
+
}
|
|
1692
|
+
async findById(id) {
|
|
1693
|
+
const row = this.db.prepare(
|
|
1694
|
+
"SELECT * FROM auth_users WHERE id = ?"
|
|
1695
|
+
).get(id);
|
|
1696
|
+
return row ? rowToStoredUser(row) : null;
|
|
1697
|
+
}
|
|
1698
|
+
async registerDevice(params) {
|
|
1699
|
+
const existing = this.db.prepare(
|
|
1700
|
+
"SELECT * FROM auth_devices WHERE id = ?"
|
|
1701
|
+
).get(params.id);
|
|
1702
|
+
if (existing && !existing.revoked) {
|
|
1703
|
+
return rowToDevice(existing);
|
|
1704
|
+
}
|
|
1705
|
+
const now = Date.now();
|
|
1706
|
+
if (existing) {
|
|
1707
|
+
this.db.prepare(`
|
|
1708
|
+
UPDATE auth_devices SET revoked = 0, public_key = ?, name = ?, last_seen_at = ?
|
|
1709
|
+
WHERE id = ?
|
|
1710
|
+
`).run(params.publicKey, params.name, now, params.id);
|
|
1711
|
+
} else {
|
|
1712
|
+
this.db.prepare(`
|
|
1713
|
+
INSERT INTO auth_devices (id, user_id, public_key, name, revoked, created_at, last_seen_at)
|
|
1714
|
+
VALUES (?, ?, ?, ?, 0, ?, ?)
|
|
1715
|
+
`).run(params.id, params.userId, params.publicKey, params.name, now, now);
|
|
1716
|
+
}
|
|
1717
|
+
return {
|
|
1718
|
+
id: params.id,
|
|
1719
|
+
userId: params.userId,
|
|
1720
|
+
publicKey: params.publicKey,
|
|
1721
|
+
name: params.name,
|
|
1722
|
+
revoked: false,
|
|
1723
|
+
createdAt: existing ? existing.created_at : now,
|
|
1724
|
+
lastSeenAt: now
|
|
1725
|
+
};
|
|
1726
|
+
}
|
|
1727
|
+
async findDevice(deviceId) {
|
|
1728
|
+
const row = this.db.prepare(
|
|
1729
|
+
"SELECT * FROM auth_devices WHERE id = ?"
|
|
1730
|
+
).get(deviceId);
|
|
1731
|
+
return row ? rowToDevice(row) : null;
|
|
1732
|
+
}
|
|
1733
|
+
async listDevices(userId) {
|
|
1734
|
+
const rows = this.db.prepare(
|
|
1735
|
+
"SELECT * FROM auth_devices WHERE user_id = ?"
|
|
1736
|
+
).all(userId);
|
|
1737
|
+
return rows.map(rowToDevice);
|
|
1738
|
+
}
|
|
1739
|
+
async revokeDevice(deviceId) {
|
|
1740
|
+
this.db.prepare("UPDATE auth_devices SET revoked = 1 WHERE id = ?").run(deviceId);
|
|
1741
|
+
}
|
|
1742
|
+
async setEmailVerified(userId, verified) {
|
|
1743
|
+
this.db.prepare(
|
|
1744
|
+
"UPDATE auth_users SET email_verified = ? WHERE id = ?"
|
|
1745
|
+
).run(verified ? 1 : 0, userId);
|
|
1746
|
+
}
|
|
1747
|
+
async updatePassword(userId, passwordHash, salt) {
|
|
1748
|
+
this.db.prepare(
|
|
1749
|
+
"UPDATE auth_users SET password_hash = ?, salt = ? WHERE id = ?"
|
|
1750
|
+
).run(passwordHash, salt, userId);
|
|
1751
|
+
}
|
|
1752
|
+
async listAll() {
|
|
1753
|
+
const rows = this.db.prepare("SELECT * FROM auth_users").all();
|
|
1754
|
+
return rows.map(rowToStoredUser);
|
|
1755
|
+
}
|
|
1756
|
+
async update(user) {
|
|
1757
|
+
this.db.prepare(`
|
|
1758
|
+
UPDATE auth_users
|
|
1759
|
+
SET email = ?, name = ?, email_verified = ?, password_hash = ?, salt = ?
|
|
1760
|
+
WHERE id = ?
|
|
1761
|
+
`).run(user.email, user.name, user.emailVerified ? 1 : 0, user.passwordHash, user.salt, user.id);
|
|
1762
|
+
}
|
|
1763
|
+
async delete(userId) {
|
|
1764
|
+
const deleteInTransaction = this.db.transaction(() => {
|
|
1765
|
+
this.db.prepare("DELETE FROM auth_devices WHERE user_id = ?").run(userId);
|
|
1766
|
+
this.db.prepare("DELETE FROM auth_users WHERE id = ?").run(userId);
|
|
1767
|
+
});
|
|
1768
|
+
deleteInTransaction();
|
|
1769
|
+
}
|
|
1770
|
+
async touchDevice(deviceId) {
|
|
1771
|
+
this.db.prepare(
|
|
1772
|
+
"UPDATE auth_devices SET last_seen_at = ? WHERE id = ?"
|
|
1773
|
+
).run(Date.now(), deviceId);
|
|
1774
|
+
}
|
|
1775
|
+
};
|
|
1776
|
+
async function createSqliteUserStore(options) {
|
|
1777
|
+
const Database = await loadBetterSqlite3();
|
|
1778
|
+
const db = new Database(options.filename);
|
|
1779
|
+
return new SqliteUserStore(db);
|
|
1780
|
+
}
|
|
1781
|
+
async function loadBetterSqlite3() {
|
|
1782
|
+
try {
|
|
1783
|
+
const { createRequire } = await import("module");
|
|
1784
|
+
const require2 = createRequire(import.meta.url);
|
|
1785
|
+
return require2("better-sqlite3");
|
|
1786
|
+
} catch {
|
|
1787
|
+
throw new Error(
|
|
1788
|
+
'SQLite backend requires the "better-sqlite3" package. Install it in your project dependencies.'
|
|
1789
|
+
);
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
function rowToStoredUser(row) {
|
|
1793
|
+
return {
|
|
1794
|
+
id: row.id,
|
|
1795
|
+
email: row.email,
|
|
1796
|
+
name: row.name,
|
|
1797
|
+
emailVerified: Boolean(row.email_verified),
|
|
1798
|
+
createdAt: row.created_at,
|
|
1799
|
+
passwordHash: row.password_hash,
|
|
1800
|
+
salt: row.salt
|
|
1801
|
+
};
|
|
1802
|
+
}
|
|
1803
|
+
function rowToDevice(row) {
|
|
1804
|
+
return {
|
|
1805
|
+
id: row.id,
|
|
1806
|
+
userId: row.user_id,
|
|
1807
|
+
publicKey: row.public_key,
|
|
1808
|
+
name: row.name,
|
|
1809
|
+
revoked: Boolean(row.revoked),
|
|
1810
|
+
createdAt: row.created_at,
|
|
1811
|
+
lastSeenAt: row.last_seen_at
|
|
1812
|
+
};
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
// src/provider/built-in/postgres-user-store.ts
|
|
1816
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
1817
|
+
var PostgresUserStore = class {
|
|
1818
|
+
sql;
|
|
1819
|
+
ready;
|
|
1820
|
+
constructor(sql) {
|
|
1821
|
+
this.sql = sql;
|
|
1822
|
+
this.ready = this.ensureTables();
|
|
1823
|
+
}
|
|
1824
|
+
async ensureTables() {
|
|
1825
|
+
await this.sql`
|
|
1826
|
+
CREATE TABLE IF NOT EXISTS auth_users (
|
|
1827
|
+
id TEXT PRIMARY KEY,
|
|
1828
|
+
email TEXT NOT NULL UNIQUE,
|
|
1829
|
+
name TEXT NOT NULL,
|
|
1830
|
+
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
|
1831
|
+
created_at BIGINT NOT NULL,
|
|
1832
|
+
password_hash TEXT NOT NULL,
|
|
1833
|
+
salt TEXT NOT NULL
|
|
1834
|
+
)
|
|
1835
|
+
`;
|
|
1836
|
+
await this.sql`
|
|
1837
|
+
CREATE TABLE IF NOT EXISTS auth_devices (
|
|
1838
|
+
id TEXT PRIMARY KEY,
|
|
1839
|
+
user_id TEXT NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE,
|
|
1840
|
+
public_key TEXT NOT NULL,
|
|
1841
|
+
name TEXT NOT NULL,
|
|
1842
|
+
revoked BOOLEAN NOT NULL DEFAULT FALSE,
|
|
1843
|
+
created_at BIGINT NOT NULL,
|
|
1844
|
+
last_seen_at BIGINT NOT NULL
|
|
1845
|
+
)
|
|
1846
|
+
`;
|
|
1847
|
+
await this.sql`
|
|
1848
|
+
CREATE INDEX IF NOT EXISTS idx_auth_devices_user_id ON auth_devices(user_id)
|
|
1849
|
+
`;
|
|
1850
|
+
await this.sql`
|
|
1851
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_users_email_lower ON auth_users(LOWER(email))
|
|
1852
|
+
`;
|
|
1853
|
+
}
|
|
1854
|
+
async createUser(params) {
|
|
1855
|
+
await this.ready;
|
|
1856
|
+
const normalizedEmail = params.email.toLowerCase();
|
|
1857
|
+
const now = Date.now();
|
|
1858
|
+
const id = randomUUID4();
|
|
1859
|
+
try {
|
|
1860
|
+
await this.sql`
|
|
1861
|
+
INSERT INTO auth_users (id, email, name, email_verified, created_at, password_hash, salt)
|
|
1862
|
+
VALUES (${id}, ${normalizedEmail}, ${params.name}, FALSE, ${now}, ${params.passwordHash}, ${params.salt})
|
|
1863
|
+
`;
|
|
1864
|
+
} catch (err) {
|
|
1865
|
+
if (err instanceof Error && (err.message.includes("unique constraint") || err.message.includes("duplicate key"))) {
|
|
1866
|
+
throw new DuplicateEmailError();
|
|
1867
|
+
}
|
|
1868
|
+
throw err;
|
|
1869
|
+
}
|
|
1870
|
+
return { id, email: normalizedEmail, name: params.name, emailVerified: false, createdAt: now };
|
|
1871
|
+
}
|
|
1872
|
+
async findByEmail(email) {
|
|
1873
|
+
await this.ready;
|
|
1874
|
+
const rows = await this.sql`
|
|
1875
|
+
SELECT * FROM auth_users WHERE LOWER(email) = ${email.toLowerCase()}
|
|
1876
|
+
`;
|
|
1877
|
+
return rows.length > 0 ? rowToStoredUser2(rows[0]) : null;
|
|
1878
|
+
}
|
|
1879
|
+
async findById(id) {
|
|
1880
|
+
await this.ready;
|
|
1881
|
+
const rows = await this.sql`
|
|
1882
|
+
SELECT * FROM auth_users WHERE id = ${id}
|
|
1883
|
+
`;
|
|
1884
|
+
return rows.length > 0 ? rowToStoredUser2(rows[0]) : null;
|
|
1885
|
+
}
|
|
1886
|
+
async registerDevice(params) {
|
|
1887
|
+
await this.ready;
|
|
1888
|
+
const existingRows = await this.sql`
|
|
1889
|
+
SELECT * FROM auth_devices WHERE id = ${params.id}
|
|
1890
|
+
`;
|
|
1891
|
+
if (existingRows.length > 0 && !existingRows[0].revoked) {
|
|
1892
|
+
return rowToDevice2(existingRows[0]);
|
|
1893
|
+
}
|
|
1894
|
+
const now = Date.now();
|
|
1895
|
+
if (existingRows.length > 0) {
|
|
1896
|
+
await this.sql`
|
|
1897
|
+
UPDATE auth_devices SET revoked = FALSE, public_key = ${params.publicKey}, name = ${params.name}, last_seen_at = ${now}
|
|
1898
|
+
WHERE id = ${params.id}
|
|
1899
|
+
`;
|
|
1900
|
+
} else {
|
|
1901
|
+
await this.sql`
|
|
1902
|
+
INSERT INTO auth_devices (id, user_id, public_key, name, revoked, created_at, last_seen_at)
|
|
1903
|
+
VALUES (${params.id}, ${params.userId}, ${params.publicKey}, ${params.name}, FALSE, ${now}, ${now})
|
|
1904
|
+
`;
|
|
1905
|
+
}
|
|
1906
|
+
return {
|
|
1907
|
+
id: params.id,
|
|
1908
|
+
userId: params.userId,
|
|
1909
|
+
publicKey: params.publicKey,
|
|
1910
|
+
name: params.name,
|
|
1911
|
+
revoked: false,
|
|
1912
|
+
createdAt: existingRows.length > 0 ? Number(existingRows[0].created_at) : now,
|
|
1913
|
+
lastSeenAt: now
|
|
1914
|
+
};
|
|
1915
|
+
}
|
|
1916
|
+
async findDevice(deviceId) {
|
|
1917
|
+
await this.ready;
|
|
1918
|
+
const rows = await this.sql`
|
|
1919
|
+
SELECT * FROM auth_devices WHERE id = ${deviceId}
|
|
1920
|
+
`;
|
|
1921
|
+
return rows.length > 0 ? rowToDevice2(rows[0]) : null;
|
|
1922
|
+
}
|
|
1923
|
+
async listDevices(userId) {
|
|
1924
|
+
await this.ready;
|
|
1925
|
+
const rows = await this.sql`
|
|
1926
|
+
SELECT * FROM auth_devices WHERE user_id = ${userId}
|
|
1927
|
+
`;
|
|
1928
|
+
return rows.map(rowToDevice2);
|
|
1929
|
+
}
|
|
1930
|
+
async revokeDevice(deviceId) {
|
|
1931
|
+
await this.ready;
|
|
1932
|
+
await this.sql`UPDATE auth_devices SET revoked = TRUE WHERE id = ${deviceId}`;
|
|
1933
|
+
}
|
|
1934
|
+
async setEmailVerified(userId, verified) {
|
|
1935
|
+
await this.ready;
|
|
1936
|
+
await this.sql`UPDATE auth_users SET email_verified = ${verified} WHERE id = ${userId}`;
|
|
1937
|
+
}
|
|
1938
|
+
async updatePassword(userId, passwordHash, salt) {
|
|
1939
|
+
await this.ready;
|
|
1940
|
+
await this.sql`
|
|
1941
|
+
UPDATE auth_users SET password_hash = ${passwordHash}, salt = ${salt}
|
|
1942
|
+
WHERE id = ${userId}
|
|
1943
|
+
`;
|
|
1944
|
+
}
|
|
1945
|
+
async listAll() {
|
|
1946
|
+
await this.ready;
|
|
1947
|
+
const rows = await this.sql`SELECT * FROM auth_users`;
|
|
1948
|
+
return rows.map(rowToStoredUser2);
|
|
1949
|
+
}
|
|
1950
|
+
async update(user) {
|
|
1951
|
+
await this.ready;
|
|
1952
|
+
await this.sql`
|
|
1953
|
+
UPDATE auth_users
|
|
1954
|
+
SET email = ${user.email}, name = ${user.name}, email_verified = ${user.emailVerified},
|
|
1955
|
+
password_hash = ${user.passwordHash}, salt = ${user.salt}
|
|
1956
|
+
WHERE id = ${user.id}
|
|
1957
|
+
`;
|
|
1958
|
+
}
|
|
1959
|
+
async delete(userId) {
|
|
1960
|
+
await this.ready;
|
|
1961
|
+
await this.sql.begin(async (tx) => {
|
|
1962
|
+
await tx`DELETE FROM auth_devices WHERE user_id = ${userId}`;
|
|
1963
|
+
await tx`DELETE FROM auth_users WHERE id = ${userId}`;
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1966
|
+
async touchDevice(deviceId) {
|
|
1967
|
+
await this.ready;
|
|
1968
|
+
const now = Date.now();
|
|
1969
|
+
await this.sql`UPDATE auth_devices SET last_seen_at = ${now} WHERE id = ${deviceId}`;
|
|
1970
|
+
}
|
|
1971
|
+
};
|
|
1972
|
+
async function createPostgresUserStore(options) {
|
|
1973
|
+
const postgresClient = await loadPostgresDeps();
|
|
1974
|
+
const sql = postgresClient(options.connectionString);
|
|
1975
|
+
return new PostgresUserStore(sql);
|
|
1976
|
+
}
|
|
1977
|
+
async function loadPostgresDeps() {
|
|
1978
|
+
try {
|
|
1979
|
+
const dynamicImport = new Function("specifier", "return import(specifier)");
|
|
1980
|
+
const postgresMod = await dynamicImport("postgres");
|
|
1981
|
+
return postgresMod.default;
|
|
1982
|
+
} catch {
|
|
1983
|
+
throw new Error(
|
|
1984
|
+
'PostgreSQL backend requires the "postgres" package. Install it in your project dependencies.'
|
|
1985
|
+
);
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
function rowToStoredUser2(row) {
|
|
1989
|
+
return {
|
|
1990
|
+
id: row.id,
|
|
1991
|
+
email: row.email,
|
|
1992
|
+
name: row.name,
|
|
1993
|
+
emailVerified: Boolean(row.email_verified),
|
|
1994
|
+
createdAt: Number(row.created_at),
|
|
1995
|
+
passwordHash: row.password_hash,
|
|
1996
|
+
salt: row.salt
|
|
1997
|
+
};
|
|
1998
|
+
}
|
|
1999
|
+
function rowToDevice2(row) {
|
|
2000
|
+
return {
|
|
2001
|
+
id: row.id,
|
|
2002
|
+
userId: row.user_id,
|
|
2003
|
+
publicKey: row.public_key,
|
|
2004
|
+
name: row.name,
|
|
2005
|
+
revoked: Boolean(row.revoked),
|
|
2006
|
+
createdAt: Number(row.created_at),
|
|
2007
|
+
lastSeenAt: Number(row.last_seen_at)
|
|
2008
|
+
};
|
|
2009
|
+
}
|
|
2010
|
+
|
|
794
2011
|
// src/provider/adapter.ts
|
|
795
2012
|
var AuthProviderError = class extends Error {
|
|
796
2013
|
/** HTTP-style status code */
|
|
@@ -852,6 +2069,7 @@ var BuiltInProvider = class {
|
|
|
852
2069
|
id: stored.id,
|
|
853
2070
|
email: stored.email,
|
|
854
2071
|
name: stored.name,
|
|
2072
|
+
emailVerified: stored.emailVerified,
|
|
855
2073
|
createdAt: stored.createdAt
|
|
856
2074
|
};
|
|
857
2075
|
}
|
|
@@ -871,20 +2089,3363 @@ var BuiltInProvider = class {
|
|
|
871
2089
|
return result.body.data;
|
|
872
2090
|
}
|
|
873
2091
|
};
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
2092
|
+
|
|
2093
|
+
// src/provider/external/external-jwt-provider.ts
|
|
2094
|
+
import { KoraError as KoraError5 } from "@korajs/core";
|
|
2095
|
+
var ExternalAuthOperationNotSupportedError = class extends KoraError5 {
|
|
2096
|
+
constructor(operation, provider) {
|
|
2097
|
+
super(
|
|
2098
|
+
`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.`,
|
|
2099
|
+
"AUTH_EXTERNAL_OPERATION_NOT_SUPPORTED",
|
|
2100
|
+
{ operation, provider }
|
|
2101
|
+
);
|
|
2102
|
+
this.name = "ExternalAuthOperationNotSupportedError";
|
|
2103
|
+
}
|
|
2104
|
+
};
|
|
2105
|
+
var ExternalTokenValidationError = class extends KoraError5 {
|
|
2106
|
+
constructor(reason, context) {
|
|
2107
|
+
super(
|
|
2108
|
+
`External token validation failed: ${reason}`,
|
|
2109
|
+
"AUTH_EXTERNAL_TOKEN_INVALID",
|
|
2110
|
+
context
|
|
2111
|
+
);
|
|
2112
|
+
this.name = "ExternalTokenValidationError";
|
|
2113
|
+
}
|
|
2114
|
+
};
|
|
2115
|
+
function defaultMapClaims(claims) {
|
|
2116
|
+
const sub = claims["sub"];
|
|
2117
|
+
if (typeof sub !== "string" || sub.length === 0) {
|
|
2118
|
+
throw new ExternalTokenValidationError(
|
|
2119
|
+
'JWT is missing a valid "sub" (subject) claim. The "sub" claim must be a non-empty string identifying the user.',
|
|
2120
|
+
{ availableClaims: Object.keys(claims) }
|
|
2121
|
+
);
|
|
2122
|
+
}
|
|
2123
|
+
return {
|
|
2124
|
+
userId: sub,
|
|
2125
|
+
email: typeof claims["email"] === "string" ? claims["email"] : void 0,
|
|
2126
|
+
name: typeof claims["name"] === "string" ? claims["name"] : void 0,
|
|
2127
|
+
metadata: void 0
|
|
2128
|
+
};
|
|
2129
|
+
}
|
|
2130
|
+
var ExternalJwtProvider = class {
|
|
2131
|
+
providerName;
|
|
2132
|
+
jwtSecret;
|
|
2133
|
+
customValidateToken;
|
|
2134
|
+
mapClaims;
|
|
2135
|
+
constructor(config) {
|
|
2136
|
+
if (config.validateToken === void 0 && config.jwtSecret === void 0) {
|
|
2137
|
+
throw new ExternalTokenValidationError(
|
|
2138
|
+
'ExternalJwtProvider requires either a "jwtSecret" for HS256 verification or a custom "validateToken" function. Provide at least one.',
|
|
2139
|
+
{ providerName: config.providerName }
|
|
2140
|
+
);
|
|
2141
|
+
}
|
|
2142
|
+
this.providerName = config.providerName;
|
|
2143
|
+
this.jwtSecret = config.jwtSecret;
|
|
2144
|
+
this.customValidateToken = config.validateToken;
|
|
2145
|
+
this.mapClaims = config.mapClaims ?? defaultMapClaims;
|
|
2146
|
+
}
|
|
2147
|
+
/**
|
|
2148
|
+
* Not supported for external providers.
|
|
2149
|
+
*
|
|
2150
|
+
* User registration must be performed through the external auth provider's
|
|
2151
|
+
* SDK or UI. Kora does not manage user accounts for external providers.
|
|
2152
|
+
*
|
|
2153
|
+
* @throws {ExternalAuthOperationNotSupportedError} Always
|
|
2154
|
+
*/
|
|
2155
|
+
async signUp(_params) {
|
|
2156
|
+
throw new ExternalAuthOperationNotSupportedError("signUp", this.providerName);
|
|
2157
|
+
}
|
|
2158
|
+
/**
|
|
2159
|
+
* Not supported for external providers.
|
|
2160
|
+
*
|
|
2161
|
+
* User authentication must be performed through the external auth provider's
|
|
2162
|
+
* SDK or UI. Kora does not manage credentials for external providers.
|
|
2163
|
+
*
|
|
2164
|
+
* @throws {ExternalAuthOperationNotSupportedError} Always
|
|
2165
|
+
*/
|
|
2166
|
+
async signIn(_params) {
|
|
2167
|
+
throw new ExternalAuthOperationNotSupportedError("signIn", this.providerName);
|
|
2168
|
+
}
|
|
2169
|
+
/**
|
|
2170
|
+
* Not supported for external providers.
|
|
2171
|
+
*
|
|
2172
|
+
* Token refresh must be performed through the external auth provider's SDK.
|
|
2173
|
+
* Kora does not manage token lifecycle for external providers.
|
|
2174
|
+
*
|
|
2175
|
+
* @throws {ExternalAuthOperationNotSupportedError} Always
|
|
2176
|
+
*/
|
|
2177
|
+
async refreshTokens(_refreshToken) {
|
|
2178
|
+
throw new ExternalAuthOperationNotSupportedError("refreshTokens", this.providerName);
|
|
2179
|
+
}
|
|
2180
|
+
/**
|
|
2181
|
+
* Validate an access token from the external auth provider.
|
|
2182
|
+
*
|
|
2183
|
+
* Uses either the custom `validateToken` function or HS256 HMAC verification
|
|
2184
|
+
* (depending on configuration) to validate the JWT. On success, maps the claims
|
|
2185
|
+
* to Kora's expected format and returns the user ID and device ID.
|
|
2186
|
+
*
|
|
2187
|
+
* The device ID for external providers is derived from the user ID with a
|
|
2188
|
+
* "external-" prefix, since external providers typically don't use Kora's
|
|
2189
|
+
* device identity system.
|
|
2190
|
+
*
|
|
2191
|
+
* @param token - The JWT access token issued by the external provider
|
|
2192
|
+
* @returns User ID and device ID if the token is valid, or null if invalid/expired
|
|
2193
|
+
*/
|
|
2194
|
+
async validateAccessToken(token) {
|
|
2195
|
+
const claims = await this.extractClaims(token);
|
|
2196
|
+
if (claims === null) {
|
|
2197
|
+
return null;
|
|
2198
|
+
}
|
|
2199
|
+
let userInfo;
|
|
2200
|
+
try {
|
|
2201
|
+
userInfo = this.mapClaims(claims);
|
|
2202
|
+
} catch {
|
|
2203
|
+
return null;
|
|
2204
|
+
}
|
|
2205
|
+
if (typeof userInfo.userId !== "string" || userInfo.userId.length === 0) {
|
|
2206
|
+
return null;
|
|
2207
|
+
}
|
|
2208
|
+
const deviceId = `external-${this.providerName}-${userInfo.userId}`;
|
|
2209
|
+
return { userId: userInfo.userId, deviceId };
|
|
2210
|
+
}
|
|
2211
|
+
/**
|
|
2212
|
+
* Not supported for external providers.
|
|
2213
|
+
*
|
|
2214
|
+
* User lookup must be performed through the external auth provider's API.
|
|
2215
|
+
*
|
|
2216
|
+
* @throws {ExternalAuthOperationNotSupportedError} Always
|
|
2217
|
+
*/
|
|
2218
|
+
async getUser(_userId) {
|
|
2219
|
+
throw new ExternalAuthOperationNotSupportedError("getUser", this.providerName);
|
|
2220
|
+
}
|
|
2221
|
+
/**
|
|
2222
|
+
* Not supported for external providers.
|
|
2223
|
+
*
|
|
2224
|
+
* Device revocation must be managed through the external auth provider
|
|
2225
|
+
* or by revoking the user's tokens at the provider level.
|
|
2226
|
+
*
|
|
2227
|
+
* @throws {ExternalAuthOperationNotSupportedError} Always
|
|
2228
|
+
*/
|
|
2229
|
+
async revokeDevice(_accessToken, _deviceId) {
|
|
2230
|
+
throw new ExternalAuthOperationNotSupportedError("revokeDevice", this.providerName);
|
|
2231
|
+
}
|
|
2232
|
+
/**
|
|
2233
|
+
* Not supported for external providers.
|
|
2234
|
+
*
|
|
2235
|
+
* Device listing must be performed through the external auth provider's API.
|
|
2236
|
+
*
|
|
2237
|
+
* @throws {ExternalAuthOperationNotSupportedError} Always
|
|
2238
|
+
*/
|
|
2239
|
+
async listDevices(_accessToken) {
|
|
2240
|
+
throw new ExternalAuthOperationNotSupportedError("listDevices", this.providerName);
|
|
2241
|
+
}
|
|
2242
|
+
/**
|
|
2243
|
+
* Creates a sync server auth provider compatible with `@korajs/server`.
|
|
2244
|
+
*
|
|
2245
|
+
* Returns an object with an `authenticate` method that validates the external
|
|
2246
|
+
* JWT and returns a Kora-compatible auth context. Use this to wire the external
|
|
2247
|
+
* auth provider into KoraSyncServer.
|
|
2248
|
+
*
|
|
2249
|
+
* @returns An object with an `authenticate` method for KoraSyncServer's `auth` config
|
|
2250
|
+
*
|
|
2251
|
+
* @example
|
|
2252
|
+
* ```typescript
|
|
2253
|
+
* const externalAuth = new ExternalJwtProvider({ ... })
|
|
2254
|
+
* const syncServer = new KoraSyncServer({
|
|
2255
|
+
* store,
|
|
2256
|
+
* auth: externalAuth.toSyncAuthProvider(),
|
|
2257
|
+
* })
|
|
2258
|
+
* ```
|
|
2259
|
+
*/
|
|
2260
|
+
toSyncAuthProvider() {
|
|
2261
|
+
return {
|
|
2262
|
+
authenticate: async (token) => {
|
|
2263
|
+
const claims = await this.extractClaims(token);
|
|
2264
|
+
if (claims === null) {
|
|
2265
|
+
return null;
|
|
2266
|
+
}
|
|
2267
|
+
let userInfo;
|
|
2268
|
+
try {
|
|
2269
|
+
userInfo = this.mapClaims(claims);
|
|
2270
|
+
} catch {
|
|
2271
|
+
return null;
|
|
2272
|
+
}
|
|
2273
|
+
if (typeof userInfo.userId !== "string" || userInfo.userId.length === 0) {
|
|
2274
|
+
return null;
|
|
2275
|
+
}
|
|
2276
|
+
return {
|
|
2277
|
+
userId: userInfo.userId,
|
|
2278
|
+
metadata: {
|
|
2279
|
+
provider: this.providerName,
|
|
2280
|
+
email: userInfo.email,
|
|
2281
|
+
name: userInfo.name,
|
|
2282
|
+
...userInfo.metadata
|
|
2283
|
+
}
|
|
2284
|
+
};
|
|
2285
|
+
}
|
|
2286
|
+
};
|
|
2287
|
+
}
|
|
2288
|
+
/**
|
|
2289
|
+
* Extract and validate claims from a JWT token.
|
|
2290
|
+
*
|
|
2291
|
+
* Uses the custom validator if configured, otherwise falls back to
|
|
2292
|
+
* HS256 HMAC verification with the configured secret.
|
|
2293
|
+
*
|
|
2294
|
+
* @param token - The raw JWT string
|
|
2295
|
+
* @returns The decoded claims object, or null if the token is invalid
|
|
2296
|
+
*/
|
|
2297
|
+
async extractClaims(token) {
|
|
2298
|
+
if (this.customValidateToken !== void 0) {
|
|
2299
|
+
try {
|
|
2300
|
+
const result = await this.customValidateToken(token);
|
|
2301
|
+
if (result === null) {
|
|
2302
|
+
return null;
|
|
2303
|
+
}
|
|
2304
|
+
return result;
|
|
2305
|
+
} catch {
|
|
2306
|
+
return null;
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
if (this.jwtSecret !== void 0) {
|
|
2310
|
+
const claims = verifyJwt(token, this.jwtSecret);
|
|
2311
|
+
if (claims === null) {
|
|
2312
|
+
return null;
|
|
2313
|
+
}
|
|
2314
|
+
if (isExpired(claims)) {
|
|
2315
|
+
return null;
|
|
2316
|
+
}
|
|
2317
|
+
return claims;
|
|
2318
|
+
}
|
|
2319
|
+
return null;
|
|
2320
|
+
}
|
|
2321
|
+
};
|
|
2322
|
+
|
|
2323
|
+
// src/provider/external/clerk-adapter.ts
|
|
2324
|
+
function defaultClerkClaimMapping(claims) {
|
|
2325
|
+
const sub = claims["sub"];
|
|
2326
|
+
if (typeof sub !== "string" || sub.length === 0) {
|
|
2327
|
+
return { userId: "" };
|
|
2328
|
+
}
|
|
2329
|
+
const firstName = typeof claims["first_name"] === "string" ? claims["first_name"] : "";
|
|
2330
|
+
const lastName = typeof claims["last_name"] === "string" ? claims["last_name"] : "";
|
|
2331
|
+
const fullName = [firstName, lastName].filter(Boolean).join(" ");
|
|
2332
|
+
const email = typeof claims["email"] === "string" ? claims["email"] : void 0;
|
|
2333
|
+
const metadata = {};
|
|
2334
|
+
if (typeof claims["org_id"] === "string") {
|
|
2335
|
+
metadata["orgId"] = claims["org_id"];
|
|
2336
|
+
}
|
|
2337
|
+
if (typeof claims["org_slug"] === "string") {
|
|
2338
|
+
metadata["orgSlug"] = claims["org_slug"];
|
|
2339
|
+
}
|
|
2340
|
+
if (typeof claims["org_role"] === "string") {
|
|
2341
|
+
metadata["orgRole"] = claims["org_role"];
|
|
2342
|
+
}
|
|
2343
|
+
return {
|
|
2344
|
+
userId: sub,
|
|
2345
|
+
email,
|
|
2346
|
+
name: fullName.length > 0 ? fullName : void 0,
|
|
2347
|
+
metadata: Object.keys(metadata).length > 0 ? metadata : void 0
|
|
2348
|
+
};
|
|
2349
|
+
}
|
|
2350
|
+
function createClerkAdapter(config) {
|
|
2351
|
+
return new ExternalJwtProvider({
|
|
2352
|
+
providerName: "clerk",
|
|
2353
|
+
validateToken: config.validateToken,
|
|
2354
|
+
mapClaims: config.mapClaims ?? defaultClerkClaimMapping
|
|
2355
|
+
});
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
// src/provider/external/supabase-adapter.ts
|
|
2359
|
+
function defaultSupabaseClaimMapping(claims) {
|
|
2360
|
+
const sub = claims["sub"];
|
|
2361
|
+
if (typeof sub !== "string" || sub.length === 0) {
|
|
2362
|
+
return { userId: "" };
|
|
2363
|
+
}
|
|
2364
|
+
const email = typeof claims["email"] === "string" ? claims["email"] : void 0;
|
|
2365
|
+
let name;
|
|
2366
|
+
const userMetadata = claims["user_metadata"];
|
|
2367
|
+
if (typeof userMetadata === "object" && userMetadata !== null && !Array.isArray(userMetadata)) {
|
|
2368
|
+
const meta = userMetadata;
|
|
2369
|
+
if (typeof meta["full_name"] === "string" && meta["full_name"].length > 0) {
|
|
2370
|
+
name = meta["full_name"];
|
|
2371
|
+
} else if (typeof meta["name"] === "string" && meta["name"].length > 0) {
|
|
2372
|
+
name = meta["name"];
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
const metadata = {};
|
|
2376
|
+
if (typeof claims["role"] === "string") {
|
|
2377
|
+
metadata["role"] = claims["role"];
|
|
2378
|
+
}
|
|
2379
|
+
if (typeof claims["aud"] === "string") {
|
|
2380
|
+
metadata["aud"] = claims["aud"];
|
|
2381
|
+
}
|
|
2382
|
+
if (typeof claims["app_metadata"] === "object" && claims["app_metadata"] !== null && !Array.isArray(claims["app_metadata"])) {
|
|
2383
|
+
metadata["appMetadata"] = claims["app_metadata"];
|
|
2384
|
+
}
|
|
2385
|
+
return {
|
|
2386
|
+
userId: sub,
|
|
2387
|
+
email,
|
|
2388
|
+
name,
|
|
2389
|
+
metadata: Object.keys(metadata).length > 0 ? metadata : void 0
|
|
2390
|
+
};
|
|
2391
|
+
}
|
|
2392
|
+
function createSupabaseAdapter(config) {
|
|
2393
|
+
return new ExternalJwtProvider({
|
|
2394
|
+
providerName: "supabase",
|
|
2395
|
+
jwtSecret: config.jwtSecret,
|
|
2396
|
+
mapClaims: config.mapClaims ?? defaultSupabaseClaimMapping
|
|
2397
|
+
});
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
// src/passkey/passkey-server.ts
|
|
2401
|
+
import { randomBytes as randomBytes3 } from "crypto";
|
|
2402
|
+
import { KoraError as KoraError6 } from "@korajs/core";
|
|
2403
|
+
var PasskeyVerificationError = class extends KoraError6 {
|
|
2404
|
+
constructor(message, context) {
|
|
2405
|
+
super(message, "PASSKEY_VERIFICATION_ERROR", context);
|
|
2406
|
+
this.name = "PasskeyVerificationError";
|
|
2407
|
+
}
|
|
2408
|
+
};
|
|
2409
|
+
function generateRegistrationOptions(params) {
|
|
2410
|
+
const challengeBytes = randomBytes3(32);
|
|
2411
|
+
const challenge = toBase64Url(challengeBytes.buffer.slice(
|
|
2412
|
+
challengeBytes.byteOffset,
|
|
2413
|
+
challengeBytes.byteOffset + challengeBytes.byteLength
|
|
2414
|
+
));
|
|
2415
|
+
return {
|
|
2416
|
+
challenge,
|
|
2417
|
+
rpId: params.rpId,
|
|
2418
|
+
rpName: params.rpName,
|
|
2419
|
+
userId: params.userId,
|
|
2420
|
+
userName: params.userName,
|
|
2421
|
+
userDisplayName: params.userDisplayName,
|
|
2422
|
+
excludeCredentialIds: params.existingCredentialIds ?? [],
|
|
2423
|
+
authenticatorSelection: {
|
|
2424
|
+
authenticatorAttachment: "platform",
|
|
2425
|
+
residentKey: "preferred",
|
|
2426
|
+
userVerification: "required"
|
|
2427
|
+
},
|
|
2428
|
+
timeout: 6e4
|
|
2429
|
+
};
|
|
2430
|
+
}
|
|
2431
|
+
async function verifyRegistrationResponse(params) {
|
|
2432
|
+
const { credential, expectedChallenge, expectedOrigin, expectedRpId } = params;
|
|
2433
|
+
const clientDataBytes = fromBase64Url(credential.clientDataJSON);
|
|
2434
|
+
const clientDataText = new TextDecoder().decode(clientDataBytes);
|
|
2435
|
+
let clientData;
|
|
2436
|
+
try {
|
|
2437
|
+
clientData = JSON.parse(clientDataText);
|
|
2438
|
+
} catch {
|
|
2439
|
+
throw new PasskeyVerificationError(
|
|
2440
|
+
"Failed to parse clientDataJSON. The response may be malformed."
|
|
2441
|
+
);
|
|
2442
|
+
}
|
|
2443
|
+
if (clientData.type !== "webauthn.create") {
|
|
2444
|
+
throw new PasskeyVerificationError(
|
|
2445
|
+
`Expected clientData.type "webauthn.create" but received "${clientData.type}".`,
|
|
2446
|
+
{ type: clientData.type }
|
|
2447
|
+
);
|
|
2448
|
+
}
|
|
2449
|
+
if (clientData.challenge !== expectedChallenge) {
|
|
2450
|
+
throw new PasskeyVerificationError(
|
|
2451
|
+
"Challenge mismatch. The response does not match the expected challenge. This may indicate a replay attack or session mismatch."
|
|
2452
|
+
);
|
|
2453
|
+
}
|
|
2454
|
+
if (clientData.origin !== expectedOrigin) {
|
|
2455
|
+
throw new PasskeyVerificationError(
|
|
2456
|
+
`Origin mismatch. Expected "${expectedOrigin}" but received "${clientData.origin}".`,
|
|
2457
|
+
{ expected: expectedOrigin, received: clientData.origin }
|
|
2458
|
+
);
|
|
2459
|
+
}
|
|
2460
|
+
const attestationBytes = fromBase64Url(credential.attestationObject);
|
|
2461
|
+
const attestationResult = decodeCbor(attestationBytes, 0);
|
|
2462
|
+
const attestationMap = attestationResult.value;
|
|
2463
|
+
const fmt = attestationMap.get("fmt");
|
|
2464
|
+
if (fmt !== "none") {
|
|
2465
|
+
throw new PasskeyVerificationError(
|
|
2466
|
+
`Unsupported attestation format "${String(fmt)}". Only "none" attestation is currently supported.`,
|
|
2467
|
+
{ format: String(fmt) }
|
|
2468
|
+
);
|
|
2469
|
+
}
|
|
2470
|
+
const authData = attestationMap.get("authData");
|
|
2471
|
+
if (!(authData instanceof Uint8Array)) {
|
|
2472
|
+
throw new PasskeyVerificationError(
|
|
2473
|
+
"Invalid attestation object: authData is missing or not a byte string."
|
|
2474
|
+
);
|
|
2475
|
+
}
|
|
2476
|
+
const rpIdHash = authData.slice(0, 32);
|
|
2477
|
+
const expectedRpIdHash = await sha256(new TextEncoder().encode(expectedRpId));
|
|
2478
|
+
if (!constantTimeEqual(rpIdHash, new Uint8Array(expectedRpIdHash))) {
|
|
2479
|
+
throw new PasskeyVerificationError(
|
|
2480
|
+
"RP ID hash mismatch. The authenticator data does not match the expected relying party."
|
|
2481
|
+
);
|
|
2482
|
+
}
|
|
2483
|
+
const flags = authData[32];
|
|
2484
|
+
if ((flags & 1) === 0) {
|
|
2485
|
+
throw new PasskeyVerificationError(
|
|
2486
|
+
"User Present flag is not set in authenticator data. The authenticator did not confirm user presence."
|
|
2487
|
+
);
|
|
2488
|
+
}
|
|
2489
|
+
if ((flags & 64) === 0) {
|
|
2490
|
+
throw new PasskeyVerificationError(
|
|
2491
|
+
"Attested Credential Data flag is not set. The authenticator did not include credential data."
|
|
2492
|
+
);
|
|
2493
|
+
}
|
|
2494
|
+
const signCount = authData[33] << 24 | authData[34] << 16 | authData[35] << 8 | authData[36];
|
|
2495
|
+
let offset = 37;
|
|
2496
|
+
offset += 16;
|
|
2497
|
+
const credentialIdLength = authData[offset] << 8 | authData[offset + 1];
|
|
2498
|
+
offset += 2;
|
|
2499
|
+
const credentialIdBytes = authData.slice(offset, offset + credentialIdLength);
|
|
2500
|
+
offset += credentialIdLength;
|
|
2501
|
+
const expectedCredentialId = toBase64Url(credentialIdBytes.buffer);
|
|
2502
|
+
if (expectedCredentialId !== credential.credentialId) {
|
|
2503
|
+
throw new PasskeyVerificationError(
|
|
2504
|
+
"Credential ID mismatch between attestation object and client response."
|
|
2505
|
+
);
|
|
2506
|
+
}
|
|
2507
|
+
const coseKeyResult = decodeCbor(authData, offset);
|
|
2508
|
+
const coseKeyBytes = authData.slice(offset, coseKeyResult.offset);
|
|
2509
|
+
const publicKeyFromAttestation = toBase64Url(coseKeyBytes.buffer);
|
|
2510
|
+
if (publicKeyFromAttestation !== credential.publicKey) {
|
|
2511
|
+
throw new PasskeyVerificationError(
|
|
2512
|
+
"Public key mismatch between attestation object and client response."
|
|
2513
|
+
);
|
|
2514
|
+
}
|
|
2515
|
+
return {
|
|
2516
|
+
verified: true,
|
|
2517
|
+
credentialId: credential.credentialId,
|
|
2518
|
+
publicKey: credential.publicKey,
|
|
2519
|
+
signCount: signCount >>> 0
|
|
2520
|
+
};
|
|
2521
|
+
}
|
|
2522
|
+
function generateAuthenticationOptions(params) {
|
|
2523
|
+
const challengeBytes = randomBytes3(32);
|
|
2524
|
+
const challenge = toBase64Url(challengeBytes.buffer.slice(
|
|
2525
|
+
challengeBytes.byteOffset,
|
|
2526
|
+
challengeBytes.byteOffset + challengeBytes.byteLength
|
|
2527
|
+
));
|
|
2528
|
+
return {
|
|
2529
|
+
challenge,
|
|
2530
|
+
rpId: params.rpId,
|
|
2531
|
+
allowCredentialIds: params.allowCredentialIds,
|
|
2532
|
+
userVerification: "preferred",
|
|
2533
|
+
timeout: 6e4
|
|
2534
|
+
};
|
|
2535
|
+
}
|
|
2536
|
+
async function verifyAuthenticationResponse(params) {
|
|
2537
|
+
const {
|
|
2538
|
+
assertion,
|
|
2539
|
+
expectedChallenge,
|
|
2540
|
+
expectedOrigin,
|
|
2541
|
+
expectedRpId,
|
|
2542
|
+
publicKey,
|
|
2543
|
+
previousSignCount
|
|
2544
|
+
} = params;
|
|
2545
|
+
const clientDataBytes = fromBase64Url(assertion.clientDataJSON);
|
|
2546
|
+
const clientDataText = new TextDecoder().decode(clientDataBytes);
|
|
2547
|
+
let clientData;
|
|
2548
|
+
try {
|
|
2549
|
+
clientData = JSON.parse(clientDataText);
|
|
2550
|
+
} catch {
|
|
2551
|
+
throw new PasskeyVerificationError(
|
|
2552
|
+
"Failed to parse clientDataJSON. The assertion may be malformed."
|
|
2553
|
+
);
|
|
2554
|
+
}
|
|
2555
|
+
if (clientData.type !== "webauthn.get") {
|
|
2556
|
+
throw new PasskeyVerificationError(
|
|
2557
|
+
`Expected clientData.type "webauthn.get" but received "${clientData.type}".`,
|
|
2558
|
+
{ type: clientData.type }
|
|
2559
|
+
);
|
|
2560
|
+
}
|
|
2561
|
+
if (clientData.challenge !== expectedChallenge) {
|
|
2562
|
+
throw new PasskeyVerificationError(
|
|
2563
|
+
"Challenge mismatch. The assertion does not match the expected challenge."
|
|
2564
|
+
);
|
|
2565
|
+
}
|
|
2566
|
+
if (clientData.origin !== expectedOrigin) {
|
|
2567
|
+
throw new PasskeyVerificationError(
|
|
2568
|
+
`Origin mismatch. Expected "${expectedOrigin}" but received "${clientData.origin}".`,
|
|
2569
|
+
{ expected: expectedOrigin, received: clientData.origin }
|
|
2570
|
+
);
|
|
2571
|
+
}
|
|
2572
|
+
const authDataBytes = fromBase64Url(assertion.authenticatorData);
|
|
2573
|
+
const rpIdHash = authDataBytes.slice(0, 32);
|
|
2574
|
+
const expectedRpIdHash = await sha256(new TextEncoder().encode(expectedRpId));
|
|
2575
|
+
if (!constantTimeEqual(rpIdHash, new Uint8Array(expectedRpIdHash))) {
|
|
2576
|
+
throw new PasskeyVerificationError(
|
|
2577
|
+
"RP ID hash mismatch. The authenticator data does not match the expected relying party."
|
|
2578
|
+
);
|
|
2579
|
+
}
|
|
2580
|
+
const flags = authDataBytes[32];
|
|
2581
|
+
if ((flags & 1) === 0) {
|
|
2582
|
+
throw new PasskeyVerificationError(
|
|
2583
|
+
"User Present flag is not set in authenticator data."
|
|
2584
|
+
);
|
|
2585
|
+
}
|
|
2586
|
+
const signCount = (authDataBytes[33] << 24 | authDataBytes[34] << 16 | authDataBytes[35] << 8 | authDataBytes[36]) >>> 0;
|
|
2587
|
+
if (previousSignCount > 0 || signCount > 0) {
|
|
2588
|
+
if (signCount <= previousSignCount) {
|
|
2589
|
+
throw new PasskeyVerificationError(
|
|
2590
|
+
`Signature counter did not increase. This may indicate a cloned authenticator. Previous count: ${previousSignCount}, received count: ${signCount}.`,
|
|
2591
|
+
{
|
|
2592
|
+
previousSignCount,
|
|
2593
|
+
receivedSignCount: signCount
|
|
2594
|
+
}
|
|
2595
|
+
);
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
const clientDataHash = await sha256(clientDataBytes);
|
|
2599
|
+
const signedData = new Uint8Array(
|
|
2600
|
+
authDataBytes.length + clientDataHash.byteLength
|
|
2601
|
+
);
|
|
2602
|
+
signedData.set(authDataBytes, 0);
|
|
2603
|
+
signedData.set(new Uint8Array(clientDataHash), authDataBytes.length);
|
|
2604
|
+
const coseKeyBytes = fromBase64Url(publicKey);
|
|
2605
|
+
const coseKeyResult = decodeCbor(coseKeyBytes, 0);
|
|
2606
|
+
const coseKeyMap = coseKeyResult.value;
|
|
2607
|
+
const kty = coseKeyMap.get(1);
|
|
2608
|
+
const alg = coseKeyMap.get(3);
|
|
2609
|
+
if (kty !== 2) {
|
|
2610
|
+
throw new PasskeyVerificationError(
|
|
2611
|
+
`Unsupported COSE key type ${String(kty)}. Only EC2 (kty=2) is supported.`,
|
|
2612
|
+
{ kty: String(kty) }
|
|
2613
|
+
);
|
|
2614
|
+
}
|
|
2615
|
+
if (alg !== -7) {
|
|
2616
|
+
throw new PasskeyVerificationError(
|
|
2617
|
+
`Unsupported COSE algorithm ${String(alg)}. Only ES256 (alg=-7) is supported.`,
|
|
2618
|
+
{ alg: String(alg) }
|
|
2619
|
+
);
|
|
2620
|
+
}
|
|
2621
|
+
const xCoord = coseKeyMap.get(-2);
|
|
2622
|
+
const yCoord = coseKeyMap.get(-3);
|
|
2623
|
+
if (!(xCoord instanceof Uint8Array) || !(yCoord instanceof Uint8Array) || xCoord.length !== 32 || yCoord.length !== 32) {
|
|
2624
|
+
throw new PasskeyVerificationError(
|
|
2625
|
+
"Invalid COSE public key: x and y coordinates must be 32-byte arrays."
|
|
2626
|
+
);
|
|
2627
|
+
}
|
|
2628
|
+
const rawPublicKey = new Uint8Array(65);
|
|
2629
|
+
rawPublicKey[0] = 4;
|
|
2630
|
+
rawPublicKey.set(xCoord, 1);
|
|
2631
|
+
rawPublicKey.set(yCoord, 33);
|
|
2632
|
+
let cryptoKey;
|
|
2633
|
+
try {
|
|
2634
|
+
cryptoKey = await globalThis.crypto.subtle.importKey(
|
|
2635
|
+
"raw",
|
|
2636
|
+
rawPublicKey.buffer,
|
|
2637
|
+
{ name: "ECDSA", namedCurve: "P-256" },
|
|
2638
|
+
false,
|
|
2639
|
+
["verify"]
|
|
2640
|
+
);
|
|
2641
|
+
} catch (error) {
|
|
2642
|
+
throw new PasskeyVerificationError(
|
|
2643
|
+
"Failed to import COSE public key for signature verification.",
|
|
2644
|
+
{ cause: error instanceof Error ? error.message : String(error) }
|
|
2645
|
+
);
|
|
2646
|
+
}
|
|
2647
|
+
const signatureBytes = fromBase64Url(assertion.signature);
|
|
2648
|
+
const p1363Signature = derToP1363(signatureBytes, 32);
|
|
2649
|
+
let verified;
|
|
2650
|
+
try {
|
|
2651
|
+
verified = await globalThis.crypto.subtle.verify(
|
|
2652
|
+
{ name: "ECDSA", hash: { name: "SHA-256" } },
|
|
2653
|
+
cryptoKey,
|
|
2654
|
+
p1363Signature.buffer,
|
|
2655
|
+
signedData.buffer
|
|
2656
|
+
);
|
|
2657
|
+
} catch (error) {
|
|
2658
|
+
throw new PasskeyVerificationError(
|
|
2659
|
+
"Signature verification operation failed.",
|
|
2660
|
+
{ cause: error instanceof Error ? error.message : String(error) }
|
|
2661
|
+
);
|
|
2662
|
+
}
|
|
2663
|
+
if (!verified) {
|
|
2664
|
+
return { verified: false, newSignCount: signCount };
|
|
2665
|
+
}
|
|
2666
|
+
return { verified: true, newSignCount: signCount };
|
|
2667
|
+
}
|
|
2668
|
+
async function sha256(data) {
|
|
2669
|
+
return globalThis.crypto.subtle.digest("SHA-256", data);
|
|
2670
|
+
}
|
|
2671
|
+
function constantTimeEqual(a, b) {
|
|
2672
|
+
if (a.length !== b.length) {
|
|
2673
|
+
return false;
|
|
2674
|
+
}
|
|
2675
|
+
let result = 0;
|
|
2676
|
+
for (let i = 0; i < a.length; i++) {
|
|
2677
|
+
result |= a[i] ^ b[i];
|
|
2678
|
+
}
|
|
2679
|
+
return result === 0;
|
|
2680
|
+
}
|
|
2681
|
+
function derToP1363(derSignature, componentLength) {
|
|
2682
|
+
let offset = 0;
|
|
2683
|
+
if (derSignature[offset] !== 48) {
|
|
2684
|
+
throw new PasskeyVerificationError(
|
|
2685
|
+
"Invalid DER signature: expected SEQUENCE tag (0x30)."
|
|
2686
|
+
);
|
|
2687
|
+
}
|
|
2688
|
+
offset += 1;
|
|
2689
|
+
if (derSignature[offset] & 128) {
|
|
2690
|
+
const lengthBytes = derSignature[offset] & 127;
|
|
2691
|
+
offset += 1 + lengthBytes;
|
|
2692
|
+
} else {
|
|
2693
|
+
offset += 1;
|
|
2694
|
+
}
|
|
2695
|
+
if (derSignature[offset] !== 2) {
|
|
2696
|
+
throw new PasskeyVerificationError(
|
|
2697
|
+
"Invalid DER signature: expected INTEGER tag (0x02) for r component."
|
|
2698
|
+
);
|
|
2699
|
+
}
|
|
2700
|
+
offset += 1;
|
|
2701
|
+
const rLength = derSignature[offset];
|
|
2702
|
+
offset += 1;
|
|
2703
|
+
const rBytes = derSignature.slice(offset, offset + rLength);
|
|
2704
|
+
offset += rLength;
|
|
2705
|
+
if (derSignature[offset] !== 2) {
|
|
2706
|
+
throw new PasskeyVerificationError(
|
|
2707
|
+
"Invalid DER signature: expected INTEGER tag (0x02) for s component."
|
|
2708
|
+
);
|
|
2709
|
+
}
|
|
2710
|
+
offset += 1;
|
|
2711
|
+
const sLength = derSignature[offset];
|
|
2712
|
+
offset += 1;
|
|
2713
|
+
const sBytes = derSignature.slice(offset, offset + sLength);
|
|
2714
|
+
const result = new Uint8Array(componentLength * 2);
|
|
2715
|
+
copyComponentToP1363(rBytes, result, 0, componentLength);
|
|
2716
|
+
copyComponentToP1363(sBytes, result, componentLength, componentLength);
|
|
2717
|
+
return result;
|
|
2718
|
+
}
|
|
2719
|
+
function copyComponentToP1363(component, target, targetOffset, componentLength) {
|
|
2720
|
+
if (component.length === componentLength) {
|
|
2721
|
+
target.set(component, targetOffset);
|
|
2722
|
+
} else if (component.length > componentLength) {
|
|
2723
|
+
const excess = component.length - componentLength;
|
|
2724
|
+
target.set(component.slice(excess), targetOffset);
|
|
2725
|
+
} else {
|
|
2726
|
+
const padding = componentLength - component.length;
|
|
2727
|
+
target.set(component, targetOffset + padding);
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
// src/org/org-types.ts
|
|
2732
|
+
import { KoraError as KoraError7 } from "@korajs/core";
|
|
2733
|
+
var ORG_ROLES = ["owner", "admin", "member", "viewer", "billing"];
|
|
2734
|
+
var ROLE_HIERARCHY = {
|
|
2735
|
+
viewer: 10,
|
|
2736
|
+
billing: 15,
|
|
2737
|
+
member: 20,
|
|
2738
|
+
admin: 30,
|
|
2739
|
+
owner: 40
|
|
2740
|
+
};
|
|
2741
|
+
function hasRoleLevel(userRole, requiredRole) {
|
|
2742
|
+
return ROLE_HIERARCHY[userRole] >= ROLE_HIERARCHY[requiredRole];
|
|
2743
|
+
}
|
|
2744
|
+
var INVITATION_STATUSES = ["pending", "accepted", "revoked", "expired"];
|
|
2745
|
+
var OrgError = class extends KoraError7 {
|
|
2746
|
+
constructor(message, code, context) {
|
|
2747
|
+
super(message, code, context);
|
|
2748
|
+
this.name = "OrgError";
|
|
2749
|
+
}
|
|
2750
|
+
};
|
|
2751
|
+
var OrgNotFoundError = class extends OrgError {
|
|
2752
|
+
constructor() {
|
|
2753
|
+
super("Organization not found.", "ORG_NOT_FOUND");
|
|
2754
|
+
}
|
|
2755
|
+
};
|
|
2756
|
+
var OrgSlugTakenError = class extends OrgError {
|
|
2757
|
+
constructor() {
|
|
2758
|
+
super("An organization with this slug already exists.", "ORG_SLUG_TAKEN");
|
|
2759
|
+
}
|
|
2760
|
+
};
|
|
2761
|
+
var MembershipNotFoundError = class extends OrgError {
|
|
2762
|
+
constructor() {
|
|
2763
|
+
super("User is not a member of this organization.", "MEMBERSHIP_NOT_FOUND");
|
|
2764
|
+
}
|
|
2765
|
+
};
|
|
2766
|
+
var MemberAlreadyExistsError = class extends OrgError {
|
|
2767
|
+
constructor() {
|
|
2768
|
+
super("User is already a member of this organization.", "MEMBER_ALREADY_EXISTS");
|
|
2769
|
+
}
|
|
2770
|
+
};
|
|
2771
|
+
var InsufficientRoleError = class extends OrgError {
|
|
2772
|
+
constructor(required) {
|
|
2773
|
+
super(
|
|
2774
|
+
`This action requires at least the "${required}" role.`,
|
|
2775
|
+
"INSUFFICIENT_ROLE",
|
|
2776
|
+
{ requiredRole: required }
|
|
2777
|
+
);
|
|
2778
|
+
}
|
|
2779
|
+
};
|
|
2780
|
+
var CannotRemoveOwnerError = class extends OrgError {
|
|
2781
|
+
constructor() {
|
|
2782
|
+
super(
|
|
2783
|
+
"The organization owner cannot be removed. Transfer ownership first.",
|
|
2784
|
+
"CANNOT_REMOVE_OWNER"
|
|
2785
|
+
);
|
|
2786
|
+
}
|
|
2787
|
+
};
|
|
2788
|
+
var InvitationNotFoundError = class extends OrgError {
|
|
2789
|
+
constructor() {
|
|
2790
|
+
super("Invitation not found or has already been used.", "INVITATION_NOT_FOUND");
|
|
2791
|
+
}
|
|
2792
|
+
};
|
|
2793
|
+
var InvitationExpiredError = class extends OrgError {
|
|
2794
|
+
constructor() {
|
|
2795
|
+
super("This invitation has expired.", "INVITATION_EXPIRED");
|
|
2796
|
+
}
|
|
2797
|
+
};
|
|
2798
|
+
|
|
2799
|
+
// src/org/org-routes.ts
|
|
2800
|
+
var MAX_ORG_NAME_LENGTH = 200;
|
|
2801
|
+
var MAX_SLUG_LENGTH = 100;
|
|
2802
|
+
var SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,98}[a-z0-9]$/;
|
|
2803
|
+
function isValidEmail2(email) {
|
|
2804
|
+
if (email.length === 0 || email.length > 254) return false;
|
|
2805
|
+
const atIndex = email.indexOf("@");
|
|
2806
|
+
if (atIndex < 1) return false;
|
|
2807
|
+
const domain = email.slice(atIndex + 1);
|
|
2808
|
+
if (domain.length === 0 || !domain.includes(".")) return false;
|
|
2809
|
+
if (email.indexOf("@", atIndex + 1) !== -1) return false;
|
|
2810
|
+
if (email.includes(" ")) return false;
|
|
2811
|
+
return true;
|
|
2812
|
+
}
|
|
2813
|
+
function sanitize(value) {
|
|
2814
|
+
return value.replace(/[\x00-\x1f\x7f]/g, "").trim();
|
|
2815
|
+
}
|
|
2816
|
+
var OrgRoutes = class {
|
|
2817
|
+
store;
|
|
2818
|
+
constructor(config) {
|
|
2819
|
+
this.store = config.orgStore;
|
|
2820
|
+
}
|
|
2821
|
+
// --- Organizations ---
|
|
2822
|
+
/**
|
|
2823
|
+
* Create a new organization. The authenticated user becomes the owner.
|
|
2824
|
+
*/
|
|
2825
|
+
async createOrg(userId, params) {
|
|
2826
|
+
if (typeof params.name !== "string" || params.name.trim().length === 0) {
|
|
2827
|
+
return { status: 400, body: { error: "Organization name is required." } };
|
|
2828
|
+
}
|
|
2829
|
+
const name = sanitize(params.name);
|
|
2830
|
+
if (name.length > MAX_ORG_NAME_LENGTH) {
|
|
2831
|
+
return {
|
|
2832
|
+
status: 400,
|
|
2833
|
+
body: { error: `Organization name must be at most ${MAX_ORG_NAME_LENGTH} characters.` }
|
|
2834
|
+
};
|
|
2835
|
+
}
|
|
2836
|
+
let slug;
|
|
2837
|
+
if (params.slug !== void 0) {
|
|
2838
|
+
if (typeof params.slug !== "string") {
|
|
2839
|
+
return { status: 400, body: { error: "Slug must be a string." } };
|
|
2840
|
+
}
|
|
2841
|
+
slug = params.slug.toLowerCase().trim();
|
|
2842
|
+
if (slug.length < 2) {
|
|
2843
|
+
return { status: 400, body: { error: "Slug must be at least 2 characters." } };
|
|
2844
|
+
}
|
|
2845
|
+
if (slug.length > MAX_SLUG_LENGTH) {
|
|
2846
|
+
return {
|
|
2847
|
+
status: 400,
|
|
2848
|
+
body: { error: `Slug must be at most ${MAX_SLUG_LENGTH} characters.` }
|
|
2849
|
+
};
|
|
2850
|
+
}
|
|
2851
|
+
if (!SLUG_PATTERN.test(slug)) {
|
|
2852
|
+
return {
|
|
2853
|
+
status: 400,
|
|
2854
|
+
body: {
|
|
2855
|
+
error: "Slug must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen."
|
|
2856
|
+
}
|
|
2857
|
+
};
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
if (params.metadata !== void 0 && (typeof params.metadata !== "object" || params.metadata === null || Array.isArray(params.metadata))) {
|
|
2861
|
+
return { status: 400, body: { error: "Metadata must be a plain object." } };
|
|
2862
|
+
}
|
|
2863
|
+
try {
|
|
2864
|
+
const createParams = {
|
|
2865
|
+
name,
|
|
2866
|
+
slug,
|
|
2867
|
+
metadata: params.metadata
|
|
2868
|
+
};
|
|
2869
|
+
const org = await this.store.createOrg(userId, createParams);
|
|
2870
|
+
return { status: 201, body: { data: org } };
|
|
2871
|
+
} catch (err) {
|
|
2872
|
+
if (err instanceof OrgSlugTakenError) {
|
|
2873
|
+
return { status: 409, body: { error: err.message } };
|
|
2874
|
+
}
|
|
2875
|
+
throw err;
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
/**
|
|
2879
|
+
* Get an organization by ID. Requires membership.
|
|
2880
|
+
*/
|
|
2881
|
+
async getOrg(userId, orgId) {
|
|
2882
|
+
const membership = await this.store.getMembership(orgId, userId);
|
|
2883
|
+
if (!membership) {
|
|
2884
|
+
return { status: 404, body: { error: "Organization not found." } };
|
|
2885
|
+
}
|
|
2886
|
+
const org = await this.store.getOrg(orgId);
|
|
2887
|
+
if (!org) {
|
|
2888
|
+
return { status: 404, body: { error: "Organization not found." } };
|
|
2889
|
+
}
|
|
2890
|
+
return { status: 200, body: { data: org } };
|
|
2891
|
+
}
|
|
2892
|
+
/**
|
|
2893
|
+
* Update an organization. Requires admin or higher.
|
|
2894
|
+
*/
|
|
2895
|
+
async updateOrg(userId, orgId, params) {
|
|
2896
|
+
const authResult = await this.requireRole(orgId, userId, "admin");
|
|
2897
|
+
if (authResult) return authResult;
|
|
2898
|
+
const updateParams = {};
|
|
2899
|
+
if (params.name !== void 0) {
|
|
2900
|
+
if (typeof params.name !== "string" || params.name.trim().length === 0) {
|
|
2901
|
+
return { status: 400, body: { error: "Organization name must be a non-empty string." } };
|
|
2902
|
+
}
|
|
2903
|
+
updateParams.name = sanitize(params.name);
|
|
2904
|
+
if (updateParams.name.length > MAX_ORG_NAME_LENGTH) {
|
|
2905
|
+
return {
|
|
2906
|
+
status: 400,
|
|
2907
|
+
body: { error: `Organization name must be at most ${MAX_ORG_NAME_LENGTH} characters.` }
|
|
2908
|
+
};
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
if (params.slug !== void 0) {
|
|
2912
|
+
if (typeof params.slug !== "string") {
|
|
2913
|
+
return { status: 400, body: { error: "Slug must be a string." } };
|
|
2914
|
+
}
|
|
2915
|
+
updateParams.slug = params.slug.toLowerCase().trim();
|
|
2916
|
+
if (updateParams.slug.length < 2) {
|
|
2917
|
+
return { status: 400, body: { error: "Slug must be at least 2 characters." } };
|
|
2918
|
+
}
|
|
2919
|
+
if (updateParams.slug.length > MAX_SLUG_LENGTH) {
|
|
2920
|
+
return {
|
|
2921
|
+
status: 400,
|
|
2922
|
+
body: { error: `Slug must be at most ${MAX_SLUG_LENGTH} characters.` }
|
|
2923
|
+
};
|
|
2924
|
+
}
|
|
2925
|
+
if (!SLUG_PATTERN.test(updateParams.slug)) {
|
|
2926
|
+
return {
|
|
2927
|
+
status: 400,
|
|
2928
|
+
body: {
|
|
2929
|
+
error: "Slug must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen."
|
|
2930
|
+
}
|
|
2931
|
+
};
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
if (params.metadata !== void 0) {
|
|
2935
|
+
if (typeof params.metadata !== "object" || params.metadata === null || Array.isArray(params.metadata)) {
|
|
2936
|
+
return { status: 400, body: { error: "Metadata must be a plain object." } };
|
|
2937
|
+
}
|
|
2938
|
+
updateParams.metadata = params.metadata;
|
|
2939
|
+
}
|
|
2940
|
+
try {
|
|
2941
|
+
const org = await this.store.updateOrg(orgId, updateParams);
|
|
2942
|
+
return { status: 200, body: { data: org } };
|
|
2943
|
+
} catch (err) {
|
|
2944
|
+
if (err instanceof OrgNotFoundError) {
|
|
2945
|
+
return { status: 404, body: { error: err.message } };
|
|
2946
|
+
}
|
|
2947
|
+
if (err instanceof OrgSlugTakenError) {
|
|
2948
|
+
return { status: 409, body: { error: err.message } };
|
|
2949
|
+
}
|
|
2950
|
+
throw err;
|
|
2951
|
+
}
|
|
2952
|
+
}
|
|
2953
|
+
/**
|
|
2954
|
+
* Delete an organization. Requires owner.
|
|
2955
|
+
*/
|
|
2956
|
+
async deleteOrg(userId, orgId) {
|
|
2957
|
+
const authResult = await this.requireRole(orgId, userId, "owner");
|
|
2958
|
+
if (authResult) return authResult;
|
|
2959
|
+
try {
|
|
2960
|
+
await this.store.deleteOrg(orgId);
|
|
2961
|
+
return { status: 200, body: { data: { deleted: true } } };
|
|
2962
|
+
} catch (err) {
|
|
2963
|
+
if (err instanceof OrgNotFoundError) {
|
|
2964
|
+
return { status: 404, body: { error: err.message } };
|
|
2965
|
+
}
|
|
2966
|
+
throw err;
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2969
|
+
/**
|
|
2970
|
+
* List all organizations the authenticated user belongs to.
|
|
2971
|
+
*/
|
|
2972
|
+
async listUserOrgs(userId) {
|
|
2973
|
+
const orgs = await this.store.listUserOrgs(userId);
|
|
2974
|
+
return { status: 200, body: { data: orgs } };
|
|
2975
|
+
}
|
|
2976
|
+
// --- Members ---
|
|
2977
|
+
/**
|
|
2978
|
+
* Add a member to an organization. Requires admin or higher.
|
|
2979
|
+
*/
|
|
2980
|
+
async addMember(userId, orgId, params) {
|
|
2981
|
+
const authResult = await this.requireRole(orgId, userId, "admin");
|
|
2982
|
+
if (authResult) return authResult;
|
|
2983
|
+
if (typeof params.targetUserId !== "string" || params.targetUserId.length === 0) {
|
|
2984
|
+
return { status: 400, body: { error: "Target user ID is required." } };
|
|
2985
|
+
}
|
|
2986
|
+
if (typeof params.role !== "string" || !isValidRole(params.role)) {
|
|
2987
|
+
return { status: 400, body: { error: "A valid role is required (admin, member, viewer, billing)." } };
|
|
2988
|
+
}
|
|
2989
|
+
if (params.role === "owner") {
|
|
2990
|
+
return { status: 400, body: { error: "Cannot assign owner role directly. Use ownership transfer." } };
|
|
2991
|
+
}
|
|
2992
|
+
const callerMembership = await this.store.getMembership(orgId, userId);
|
|
2993
|
+
if (callerMembership && callerMembership.role !== "owner" && params.role === "admin") {
|
|
2994
|
+
return { status: 403, body: { error: "Only the owner can add admin members." } };
|
|
2995
|
+
}
|
|
2996
|
+
try {
|
|
2997
|
+
const membership = await this.store.addMember(orgId, params.targetUserId, params.role, userId);
|
|
2998
|
+
return { status: 201, body: { data: membership } };
|
|
2999
|
+
} catch (err) {
|
|
3000
|
+
if (err instanceof OrgNotFoundError) {
|
|
3001
|
+
return { status: 404, body: { error: err.message } };
|
|
3002
|
+
}
|
|
3003
|
+
if (err instanceof MemberAlreadyExistsError) {
|
|
3004
|
+
return { status: 409, body: { error: err.message } };
|
|
3005
|
+
}
|
|
3006
|
+
throw err;
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
/**
|
|
3010
|
+
* Remove a member from an organization. Requires admin or higher.
|
|
3011
|
+
* Members can also remove themselves (leave).
|
|
3012
|
+
*/
|
|
3013
|
+
async removeMember(userId, orgId, targetUserId) {
|
|
3014
|
+
const isSelfRemoval = userId === targetUserId;
|
|
3015
|
+
if (!isSelfRemoval) {
|
|
3016
|
+
const authResult = await this.requireRole(orgId, userId, "admin");
|
|
3017
|
+
if (authResult) return authResult;
|
|
3018
|
+
} else {
|
|
3019
|
+
const membership = await this.store.getMembership(orgId, userId);
|
|
3020
|
+
if (!membership) {
|
|
3021
|
+
return { status: 404, body: { error: "Organization not found." } };
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
3024
|
+
try {
|
|
3025
|
+
await this.store.removeMember(orgId, targetUserId);
|
|
3026
|
+
return { status: 200, body: { data: { removed: true } } };
|
|
3027
|
+
} catch (err) {
|
|
3028
|
+
if (err instanceof OrgNotFoundError) {
|
|
3029
|
+
return { status: 404, body: { error: err.message } };
|
|
3030
|
+
}
|
|
3031
|
+
if (err instanceof CannotRemoveOwnerError) {
|
|
3032
|
+
return { status: 400, body: { error: err.message } };
|
|
3033
|
+
}
|
|
3034
|
+
if (err instanceof MembershipNotFoundError) {
|
|
3035
|
+
return { status: 404, body: { error: err.message } };
|
|
3036
|
+
}
|
|
3037
|
+
throw err;
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
3040
|
+
/**
|
|
3041
|
+
* Update a member's role. Requires admin or higher.
|
|
3042
|
+
*/
|
|
3043
|
+
async updateMemberRole(userId, orgId, params) {
|
|
3044
|
+
const authResult = await this.requireRole(orgId, userId, "admin");
|
|
3045
|
+
if (authResult) return authResult;
|
|
3046
|
+
if (typeof params.targetUserId !== "string" || params.targetUserId.length === 0) {
|
|
3047
|
+
return { status: 400, body: { error: "Target user ID is required." } };
|
|
3048
|
+
}
|
|
3049
|
+
if (typeof params.role !== "string" || !isValidRole(params.role)) {
|
|
3050
|
+
return { status: 400, body: { error: "A valid role is required (admin, member, viewer, billing)." } };
|
|
3051
|
+
}
|
|
3052
|
+
if (params.role === "owner") {
|
|
3053
|
+
return { status: 400, body: { error: "Cannot assign owner role directly. Use ownership transfer." } };
|
|
3054
|
+
}
|
|
3055
|
+
const callerMembership = await this.store.getMembership(orgId, userId);
|
|
3056
|
+
if (callerMembership && callerMembership.role !== "owner" && params.role === "admin") {
|
|
3057
|
+
return { status: 403, body: { error: "Only the owner can assign admin role." } };
|
|
3058
|
+
}
|
|
3059
|
+
try {
|
|
3060
|
+
const membership = await this.store.updateMemberRole(orgId, params.targetUserId, params.role);
|
|
3061
|
+
return { status: 200, body: { data: membership } };
|
|
3062
|
+
} catch (err) {
|
|
3063
|
+
if (err instanceof MembershipNotFoundError) {
|
|
3064
|
+
return { status: 404, body: { error: err.message } };
|
|
3065
|
+
}
|
|
3066
|
+
throw err;
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
3069
|
+
/**
|
|
3070
|
+
* List all members of an organization. Requires membership.
|
|
3071
|
+
*/
|
|
3072
|
+
async listMembers(userId, orgId) {
|
|
3073
|
+
const membership = await this.store.getMembership(orgId, userId);
|
|
3074
|
+
if (!membership) {
|
|
3075
|
+
return { status: 404, body: { error: "Organization not found." } };
|
|
3076
|
+
}
|
|
3077
|
+
try {
|
|
3078
|
+
const members = await this.store.listMembers(orgId);
|
|
3079
|
+
return { status: 200, body: { data: members } };
|
|
3080
|
+
} catch (err) {
|
|
3081
|
+
if (err instanceof OrgNotFoundError) {
|
|
3082
|
+
return { status: 404, body: { error: err.message } };
|
|
3083
|
+
}
|
|
3084
|
+
throw err;
|
|
3085
|
+
}
|
|
3086
|
+
}
|
|
3087
|
+
/**
|
|
3088
|
+
* Transfer ownership to another member. Requires owner.
|
|
3089
|
+
*/
|
|
3090
|
+
async transferOwnership(userId, orgId, params) {
|
|
3091
|
+
const authResult = await this.requireRole(orgId, userId, "owner");
|
|
3092
|
+
if (authResult) return authResult;
|
|
3093
|
+
if (typeof params.newOwnerId !== "string" || params.newOwnerId.length === 0) {
|
|
3094
|
+
return { status: 400, body: { error: "New owner ID is required." } };
|
|
3095
|
+
}
|
|
3096
|
+
if (params.newOwnerId === userId) {
|
|
3097
|
+
return { status: 400, body: { error: "You are already the owner." } };
|
|
3098
|
+
}
|
|
3099
|
+
try {
|
|
3100
|
+
await this.store.transferOwnership(orgId, params.newOwnerId);
|
|
3101
|
+
return { status: 200, body: { data: { transferred: true } } };
|
|
3102
|
+
} catch (err) {
|
|
3103
|
+
if (err instanceof OrgNotFoundError) {
|
|
3104
|
+
return { status: 404, body: { error: err.message } };
|
|
3105
|
+
}
|
|
3106
|
+
if (err instanceof MembershipNotFoundError) {
|
|
3107
|
+
return { status: 404, body: { error: "Target user is not a member of this organization." } };
|
|
3108
|
+
}
|
|
3109
|
+
throw err;
|
|
3110
|
+
}
|
|
3111
|
+
}
|
|
3112
|
+
// --- Invitations ---
|
|
3113
|
+
/**
|
|
3114
|
+
* Create an invitation to join the organization. Requires admin or higher.
|
|
3115
|
+
*/
|
|
3116
|
+
async createInvitation(userId, orgId, params) {
|
|
3117
|
+
const authResult = await this.requireRole(orgId, userId, "admin");
|
|
3118
|
+
if (authResult) return authResult;
|
|
3119
|
+
if (typeof params.email !== "string" || !isValidEmail2(params.email.trim())) {
|
|
3120
|
+
return { status: 400, body: { error: "A valid email address is required." } };
|
|
3121
|
+
}
|
|
3122
|
+
if (typeof params.role !== "string" || !isValidRole(params.role)) {
|
|
3123
|
+
return { status: 400, body: { error: "A valid role is required (admin, member, viewer, billing)." } };
|
|
3124
|
+
}
|
|
3125
|
+
if (params.role === "owner") {
|
|
3126
|
+
return { status: 400, body: { error: "Cannot invite with owner role. Use ownership transfer." } };
|
|
3127
|
+
}
|
|
3128
|
+
const callerMembership = await this.store.getMembership(orgId, userId);
|
|
3129
|
+
if (callerMembership && callerMembership.role !== "owner" && params.role === "admin") {
|
|
3130
|
+
return { status: 403, body: { error: "Only the owner can invite admin members." } };
|
|
3131
|
+
}
|
|
3132
|
+
try {
|
|
3133
|
+
const invitation = await this.store.createInvitation(orgId, userId, {
|
|
3134
|
+
email: params.email.trim(),
|
|
3135
|
+
role: params.role
|
|
3136
|
+
});
|
|
3137
|
+
return { status: 201, body: { data: invitation } };
|
|
3138
|
+
} catch (err) {
|
|
3139
|
+
if (err instanceof OrgNotFoundError) {
|
|
3140
|
+
return { status: 404, body: { error: err.message } };
|
|
3141
|
+
}
|
|
3142
|
+
throw err;
|
|
3143
|
+
}
|
|
3144
|
+
}
|
|
3145
|
+
/**
|
|
3146
|
+
* Accept an invitation by its token. The authenticated user joins the org.
|
|
3147
|
+
*/
|
|
3148
|
+
async acceptInvitation(userId, params) {
|
|
3149
|
+
if (typeof params.token !== "string" || params.token.length === 0) {
|
|
3150
|
+
return { status: 400, body: { error: "Invitation token is required." } };
|
|
3151
|
+
}
|
|
3152
|
+
try {
|
|
3153
|
+
const invitation = await this.store.consumeInvitation(params.token);
|
|
3154
|
+
const membership = await this.store.addMember(
|
|
3155
|
+
invitation.orgId,
|
|
3156
|
+
userId,
|
|
3157
|
+
invitation.role,
|
|
3158
|
+
invitation.invitedBy
|
|
3159
|
+
);
|
|
3160
|
+
return { status: 200, body: { data: membership } };
|
|
3161
|
+
} catch (err) {
|
|
3162
|
+
if (err instanceof InvitationNotFoundError) {
|
|
3163
|
+
return { status: 404, body: { error: err.message } };
|
|
3164
|
+
}
|
|
3165
|
+
if (err instanceof InvitationExpiredError) {
|
|
3166
|
+
return { status: 410, body: { error: err.message } };
|
|
3167
|
+
}
|
|
3168
|
+
if (err instanceof MemberAlreadyExistsError) {
|
|
3169
|
+
return { status: 409, body: { error: "You are already a member of this organization." } };
|
|
3170
|
+
}
|
|
3171
|
+
throw err;
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
/**
|
|
3175
|
+
* Revoke a pending invitation. Requires admin or higher.
|
|
3176
|
+
*/
|
|
3177
|
+
async revokeInvitation(userId, orgId, invitationId) {
|
|
3178
|
+
const authResult = await this.requireRole(orgId, userId, "admin");
|
|
3179
|
+
if (authResult) return authResult;
|
|
3180
|
+
try {
|
|
3181
|
+
await this.store.revokeInvitation(invitationId);
|
|
3182
|
+
return { status: 200, body: { data: { revoked: true } } };
|
|
3183
|
+
} catch (err) {
|
|
3184
|
+
if (err instanceof InvitationNotFoundError) {
|
|
3185
|
+
return { status: 404, body: { error: err.message } };
|
|
3186
|
+
}
|
|
3187
|
+
throw err;
|
|
3188
|
+
}
|
|
3189
|
+
}
|
|
3190
|
+
/**
|
|
3191
|
+
* List pending invitations for an organization. Requires admin or higher.
|
|
3192
|
+
*/
|
|
3193
|
+
async listPendingInvitations(userId, orgId) {
|
|
3194
|
+
const authResult = await this.requireRole(orgId, userId, "admin");
|
|
3195
|
+
if (authResult) return authResult;
|
|
3196
|
+
try {
|
|
3197
|
+
const invitations = await this.store.listPendingInvitations(orgId);
|
|
3198
|
+
return { status: 200, body: { data: invitations } };
|
|
3199
|
+
} catch (err) {
|
|
3200
|
+
if (err instanceof OrgNotFoundError) {
|
|
3201
|
+
return { status: 404, body: { error: err.message } };
|
|
3202
|
+
}
|
|
3203
|
+
throw err;
|
|
3204
|
+
}
|
|
3205
|
+
}
|
|
3206
|
+
/**
|
|
3207
|
+
* List pending invitations for the authenticated user's email.
|
|
3208
|
+
*/
|
|
3209
|
+
async listMyInvitations(email) {
|
|
3210
|
+
if (!isValidEmail2(email)) {
|
|
3211
|
+
return { status: 400, body: { error: "A valid email address is required." } };
|
|
3212
|
+
}
|
|
3213
|
+
const invitations = await this.store.listInvitationsForEmail(email);
|
|
3214
|
+
return { status: 200, body: { data: invitations } };
|
|
3215
|
+
}
|
|
3216
|
+
// --- Private helpers ---
|
|
3217
|
+
/**
|
|
3218
|
+
* Check if the caller has the required role in the org.
|
|
3219
|
+
* Returns an error response if not authorized, or null if authorized.
|
|
3220
|
+
*/
|
|
3221
|
+
async requireRole(orgId, userId, requiredRole) {
|
|
3222
|
+
const membership = await this.store.getMembership(orgId, userId);
|
|
3223
|
+
if (!membership) {
|
|
3224
|
+
return { status: 404, body: { error: "Organization not found." } };
|
|
3225
|
+
}
|
|
3226
|
+
if (!hasRoleLevel(membership.role, requiredRole)) {
|
|
3227
|
+
return {
|
|
3228
|
+
status: 403,
|
|
3229
|
+
body: { error: `This action requires at least the "${requiredRole}" role.` }
|
|
3230
|
+
};
|
|
3231
|
+
}
|
|
3232
|
+
return null;
|
|
3233
|
+
}
|
|
3234
|
+
};
|
|
3235
|
+
var ASSIGNABLE_ROLES = /* @__PURE__ */ new Set(["admin", "member", "viewer", "billing"]);
|
|
3236
|
+
function isValidRole(role) {
|
|
3237
|
+
return ASSIGNABLE_ROLES.has(role) || role === "owner";
|
|
3238
|
+
}
|
|
3239
|
+
|
|
3240
|
+
// src/org/org-store.ts
|
|
3241
|
+
var InMemoryOrgStore = class {
|
|
3242
|
+
orgs = /* @__PURE__ */ new Map();
|
|
3243
|
+
memberships = /* @__PURE__ */ new Map();
|
|
3244
|
+
invitations = /* @__PURE__ */ new Map();
|
|
3245
|
+
// --- Organizations ---
|
|
3246
|
+
async createOrg(ownerId, params) {
|
|
3247
|
+
const slug = params.slug ?? slugify(params.name);
|
|
3248
|
+
for (const org2 of this.orgs.values()) {
|
|
3249
|
+
if (org2.slug === slug) {
|
|
3250
|
+
throw new OrgSlugTakenError();
|
|
3251
|
+
}
|
|
3252
|
+
}
|
|
3253
|
+
const now = Date.now();
|
|
3254
|
+
const org = {
|
|
3255
|
+
id: generateId(),
|
|
3256
|
+
name: params.name,
|
|
3257
|
+
slug,
|
|
3258
|
+
ownerId,
|
|
3259
|
+
createdAt: now,
|
|
3260
|
+
updatedAt: now,
|
|
3261
|
+
metadata: params.metadata ?? {}
|
|
3262
|
+
};
|
|
3263
|
+
this.orgs.set(org.id, org);
|
|
3264
|
+
await this.addMember(org.id, ownerId, "owner", null);
|
|
3265
|
+
return org;
|
|
3266
|
+
}
|
|
3267
|
+
async getOrg(orgId) {
|
|
3268
|
+
return this.orgs.get(orgId) ?? null;
|
|
3269
|
+
}
|
|
3270
|
+
async getOrgBySlug(slug) {
|
|
3271
|
+
for (const org of this.orgs.values()) {
|
|
3272
|
+
if (org.slug === slug) return org;
|
|
3273
|
+
}
|
|
3274
|
+
return null;
|
|
3275
|
+
}
|
|
3276
|
+
async updateOrg(orgId, params) {
|
|
3277
|
+
const org = this.orgs.get(orgId);
|
|
3278
|
+
if (!org) throw new OrgNotFoundError();
|
|
3279
|
+
if (params.slug !== void 0 && params.slug !== org.slug) {
|
|
3280
|
+
for (const existing of this.orgs.values()) {
|
|
3281
|
+
if (existing.slug === params.slug && existing.id !== orgId) {
|
|
3282
|
+
throw new OrgSlugTakenError();
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3285
|
+
}
|
|
3286
|
+
const updated = {
|
|
3287
|
+
...org,
|
|
3288
|
+
name: params.name ?? org.name,
|
|
3289
|
+
slug: params.slug ?? org.slug,
|
|
3290
|
+
updatedAt: Date.now(),
|
|
3291
|
+
metadata: params.metadata ? { ...org.metadata, ...params.metadata } : org.metadata
|
|
3292
|
+
};
|
|
3293
|
+
this.orgs.set(orgId, updated);
|
|
3294
|
+
return updated;
|
|
3295
|
+
}
|
|
3296
|
+
async deleteOrg(orgId) {
|
|
3297
|
+
if (!this.orgs.has(orgId)) throw new OrgNotFoundError();
|
|
3298
|
+
for (const [id, membership] of this.memberships) {
|
|
3299
|
+
if (membership.orgId === orgId) {
|
|
3300
|
+
this.memberships.delete(id);
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
for (const [id, invitation] of this.invitations) {
|
|
3304
|
+
if (invitation.orgId === orgId) {
|
|
3305
|
+
this.invitations.delete(id);
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
this.orgs.delete(orgId);
|
|
3309
|
+
}
|
|
3310
|
+
async listUserOrgs(userId) {
|
|
3311
|
+
const orgIds = /* @__PURE__ */ new Set();
|
|
3312
|
+
for (const membership of this.memberships.values()) {
|
|
3313
|
+
if (membership.userId === userId) {
|
|
3314
|
+
orgIds.add(membership.orgId);
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
const result = [];
|
|
3318
|
+
for (const orgId of orgIds) {
|
|
3319
|
+
const org = this.orgs.get(orgId);
|
|
3320
|
+
if (org) result.push(org);
|
|
3321
|
+
}
|
|
3322
|
+
return result;
|
|
3323
|
+
}
|
|
3324
|
+
// --- Memberships ---
|
|
3325
|
+
async addMember(orgId, userId, role, invitedBy) {
|
|
3326
|
+
if (!this.orgs.has(orgId)) throw new OrgNotFoundError();
|
|
3327
|
+
for (const membership2 of this.memberships.values()) {
|
|
3328
|
+
if (membership2.orgId === orgId && membership2.userId === userId) {
|
|
3329
|
+
throw new MemberAlreadyExistsError();
|
|
3330
|
+
}
|
|
3331
|
+
}
|
|
3332
|
+
const membership = {
|
|
3333
|
+
id: generateId(),
|
|
3334
|
+
orgId,
|
|
3335
|
+
userId,
|
|
3336
|
+
role,
|
|
3337
|
+
invitedBy,
|
|
3338
|
+
joinedAt: Date.now(),
|
|
3339
|
+
metadata: {}
|
|
3340
|
+
};
|
|
3341
|
+
this.memberships.set(membership.id, membership);
|
|
3342
|
+
return membership;
|
|
3343
|
+
}
|
|
3344
|
+
async removeMember(orgId, userId) {
|
|
3345
|
+
const org = this.orgs.get(orgId);
|
|
3346
|
+
if (!org) throw new OrgNotFoundError();
|
|
3347
|
+
if (org.ownerId === userId) {
|
|
3348
|
+
throw new CannotRemoveOwnerError();
|
|
3349
|
+
}
|
|
3350
|
+
let found = false;
|
|
3351
|
+
for (const [id, membership] of this.memberships) {
|
|
3352
|
+
if (membership.orgId === orgId && membership.userId === userId) {
|
|
3353
|
+
this.memberships.delete(id);
|
|
3354
|
+
found = true;
|
|
3355
|
+
break;
|
|
3356
|
+
}
|
|
3357
|
+
}
|
|
3358
|
+
if (!found) throw new MembershipNotFoundError();
|
|
3359
|
+
}
|
|
3360
|
+
async updateMemberRole(orgId, userId, role) {
|
|
3361
|
+
for (const [id, membership] of this.memberships) {
|
|
3362
|
+
if (membership.orgId === orgId && membership.userId === userId) {
|
|
3363
|
+
const updated = { ...membership, role };
|
|
3364
|
+
this.memberships.set(id, updated);
|
|
3365
|
+
if (role === "owner") {
|
|
3366
|
+
const org = this.orgs.get(orgId);
|
|
3367
|
+
if (org) {
|
|
3368
|
+
for (const [mId, m] of this.memberships) {
|
|
3369
|
+
if (m.orgId === orgId && m.userId === org.ownerId && m.userId !== userId) {
|
|
3370
|
+
this.memberships.set(mId, { ...m, role: "admin" });
|
|
3371
|
+
}
|
|
3372
|
+
}
|
|
3373
|
+
this.orgs.set(orgId, { ...org, ownerId: userId, updatedAt: Date.now() });
|
|
3374
|
+
}
|
|
3375
|
+
}
|
|
3376
|
+
return updated;
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
throw new MembershipNotFoundError();
|
|
3380
|
+
}
|
|
3381
|
+
async listMembers(orgId) {
|
|
3382
|
+
if (!this.orgs.has(orgId)) throw new OrgNotFoundError();
|
|
3383
|
+
const result = [];
|
|
3384
|
+
for (const membership of this.memberships.values()) {
|
|
3385
|
+
if (membership.orgId === orgId) {
|
|
3386
|
+
result.push(membership);
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
return result;
|
|
3390
|
+
}
|
|
3391
|
+
async getMembership(orgId, userId) {
|
|
3392
|
+
for (const membership of this.memberships.values()) {
|
|
3393
|
+
if (membership.orgId === orgId && membership.userId === userId) {
|
|
3394
|
+
return membership;
|
|
3395
|
+
}
|
|
3396
|
+
}
|
|
3397
|
+
return null;
|
|
3398
|
+
}
|
|
3399
|
+
async transferOwnership(orgId, newOwnerId) {
|
|
3400
|
+
const org = this.orgs.get(orgId);
|
|
3401
|
+
if (!org) throw new OrgNotFoundError();
|
|
3402
|
+
const membership = await this.getMembership(orgId, newOwnerId);
|
|
3403
|
+
if (!membership) throw new MembershipNotFoundError();
|
|
3404
|
+
await this.updateMemberRole(orgId, org.ownerId, "admin");
|
|
3405
|
+
await this.updateMemberRole(orgId, newOwnerId, "owner");
|
|
3406
|
+
}
|
|
3407
|
+
// --- Invitations ---
|
|
3408
|
+
async createInvitation(orgId, invitedBy, params) {
|
|
3409
|
+
if (!this.orgs.has(orgId)) throw new OrgNotFoundError();
|
|
3410
|
+
const now = Date.now();
|
|
3411
|
+
const invitation = {
|
|
3412
|
+
id: generateId(),
|
|
3413
|
+
orgId,
|
|
3414
|
+
email: params.email.toLowerCase().trim(),
|
|
3415
|
+
role: params.role,
|
|
3416
|
+
invitedBy,
|
|
3417
|
+
token: generateToken(),
|
|
3418
|
+
createdAt: now,
|
|
3419
|
+
expiresAt: now + 7 * 24 * 60 * 60 * 1e3,
|
|
3420
|
+
// 7 days
|
|
3421
|
+
status: "pending"
|
|
3422
|
+
};
|
|
3423
|
+
this.invitations.set(invitation.id, invitation);
|
|
3424
|
+
return invitation;
|
|
3425
|
+
}
|
|
3426
|
+
async getInvitationByToken(token) {
|
|
3427
|
+
for (const invitation of this.invitations.values()) {
|
|
3428
|
+
if (invitation.token === token && invitation.status === "pending") {
|
|
3429
|
+
return invitation;
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
return null;
|
|
3433
|
+
}
|
|
3434
|
+
async consumeInvitation(token) {
|
|
3435
|
+
for (const [id, invitation] of this.invitations) {
|
|
3436
|
+
if (invitation.token === token) {
|
|
3437
|
+
if (invitation.status !== "pending") {
|
|
3438
|
+
throw new InvitationNotFoundError();
|
|
3439
|
+
}
|
|
3440
|
+
if (Date.now() > invitation.expiresAt) {
|
|
3441
|
+
this.invitations.set(id, { ...invitation, status: "expired" });
|
|
3442
|
+
throw new InvitationExpiredError();
|
|
3443
|
+
}
|
|
3444
|
+
const consumed = { ...invitation, status: "accepted" };
|
|
3445
|
+
this.invitations.set(id, consumed);
|
|
3446
|
+
return consumed;
|
|
3447
|
+
}
|
|
3448
|
+
}
|
|
3449
|
+
throw new InvitationNotFoundError();
|
|
3450
|
+
}
|
|
3451
|
+
async revokeInvitation(invitationId) {
|
|
3452
|
+
const invitation = this.invitations.get(invitationId);
|
|
3453
|
+
if (!invitation || invitation.status !== "pending") {
|
|
3454
|
+
throw new InvitationNotFoundError();
|
|
3455
|
+
}
|
|
3456
|
+
this.invitations.set(invitationId, { ...invitation, status: "revoked" });
|
|
3457
|
+
}
|
|
3458
|
+
async listPendingInvitations(orgId) {
|
|
3459
|
+
if (!this.orgs.has(orgId)) throw new OrgNotFoundError();
|
|
3460
|
+
const result = [];
|
|
3461
|
+
const now = Date.now();
|
|
3462
|
+
for (const invitation of this.invitations.values()) {
|
|
3463
|
+
if (invitation.orgId === orgId && invitation.status === "pending") {
|
|
3464
|
+
if (now > invitation.expiresAt) {
|
|
3465
|
+
continue;
|
|
3466
|
+
}
|
|
3467
|
+
result.push(invitation);
|
|
3468
|
+
}
|
|
3469
|
+
}
|
|
3470
|
+
return result;
|
|
3471
|
+
}
|
|
3472
|
+
async listInvitationsForEmail(email) {
|
|
3473
|
+
const normalizedEmail = email.toLowerCase().trim();
|
|
3474
|
+
const result = [];
|
|
3475
|
+
const now = Date.now();
|
|
3476
|
+
for (const invitation of this.invitations.values()) {
|
|
3477
|
+
if (invitation.email === normalizedEmail && invitation.status === "pending" && now <= invitation.expiresAt) {
|
|
3478
|
+
result.push(invitation);
|
|
3479
|
+
}
|
|
3480
|
+
}
|
|
3481
|
+
return result;
|
|
3482
|
+
}
|
|
3483
|
+
async cleanExpiredInvitations() {
|
|
3484
|
+
let count = 0;
|
|
3485
|
+
const now = Date.now();
|
|
3486
|
+
for (const [id, invitation] of this.invitations) {
|
|
3487
|
+
if (invitation.status === "pending" && now > invitation.expiresAt) {
|
|
3488
|
+
this.invitations.set(id, { ...invitation, status: "expired" });
|
|
3489
|
+
count++;
|
|
3490
|
+
}
|
|
3491
|
+
}
|
|
3492
|
+
return count;
|
|
3493
|
+
}
|
|
3494
|
+
};
|
|
3495
|
+
function generateId() {
|
|
3496
|
+
return globalThis.crypto.randomUUID();
|
|
3497
|
+
}
|
|
3498
|
+
function generateToken() {
|
|
3499
|
+
const bytes = new Uint8Array(32);
|
|
3500
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
3501
|
+
let binary = "";
|
|
3502
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
3503
|
+
binary += String.fromCharCode(bytes[i]);
|
|
3504
|
+
}
|
|
3505
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
3506
|
+
}
|
|
3507
|
+
function slugify(name) {
|
|
3508
|
+
return name.toLowerCase().trim().replace(/[^a-z0-9\s-]/g, "").replace(/[\s]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
3509
|
+
}
|
|
3510
|
+
|
|
3511
|
+
// src/rbac/rbac-types.ts
|
|
3512
|
+
import { KoraError as KoraError8 } from "@korajs/core";
|
|
3513
|
+
function parsePermission(permission) {
|
|
3514
|
+
const colonIndex = permission.indexOf(":");
|
|
3515
|
+
if (colonIndex === -1) {
|
|
3516
|
+
throw new InvalidPermissionError(permission);
|
|
3517
|
+
}
|
|
3518
|
+
const resource = permission.slice(0, colonIndex);
|
|
3519
|
+
const action = permission.slice(colonIndex + 1);
|
|
3520
|
+
if (resource.length === 0 || action.length === 0) {
|
|
3521
|
+
throw new InvalidPermissionError(permission);
|
|
3522
|
+
}
|
|
3523
|
+
return { resource, action };
|
|
3524
|
+
}
|
|
3525
|
+
function permissionCovers(granted, required) {
|
|
3526
|
+
const g = parsePermission(granted);
|
|
3527
|
+
const r = parsePermission(required);
|
|
3528
|
+
const resourceMatch = g.resource === "*" || g.resource === r.resource;
|
|
3529
|
+
const actionMatch = g.action === "*" || g.action === r.action;
|
|
3530
|
+
return resourceMatch && actionMatch;
|
|
3531
|
+
}
|
|
3532
|
+
var BUILT_IN_ROLES = [
|
|
3533
|
+
{
|
|
3534
|
+
name: "viewer",
|
|
3535
|
+
permissions: ["*:read"]
|
|
3536
|
+
},
|
|
3537
|
+
{
|
|
3538
|
+
name: "billing",
|
|
3539
|
+
permissions: ["org:billing"]
|
|
3540
|
+
},
|
|
3541
|
+
{
|
|
3542
|
+
name: "member",
|
|
3543
|
+
permissions: ["*:write", "*:delete"],
|
|
3544
|
+
inherits: ["viewer"]
|
|
3545
|
+
},
|
|
3546
|
+
{
|
|
3547
|
+
name: "admin",
|
|
3548
|
+
permissions: ["org:manage-members", "org:manage-settings", "org:manage-invitations"],
|
|
3549
|
+
inherits: ["member"]
|
|
3550
|
+
},
|
|
3551
|
+
{
|
|
3552
|
+
name: "owner",
|
|
3553
|
+
permissions: ["*:*"]
|
|
3554
|
+
}
|
|
3555
|
+
];
|
|
3556
|
+
var RbacError = class extends KoraError8 {
|
|
3557
|
+
constructor(message, code, context) {
|
|
3558
|
+
super(message, code, context);
|
|
3559
|
+
this.name = "RbacError";
|
|
3560
|
+
}
|
|
3561
|
+
};
|
|
3562
|
+
var InvalidPermissionError = class extends RbacError {
|
|
3563
|
+
constructor(permission) {
|
|
3564
|
+
super(
|
|
3565
|
+
`Invalid permission format: "${permission}". Expected "resource:action".`,
|
|
3566
|
+
"INVALID_PERMISSION",
|
|
3567
|
+
{ permission }
|
|
3568
|
+
);
|
|
3569
|
+
}
|
|
3570
|
+
};
|
|
3571
|
+
var RoleNotFoundError = class extends RbacError {
|
|
3572
|
+
constructor(role) {
|
|
3573
|
+
super(
|
|
3574
|
+
`Role "${role}" is not defined.`,
|
|
3575
|
+
"ROLE_NOT_FOUND",
|
|
3576
|
+
{ role }
|
|
3577
|
+
);
|
|
3578
|
+
}
|
|
3579
|
+
};
|
|
3580
|
+
var CircularInheritanceError = class extends RbacError {
|
|
3581
|
+
constructor(chain) {
|
|
3582
|
+
super(
|
|
3583
|
+
`Circular role inheritance detected: ${chain.join(" \u2192 ")}.`,
|
|
3584
|
+
"CIRCULAR_INHERITANCE",
|
|
3585
|
+
{ chain }
|
|
3586
|
+
);
|
|
3587
|
+
}
|
|
3588
|
+
};
|
|
3589
|
+
|
|
3590
|
+
// src/rbac/rbac-engine.ts
|
|
3591
|
+
var RbacEngine = class {
|
|
3592
|
+
orgStore;
|
|
3593
|
+
roleMap;
|
|
3594
|
+
resolvedPermissions = /* @__PURE__ */ new Map();
|
|
3595
|
+
collectionResolvers = /* @__PURE__ */ new Map();
|
|
3596
|
+
constructor(orgStore, config) {
|
|
3597
|
+
this.orgStore = orgStore;
|
|
3598
|
+
const roles = config?.roles ?? [...BUILT_IN_ROLES];
|
|
3599
|
+
this.roleMap = /* @__PURE__ */ new Map();
|
|
3600
|
+
for (const role of roles) {
|
|
3601
|
+
this.roleMap.set(role.name, role);
|
|
3602
|
+
}
|
|
3603
|
+
this.validateRoles();
|
|
3604
|
+
}
|
|
3605
|
+
// --- Permission Checks ---
|
|
3606
|
+
/**
|
|
3607
|
+
* Check if a user has a specific permission in an organization.
|
|
3608
|
+
*
|
|
3609
|
+
* Resolves the user's role from the org membership, then evaluates
|
|
3610
|
+
* the role's permissions (including inherited ones) against the required permission.
|
|
3611
|
+
*
|
|
3612
|
+
* Returns false (not an error) if the user is not a member.
|
|
3613
|
+
*/
|
|
3614
|
+
async hasPermission(userId, orgId, permission) {
|
|
3615
|
+
const membership = await this.orgStore.getMembership(orgId, userId);
|
|
3616
|
+
if (!membership) return false;
|
|
3617
|
+
const rolePerms = this.getRolePermissions(membership.role);
|
|
3618
|
+
return rolePerms.some((granted) => permissionCovers(granted, permission));
|
|
3619
|
+
}
|
|
3620
|
+
/**
|
|
3621
|
+
* Get all effective permissions for a user in an organization.
|
|
3622
|
+
*
|
|
3623
|
+
* Returns an empty array if the user is not a member.
|
|
3624
|
+
*/
|
|
3625
|
+
async getUserPermissions(userId, orgId) {
|
|
3626
|
+
const membership = await this.orgStore.getMembership(orgId, userId);
|
|
3627
|
+
if (!membership) return [];
|
|
3628
|
+
return this.getRolePermissions(membership.role);
|
|
3629
|
+
}
|
|
3630
|
+
/**
|
|
3631
|
+
* Get all effective permissions for a role name.
|
|
3632
|
+
* Includes permissions from inherited roles.
|
|
3633
|
+
*
|
|
3634
|
+
* @throws {RoleNotFoundError} if the role is not defined
|
|
3635
|
+
*/
|
|
3636
|
+
getRolePermissions(roleName) {
|
|
3637
|
+
const cached = this.resolvedPermissions.get(roleName);
|
|
3638
|
+
if (cached) return cached;
|
|
3639
|
+
if (!this.roleMap.has(roleName)) {
|
|
3640
|
+
throw new RoleNotFoundError(roleName);
|
|
3641
|
+
}
|
|
3642
|
+
const perms = this.resolvePermissionsForRole(roleName, /* @__PURE__ */ new Set());
|
|
3643
|
+
this.resolvedPermissions.set(roleName, perms);
|
|
3644
|
+
return perms;
|
|
3645
|
+
}
|
|
3646
|
+
/**
|
|
3647
|
+
* Check if a role has a specific permission.
|
|
3648
|
+
*/
|
|
3649
|
+
roleHasPermission(roleName, permission) {
|
|
3650
|
+
const perms = this.getRolePermissions(roleName);
|
|
3651
|
+
return perms.some((granted) => permissionCovers(granted, permission));
|
|
3652
|
+
}
|
|
3653
|
+
// --- Scope Resolution ---
|
|
3654
|
+
/**
|
|
3655
|
+
* Register a custom scope resolver for a collection.
|
|
3656
|
+
*
|
|
3657
|
+
* Custom resolvers override the default scope logic for that collection.
|
|
3658
|
+
*/
|
|
3659
|
+
registerScopeResolver(collection, resolver) {
|
|
3660
|
+
this.collectionResolvers.set(collection, resolver);
|
|
3661
|
+
}
|
|
3662
|
+
/**
|
|
3663
|
+
* Resolve sync scopes for a user in an organization.
|
|
3664
|
+
*
|
|
3665
|
+
* The scopes determine what data the user can see and modify during sync.
|
|
3666
|
+
* - Owner/Admin: all org data
|
|
3667
|
+
* - Member: all org data (read + write)
|
|
3668
|
+
* - Viewer: all org data (read-only)
|
|
3669
|
+
* - Billing: no data scopes (billing-only access)
|
|
3670
|
+
*
|
|
3671
|
+
* Custom collection resolvers can override the defaults.
|
|
3672
|
+
*
|
|
3673
|
+
* @param collections - List of collection names to resolve scopes for.
|
|
3674
|
+
* If not provided, only custom-registered collections are included.
|
|
3675
|
+
*/
|
|
3676
|
+
async resolveScopes(userId, orgId, collections) {
|
|
3677
|
+
const membership = await this.orgStore.getMembership(orgId, userId);
|
|
3678
|
+
if (!membership) return null;
|
|
3679
|
+
const permissions = this.getRolePermissions(membership.role);
|
|
3680
|
+
const ctx = {
|
|
3681
|
+
userId,
|
|
3682
|
+
orgId,
|
|
3683
|
+
role: membership.role,
|
|
3684
|
+
permissions
|
|
3685
|
+
};
|
|
3686
|
+
const scopes = {};
|
|
3687
|
+
const hasAnyRead = permissions.some(
|
|
3688
|
+
(p) => permissionCovers(p, "*:read")
|
|
3689
|
+
);
|
|
3690
|
+
if (!hasAnyRead) {
|
|
3691
|
+
return scopes;
|
|
3692
|
+
}
|
|
3693
|
+
const isReadOnly = !permissions.some(
|
|
3694
|
+
(p) => permissionCovers(p, "*:write")
|
|
3695
|
+
);
|
|
3696
|
+
const collectionsToResolve = collections ?? [...this.collectionResolvers.keys()];
|
|
3697
|
+
for (const collection of collectionsToResolve) {
|
|
3698
|
+
const customResolver = this.collectionResolvers.get(collection);
|
|
3699
|
+
if (customResolver) {
|
|
3700
|
+
const customScope = customResolver(ctx);
|
|
3701
|
+
if (customScope) {
|
|
3702
|
+
scopes[collection] = customScope;
|
|
3703
|
+
}
|
|
3704
|
+
continue;
|
|
3705
|
+
}
|
|
3706
|
+
const scope = { orgId };
|
|
3707
|
+
if (isReadOnly) {
|
|
3708
|
+
scope.__readonly = true;
|
|
3709
|
+
}
|
|
3710
|
+
scopes[collection] = scope;
|
|
3711
|
+
}
|
|
3712
|
+
return scopes;
|
|
3713
|
+
}
|
|
3714
|
+
// --- Role Management ---
|
|
3715
|
+
/**
|
|
3716
|
+
* Get all defined role names.
|
|
3717
|
+
*/
|
|
3718
|
+
getRoleNames() {
|
|
3719
|
+
return [...this.roleMap.keys()];
|
|
3720
|
+
}
|
|
3721
|
+
/**
|
|
3722
|
+
* Get a role definition by name.
|
|
3723
|
+
*/
|
|
3724
|
+
getRoleDefinition(roleName) {
|
|
3725
|
+
return this.roleMap.get(roleName) ?? null;
|
|
3726
|
+
}
|
|
3727
|
+
// --- Private ---
|
|
3728
|
+
/**
|
|
3729
|
+
* Validate all role definitions for circular inheritance and unknown references.
|
|
3730
|
+
*/
|
|
3731
|
+
validateRoles() {
|
|
3732
|
+
for (const role of this.roleMap.values()) {
|
|
3733
|
+
if (role.inherits) {
|
|
3734
|
+
for (const parent of role.inherits) {
|
|
3735
|
+
if (!this.roleMap.has(parent)) {
|
|
3736
|
+
throw new RoleNotFoundError(parent);
|
|
3737
|
+
}
|
|
3738
|
+
}
|
|
3739
|
+
}
|
|
3740
|
+
}
|
|
3741
|
+
for (const role of this.roleMap.values()) {
|
|
3742
|
+
this.detectCircularInheritance(role.name, /* @__PURE__ */ new Set());
|
|
3743
|
+
}
|
|
3744
|
+
}
|
|
3745
|
+
/**
|
|
3746
|
+
* Detect circular inheritance in role hierarchy.
|
|
3747
|
+
*/
|
|
3748
|
+
detectCircularInheritance(roleName, visited) {
|
|
3749
|
+
if (visited.has(roleName)) {
|
|
3750
|
+
throw new CircularInheritanceError([...visited, roleName]);
|
|
3751
|
+
}
|
|
3752
|
+
visited.add(roleName);
|
|
3753
|
+
const role = this.roleMap.get(roleName);
|
|
3754
|
+
if (role?.inherits) {
|
|
3755
|
+
for (const parent of role.inherits) {
|
|
3756
|
+
this.detectCircularInheritance(parent, new Set(visited));
|
|
3757
|
+
}
|
|
3758
|
+
}
|
|
3759
|
+
}
|
|
3760
|
+
/**
|
|
3761
|
+
* Recursively resolve all permissions for a role, including inherited permissions.
|
|
3762
|
+
*/
|
|
3763
|
+
resolvePermissionsForRole(roleName, visited) {
|
|
3764
|
+
if (visited.has(roleName)) return [];
|
|
3765
|
+
visited.add(roleName);
|
|
3766
|
+
const role = this.roleMap.get(roleName);
|
|
3767
|
+
if (!role) return [];
|
|
3768
|
+
const perms = new Set(role.permissions);
|
|
3769
|
+
if (role.inherits) {
|
|
3770
|
+
for (const parent of role.inherits) {
|
|
3771
|
+
const parentPerms = this.resolvePermissionsForRole(parent, visited);
|
|
3772
|
+
for (const p of parentPerms) {
|
|
3773
|
+
perms.add(p);
|
|
3774
|
+
}
|
|
3775
|
+
}
|
|
3776
|
+
}
|
|
3777
|
+
return [...perms];
|
|
3778
|
+
}
|
|
3779
|
+
};
|
|
3780
|
+
function defineRoles() {
|
|
3781
|
+
return new RoleBuilder();
|
|
3782
|
+
}
|
|
3783
|
+
var RoleBuilder = class {
|
|
3784
|
+
roles = [];
|
|
3785
|
+
/**
|
|
3786
|
+
* Add a role definition.
|
|
3787
|
+
*/
|
|
3788
|
+
role(name, permissions, options) {
|
|
3789
|
+
this.roles.push({
|
|
3790
|
+
name,
|
|
3791
|
+
permissions,
|
|
3792
|
+
inherits: options?.inherits
|
|
3793
|
+
});
|
|
3794
|
+
return this;
|
|
3795
|
+
}
|
|
3796
|
+
/**
|
|
3797
|
+
* Include the built-in roles as a base.
|
|
3798
|
+
*/
|
|
3799
|
+
withBuiltInRoles() {
|
|
3800
|
+
this.roles = [...BUILT_IN_ROLES, ...this.roles];
|
|
3801
|
+
return this;
|
|
3802
|
+
}
|
|
3803
|
+
/**
|
|
3804
|
+
* Build and return the role definitions array.
|
|
3805
|
+
*/
|
|
3806
|
+
build() {
|
|
3807
|
+
return [...this.roles];
|
|
3808
|
+
}
|
|
3809
|
+
};
|
|
3810
|
+
|
|
3811
|
+
// src/rbac/scope-resolver.ts
|
|
3812
|
+
var OrgScopeResolver = class {
|
|
3813
|
+
orgStore;
|
|
3814
|
+
rbac;
|
|
3815
|
+
collectionScopes = /* @__PURE__ */ new Map();
|
|
3816
|
+
constructor(orgStore, rbac) {
|
|
3817
|
+
this.orgStore = orgStore;
|
|
3818
|
+
this.rbac = rbac;
|
|
3819
|
+
}
|
|
3820
|
+
/**
|
|
3821
|
+
* Register a custom scope resolver for a collection.
|
|
3822
|
+
* This overrides the default orgId-based filtering for that collection.
|
|
3823
|
+
*/
|
|
3824
|
+
registerCollectionScope(collection, resolver) {
|
|
3825
|
+
this.collectionScopes.set(collection, resolver);
|
|
3826
|
+
}
|
|
3827
|
+
/**
|
|
3828
|
+
* Resolve sync scopes for all specified collections.
|
|
3829
|
+
*
|
|
3830
|
+
* Returns null if the user is not a member of the organization.
|
|
3831
|
+
* Returns an empty object if the user has no data access (e.g., billing role).
|
|
3832
|
+
*/
|
|
3833
|
+
async resolve(userId, orgId, collections) {
|
|
3834
|
+
const membership = await this.orgStore.getMembership(orgId, userId);
|
|
3835
|
+
if (!membership) return null;
|
|
3836
|
+
const permissions = this.rbac.getRolePermissions(membership.role);
|
|
3837
|
+
const ctx = {
|
|
3838
|
+
userId,
|
|
3839
|
+
orgId,
|
|
3840
|
+
role: membership.role,
|
|
3841
|
+
permissions
|
|
3842
|
+
};
|
|
3843
|
+
const scopes = {};
|
|
3844
|
+
for (const collection of collections) {
|
|
3845
|
+
const scope = this.resolveCollectionScope(ctx, collection);
|
|
3846
|
+
if (scope) {
|
|
3847
|
+
scopes[collection] = scope;
|
|
3848
|
+
}
|
|
3849
|
+
}
|
|
3850
|
+
return scopes;
|
|
3851
|
+
}
|
|
3852
|
+
/**
|
|
3853
|
+
* Check if a user can write to a specific collection in an org.
|
|
3854
|
+
*/
|
|
3855
|
+
async canWrite(userId, orgId, collection) {
|
|
3856
|
+
return this.rbac.hasPermission(
|
|
3857
|
+
userId,
|
|
3858
|
+
orgId,
|
|
3859
|
+
`${collection}:write`
|
|
3860
|
+
);
|
|
3861
|
+
}
|
|
3862
|
+
/**
|
|
3863
|
+
* Check if a user can read from a specific collection in an org.
|
|
3864
|
+
*/
|
|
3865
|
+
async canRead(userId, orgId, collection) {
|
|
3866
|
+
return this.rbac.hasPermission(
|
|
3867
|
+
userId,
|
|
3868
|
+
orgId,
|
|
3869
|
+
`${collection}:read`
|
|
3870
|
+
);
|
|
3871
|
+
}
|
|
3872
|
+
// --- Private ---
|
|
3873
|
+
resolveCollectionScope(ctx, collection) {
|
|
3874
|
+
const canRead = ctx.permissions.some(
|
|
3875
|
+
(p) => permissionCovers(p, `${collection}:read`) || permissionCovers(p, "*:read")
|
|
3876
|
+
);
|
|
3877
|
+
if (!canRead) return null;
|
|
3878
|
+
const customResolver = this.collectionScopes.get(collection);
|
|
3879
|
+
if (customResolver) {
|
|
3880
|
+
return customResolver(ctx);
|
|
3881
|
+
}
|
|
3882
|
+
const scope = { orgId: ctx.orgId };
|
|
3883
|
+
const canWrite = ctx.permissions.some(
|
|
3884
|
+
(p) => permissionCovers(p, `${collection}:write`) || permissionCovers(p, "*:write")
|
|
3885
|
+
);
|
|
3886
|
+
if (!canWrite) {
|
|
3887
|
+
scope.__readonly = true;
|
|
3888
|
+
}
|
|
3889
|
+
return scope;
|
|
3890
|
+
}
|
|
3891
|
+
};
|
|
3892
|
+
|
|
3893
|
+
// src/provider/oauth/oauth-types.ts
|
|
3894
|
+
import { KoraError as KoraError9 } from "@korajs/core";
|
|
3895
|
+
var OAuthError = class extends KoraError9 {
|
|
3896
|
+
constructor(message, code, context) {
|
|
3897
|
+
super(message, code, context);
|
|
3898
|
+
this.name = "OAuthError";
|
|
3899
|
+
}
|
|
3900
|
+
};
|
|
3901
|
+
var OAuthStateMismatchError = class extends OAuthError {
|
|
3902
|
+
constructor() {
|
|
3903
|
+
super("OAuth state parameter does not match. Possible CSRF attack.", "OAUTH_STATE_MISMATCH");
|
|
3904
|
+
}
|
|
3905
|
+
};
|
|
3906
|
+
var OAuthCodeExchangeError = class extends OAuthError {
|
|
3907
|
+
constructor(details) {
|
|
3908
|
+
super(
|
|
3909
|
+
`Failed to exchange authorization code for tokens.${details ? ` ${details}` : ""}`,
|
|
3910
|
+
"OAUTH_CODE_EXCHANGE_FAILED",
|
|
3911
|
+
details ? { details } : void 0
|
|
3912
|
+
);
|
|
3913
|
+
}
|
|
3914
|
+
};
|
|
3915
|
+
var OAuthUserInfoError = class extends OAuthError {
|
|
3916
|
+
constructor(details) {
|
|
3917
|
+
super(
|
|
3918
|
+
`Failed to fetch user info from OAuth provider.${details ? ` ${details}` : ""}`,
|
|
3919
|
+
"OAUTH_USER_INFO_FAILED",
|
|
3920
|
+
details ? { details } : void 0
|
|
3921
|
+
);
|
|
3922
|
+
}
|
|
3923
|
+
};
|
|
3924
|
+
var OAuthProviderNotFoundError = class extends OAuthError {
|
|
3925
|
+
constructor(provider) {
|
|
3926
|
+
super(`OAuth provider "${provider}" is not configured.`, "OAUTH_PROVIDER_NOT_FOUND", { provider });
|
|
3927
|
+
}
|
|
3928
|
+
};
|
|
3929
|
+
|
|
3930
|
+
// src/provider/oauth/oauth-flow.ts
|
|
3931
|
+
var DEFAULT_STATE_TTL_MS = 10 * 60 * 1e3;
|
|
3932
|
+
var InMemoryOAuthStateStore = class {
|
|
3933
|
+
states = /* @__PURE__ */ new Map();
|
|
3934
|
+
async store(state) {
|
|
3935
|
+
this.states.set(state.state, state);
|
|
3936
|
+
}
|
|
3937
|
+
async consume(stateValue) {
|
|
3938
|
+
const state = this.states.get(stateValue);
|
|
3939
|
+
if (!state) return null;
|
|
3940
|
+
this.states.delete(stateValue);
|
|
3941
|
+
if (Date.now() > state.expiresAt) return null;
|
|
3942
|
+
return state;
|
|
3943
|
+
}
|
|
3944
|
+
async cleanExpired() {
|
|
3945
|
+
const now = Date.now();
|
|
3946
|
+
let count = 0;
|
|
3947
|
+
for (const [key, state] of this.states) {
|
|
3948
|
+
if (now > state.expiresAt) {
|
|
3949
|
+
this.states.delete(key);
|
|
3950
|
+
count++;
|
|
3951
|
+
}
|
|
3952
|
+
}
|
|
3953
|
+
return count;
|
|
3954
|
+
}
|
|
3955
|
+
};
|
|
3956
|
+
var OAuthManager = class {
|
|
3957
|
+
providers = /* @__PURE__ */ new Map();
|
|
3958
|
+
stateStore;
|
|
3959
|
+
stateTtlMs;
|
|
3960
|
+
fetchFn;
|
|
3961
|
+
constructor(config) {
|
|
3962
|
+
for (const provider of config.providers) {
|
|
3963
|
+
this.providers.set(provider.providerId, provider);
|
|
3964
|
+
}
|
|
3965
|
+
this.stateStore = config.stateStore ?? new InMemoryOAuthStateStore();
|
|
3966
|
+
this.stateTtlMs = config.stateTtlMs ?? DEFAULT_STATE_TTL_MS;
|
|
3967
|
+
this.fetchFn = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
3968
|
+
}
|
|
3969
|
+
/**
|
|
3970
|
+
* Generate an authorization URL for the user to visit.
|
|
3971
|
+
* Returns the URL and the state parameter for CSRF validation.
|
|
3972
|
+
*/
|
|
3973
|
+
async getAuthorizationUrl(providerId, metadata) {
|
|
3974
|
+
const provider = this.getProvider(providerId);
|
|
3975
|
+
const state = generateState();
|
|
3976
|
+
const now = Date.now();
|
|
3977
|
+
const oauthState = {
|
|
3978
|
+
state,
|
|
3979
|
+
provider: providerId,
|
|
3980
|
+
redirectUri: provider.redirectUri,
|
|
3981
|
+
createdAt: now,
|
|
3982
|
+
expiresAt: now + this.stateTtlMs,
|
|
3983
|
+
metadata
|
|
3984
|
+
};
|
|
3985
|
+
await this.stateStore.store(oauthState);
|
|
3986
|
+
const params = new URLSearchParams({
|
|
3987
|
+
client_id: provider.clientId,
|
|
3988
|
+
redirect_uri: provider.redirectUri,
|
|
3989
|
+
response_type: "code",
|
|
3990
|
+
scope: provider.scopes.join(" "),
|
|
3991
|
+
state
|
|
3992
|
+
});
|
|
3993
|
+
const url = `${provider.authorizationUrl}?${params.toString()}`;
|
|
3994
|
+
return { url, state };
|
|
3995
|
+
}
|
|
3996
|
+
/**
|
|
3997
|
+
* Handle the OAuth callback after the user authorizes.
|
|
3998
|
+
* Validates the state parameter, exchanges the code for tokens,
|
|
3999
|
+
* and fetches user info.
|
|
4000
|
+
*
|
|
4001
|
+
* @param providerId - The OAuth provider
|
|
4002
|
+
* @param code - The authorization code from the callback
|
|
4003
|
+
* @param state - The state parameter from the callback
|
|
4004
|
+
* @returns Tokens and user info from the provider
|
|
4005
|
+
*/
|
|
4006
|
+
async handleCallback(providerId, code, state) {
|
|
4007
|
+
const provider = this.getProvider(providerId);
|
|
4008
|
+
const oauthState = await this.stateStore.consume(state);
|
|
4009
|
+
if (!oauthState || oauthState.provider !== providerId) {
|
|
4010
|
+
throw new OAuthStateMismatchError();
|
|
4011
|
+
}
|
|
4012
|
+
const tokens = await this.exchangeCodeForTokens(provider, code);
|
|
4013
|
+
const userInfo = await this.fetchUserInfo(provider, tokens.accessToken);
|
|
4014
|
+
return { tokens, userInfo, stateMetadata: oauthState.metadata };
|
|
4015
|
+
}
|
|
4016
|
+
/**
|
|
4017
|
+
* Get a registered provider by ID.
|
|
4018
|
+
*/
|
|
4019
|
+
getProvider(providerId) {
|
|
4020
|
+
const provider = this.providers.get(providerId);
|
|
4021
|
+
if (!provider) {
|
|
4022
|
+
throw new OAuthProviderNotFoundError(providerId);
|
|
4023
|
+
}
|
|
4024
|
+
return provider;
|
|
4025
|
+
}
|
|
4026
|
+
/**
|
|
4027
|
+
* List all registered provider IDs.
|
|
4028
|
+
*/
|
|
4029
|
+
getProviderIds() {
|
|
4030
|
+
return [...this.providers.keys()];
|
|
4031
|
+
}
|
|
4032
|
+
// --- Private ---
|
|
4033
|
+
async exchangeCodeForTokens(provider, code) {
|
|
4034
|
+
const body = new URLSearchParams({
|
|
4035
|
+
grant_type: "authorization_code",
|
|
4036
|
+
code,
|
|
4037
|
+
redirect_uri: provider.redirectUri,
|
|
4038
|
+
client_id: provider.clientId,
|
|
4039
|
+
client_secret: provider.clientSecret
|
|
4040
|
+
});
|
|
4041
|
+
let response;
|
|
4042
|
+
try {
|
|
4043
|
+
response = await this.fetchFn(provider.tokenUrl, {
|
|
4044
|
+
method: "POST",
|
|
4045
|
+
headers: {
|
|
4046
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
4047
|
+
Accept: "application/json"
|
|
4048
|
+
},
|
|
4049
|
+
body: body.toString()
|
|
4050
|
+
});
|
|
4051
|
+
} catch (err) {
|
|
4052
|
+
throw new OAuthCodeExchangeError(
|
|
4053
|
+
err instanceof Error ? err.message : "Network error"
|
|
4054
|
+
);
|
|
4055
|
+
}
|
|
4056
|
+
if (!response.ok) {
|
|
4057
|
+
let details = `HTTP ${response.status}`;
|
|
4058
|
+
try {
|
|
4059
|
+
const errorBody = await response.text();
|
|
4060
|
+
details += `: ${errorBody}`;
|
|
4061
|
+
} catch {
|
|
4062
|
+
}
|
|
4063
|
+
throw new OAuthCodeExchangeError(details);
|
|
4064
|
+
}
|
|
4065
|
+
const data = await response.json();
|
|
4066
|
+
return {
|
|
4067
|
+
accessToken: data["access_token"],
|
|
4068
|
+
tokenType: data["token_type"] ?? "Bearer",
|
|
4069
|
+
expiresIn: data["expires_in"],
|
|
4070
|
+
refreshToken: data["refresh_token"],
|
|
4071
|
+
idToken: data["id_token"],
|
|
4072
|
+
scope: data["scope"]
|
|
4073
|
+
};
|
|
4074
|
+
}
|
|
4075
|
+
async fetchUserInfo(provider, accessToken) {
|
|
4076
|
+
let response;
|
|
4077
|
+
try {
|
|
4078
|
+
response = await this.fetchFn(provider.userInfoUrl, {
|
|
4079
|
+
headers: {
|
|
4080
|
+
Authorization: `Bearer ${accessToken}`,
|
|
4081
|
+
Accept: "application/json"
|
|
4082
|
+
}
|
|
4083
|
+
});
|
|
4084
|
+
} catch (err) {
|
|
4085
|
+
throw new OAuthUserInfoError(
|
|
4086
|
+
err instanceof Error ? err.message : "Network error"
|
|
4087
|
+
);
|
|
4088
|
+
}
|
|
4089
|
+
if (!response.ok) {
|
|
4090
|
+
throw new OAuthUserInfoError(`HTTP ${response.status}`);
|
|
4091
|
+
}
|
|
4092
|
+
const profile = await response.json();
|
|
4093
|
+
return normalizeUserInfo(provider.providerId, profile);
|
|
4094
|
+
}
|
|
4095
|
+
};
|
|
4096
|
+
function normalizeUserInfo(providerId, profile) {
|
|
4097
|
+
switch (providerId) {
|
|
4098
|
+
case "google":
|
|
4099
|
+
return {
|
|
4100
|
+
providerId: profile["sub"],
|
|
4101
|
+
provider: "google",
|
|
4102
|
+
email: profile["email"] ?? null,
|
|
4103
|
+
emailVerified: profile["email_verified"] ?? false,
|
|
4104
|
+
name: profile["name"] ?? null,
|
|
4105
|
+
avatarUrl: profile["picture"] ?? null,
|
|
4106
|
+
rawProfile: profile
|
|
4107
|
+
};
|
|
4108
|
+
case "github":
|
|
4109
|
+
return {
|
|
4110
|
+
providerId: String(profile["id"]),
|
|
4111
|
+
provider: "github",
|
|
4112
|
+
email: profile["email"] ?? null,
|
|
4113
|
+
emailVerified: false,
|
|
4114
|
+
// GitHub doesn't confirm in the profile response
|
|
4115
|
+
name: profile["name"] ?? profile["login"] ?? null,
|
|
4116
|
+
avatarUrl: profile["avatar_url"] ?? null,
|
|
4117
|
+
rawProfile: profile
|
|
4118
|
+
};
|
|
4119
|
+
case "microsoft":
|
|
4120
|
+
return {
|
|
4121
|
+
providerId: profile["id"],
|
|
4122
|
+
provider: "microsoft",
|
|
4123
|
+
email: profile["mail"] ?? profile["userPrincipalName"] ?? null,
|
|
4124
|
+
emailVerified: false,
|
|
4125
|
+
name: profile["displayName"] ?? null,
|
|
4126
|
+
avatarUrl: null,
|
|
4127
|
+
rawProfile: profile
|
|
4128
|
+
};
|
|
4129
|
+
default:
|
|
4130
|
+
return {
|
|
4131
|
+
providerId: String(profile["id"] ?? profile["sub"] ?? ""),
|
|
4132
|
+
provider: providerId,
|
|
4133
|
+
email: profile["email"] ?? null,
|
|
4134
|
+
emailVerified: profile["email_verified"] ?? false,
|
|
4135
|
+
name: profile["name"] ?? null,
|
|
4136
|
+
avatarUrl: profile["picture"] ?? profile["avatar_url"] ?? null,
|
|
4137
|
+
rawProfile: profile
|
|
4138
|
+
};
|
|
4139
|
+
}
|
|
4140
|
+
}
|
|
4141
|
+
function googleProvider(config) {
|
|
4142
|
+
return {
|
|
4143
|
+
providerId: "google",
|
|
4144
|
+
clientId: config.clientId,
|
|
4145
|
+
clientSecret: config.clientSecret,
|
|
4146
|
+
authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
4147
|
+
tokenUrl: "https://oauth2.googleapis.com/token",
|
|
4148
|
+
userInfoUrl: "https://openidconnect.googleapis.com/v1/userinfo",
|
|
4149
|
+
scopes: config.scopes ?? ["openid", "email", "profile"],
|
|
4150
|
+
redirectUri: config.redirectUri
|
|
4151
|
+
};
|
|
4152
|
+
}
|
|
4153
|
+
function githubProvider(config) {
|
|
4154
|
+
return {
|
|
4155
|
+
providerId: "github",
|
|
4156
|
+
clientId: config.clientId,
|
|
4157
|
+
clientSecret: config.clientSecret,
|
|
4158
|
+
authorizationUrl: "https://github.com/login/oauth/authorize",
|
|
4159
|
+
tokenUrl: "https://github.com/login/oauth/access_token",
|
|
4160
|
+
userInfoUrl: "https://api.github.com/user",
|
|
4161
|
+
scopes: config.scopes ?? ["read:user", "user:email"],
|
|
4162
|
+
redirectUri: config.redirectUri
|
|
4163
|
+
};
|
|
4164
|
+
}
|
|
4165
|
+
function microsoftProvider(config) {
|
|
4166
|
+
const tenant = config.tenantId ?? "common";
|
|
4167
|
+
return {
|
|
4168
|
+
providerId: "microsoft",
|
|
4169
|
+
clientId: config.clientId,
|
|
4170
|
+
clientSecret: config.clientSecret,
|
|
4171
|
+
authorizationUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`,
|
|
4172
|
+
tokenUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`,
|
|
4173
|
+
userInfoUrl: "https://graph.microsoft.com/v1.0/me",
|
|
4174
|
+
scopes: config.scopes ?? ["openid", "email", "profile", "User.Read"],
|
|
4175
|
+
redirectUri: config.redirectUri
|
|
4176
|
+
};
|
|
4177
|
+
}
|
|
4178
|
+
function generateState() {
|
|
4179
|
+
const bytes = new Uint8Array(32);
|
|
4180
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
4181
|
+
let binary = "";
|
|
4182
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
4183
|
+
binary += String.fromCharCode(bytes[i]);
|
|
4184
|
+
}
|
|
4185
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
4186
|
+
}
|
|
4187
|
+
|
|
4188
|
+
// src/session/session.ts
|
|
4189
|
+
import { KoraError as KoraError10 } from "@korajs/core";
|
|
4190
|
+
var SessionError = class extends KoraError10 {
|
|
4191
|
+
constructor(message, code, context) {
|
|
4192
|
+
super(message, code, context);
|
|
4193
|
+
this.name = "SessionError";
|
|
4194
|
+
}
|
|
4195
|
+
};
|
|
4196
|
+
var SessionNotFoundError = class extends SessionError {
|
|
4197
|
+
constructor(sessionId) {
|
|
4198
|
+
super("Session not found or expired.", "SESSION_NOT_FOUND", { sessionId });
|
|
4199
|
+
}
|
|
4200
|
+
};
|
|
4201
|
+
var SessionExpiredError = class extends SessionError {
|
|
4202
|
+
constructor(sessionId) {
|
|
4203
|
+
super("Session has expired.", "SESSION_EXPIRED", { sessionId });
|
|
4204
|
+
}
|
|
4205
|
+
};
|
|
4206
|
+
var SessionLimitExceededError = class extends SessionError {
|
|
4207
|
+
constructor(userId, limit) {
|
|
4208
|
+
super(
|
|
4209
|
+
`Maximum concurrent sessions (${limit}) reached. Revoke an existing session first.`,
|
|
4210
|
+
"SESSION_LIMIT_EXCEEDED",
|
|
4211
|
+
{ userId, limit }
|
|
4212
|
+
);
|
|
4213
|
+
}
|
|
4214
|
+
};
|
|
4215
|
+
var SessionMfaRequiredError = class extends SessionError {
|
|
4216
|
+
constructor(sessionId) {
|
|
4217
|
+
super("MFA verification is required for this session.", "SESSION_MFA_REQUIRED", { sessionId });
|
|
4218
|
+
}
|
|
4219
|
+
};
|
|
4220
|
+
var InMemorySessionStore = class {
|
|
4221
|
+
sessions = /* @__PURE__ */ new Map();
|
|
4222
|
+
async create(session) {
|
|
4223
|
+
this.sessions.set(session.id, { ...session });
|
|
4224
|
+
}
|
|
4225
|
+
async getById(sessionId) {
|
|
4226
|
+
const session = this.sessions.get(sessionId);
|
|
4227
|
+
return session ? { ...session } : null;
|
|
4228
|
+
}
|
|
4229
|
+
async update(session) {
|
|
4230
|
+
this.sessions.set(session.id, { ...session });
|
|
4231
|
+
}
|
|
4232
|
+
async delete(sessionId) {
|
|
4233
|
+
this.sessions.delete(sessionId);
|
|
4234
|
+
}
|
|
4235
|
+
async listByUserId(userId) {
|
|
4236
|
+
const results = [];
|
|
4237
|
+
for (const session of this.sessions.values()) {
|
|
4238
|
+
if (session.userId === userId) {
|
|
4239
|
+
results.push({ ...session });
|
|
4240
|
+
}
|
|
4241
|
+
}
|
|
4242
|
+
return results.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
|
|
4243
|
+
}
|
|
4244
|
+
async deleteAllForUser(userId) {
|
|
4245
|
+
let count = 0;
|
|
4246
|
+
for (const [id, session] of this.sessions) {
|
|
4247
|
+
if (session.userId === userId) {
|
|
4248
|
+
this.sessions.delete(id);
|
|
4249
|
+
count++;
|
|
4250
|
+
}
|
|
4251
|
+
}
|
|
4252
|
+
return count;
|
|
4253
|
+
}
|
|
4254
|
+
async deleteAllExcept(userId, keepSessionId) {
|
|
4255
|
+
let count = 0;
|
|
4256
|
+
for (const [id, session] of this.sessions) {
|
|
4257
|
+
if (session.userId === userId && id !== keepSessionId) {
|
|
4258
|
+
this.sessions.delete(id);
|
|
4259
|
+
count++;
|
|
4260
|
+
}
|
|
4261
|
+
}
|
|
4262
|
+
return count;
|
|
4263
|
+
}
|
|
4264
|
+
async cleanExpired() {
|
|
4265
|
+
const now = Date.now();
|
|
4266
|
+
let count = 0;
|
|
4267
|
+
for (const [id, session] of this.sessions) {
|
|
4268
|
+
if (now > session.expiresAt) {
|
|
4269
|
+
this.sessions.delete(id);
|
|
4270
|
+
count++;
|
|
4271
|
+
}
|
|
4272
|
+
}
|
|
4273
|
+
return count;
|
|
4274
|
+
}
|
|
4275
|
+
};
|
|
4276
|
+
var DEFAULT_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
4277
|
+
var DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1e3;
|
|
4278
|
+
var DEFAULT_MAX_SESSIONS = 10;
|
|
4279
|
+
var SessionManager = class {
|
|
4280
|
+
store;
|
|
4281
|
+
sessionTtlMs;
|
|
4282
|
+
idleTimeoutMs;
|
|
4283
|
+
maxSessionsPerUser;
|
|
4284
|
+
slidingWindow;
|
|
4285
|
+
constructor(config) {
|
|
4286
|
+
this.store = config.store;
|
|
4287
|
+
this.sessionTtlMs = config.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS;
|
|
4288
|
+
this.idleTimeoutMs = config.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
4289
|
+
this.maxSessionsPerUser = config.maxSessionsPerUser ?? DEFAULT_MAX_SESSIONS;
|
|
4290
|
+
this.slidingWindow = config.slidingWindow ?? true;
|
|
4291
|
+
}
|
|
4292
|
+
/**
|
|
4293
|
+
* Create a new session for a user.
|
|
4294
|
+
* Enforces the maximum concurrent sessions limit.
|
|
4295
|
+
*/
|
|
4296
|
+
async create(params) {
|
|
4297
|
+
const existing = await this.store.listByUserId(params.userId);
|
|
4298
|
+
const activeSessions = existing.filter((s) => Date.now() <= s.expiresAt);
|
|
4299
|
+
if (activeSessions.length >= this.maxSessionsPerUser) {
|
|
4300
|
+
throw new SessionLimitExceededError(params.userId, this.maxSessionsPerUser);
|
|
4301
|
+
}
|
|
4302
|
+
const now = Date.now();
|
|
4303
|
+
const session = {
|
|
4304
|
+
id: generateSessionId(),
|
|
4305
|
+
userId: params.userId,
|
|
4306
|
+
deviceId: params.deviceId ?? null,
|
|
4307
|
+
ipAddress: params.ipAddress ?? null,
|
|
4308
|
+
userAgent: params.userAgent ?? null,
|
|
4309
|
+
createdAt: now,
|
|
4310
|
+
lastActiveAt: now,
|
|
4311
|
+
expiresAt: now + this.sessionTtlMs,
|
|
4312
|
+
mfaVerified: params.mfaVerified ?? false,
|
|
4313
|
+
metadata: params.metadata
|
|
4314
|
+
};
|
|
4315
|
+
await this.store.create(session);
|
|
4316
|
+
return session;
|
|
4317
|
+
}
|
|
4318
|
+
/**
|
|
4319
|
+
* Validate a session.
|
|
4320
|
+
* Returns the session if valid, throws if not found or expired.
|
|
4321
|
+
*/
|
|
4322
|
+
async validate(sessionId) {
|
|
4323
|
+
const session = await this.store.getById(sessionId);
|
|
4324
|
+
if (!session) {
|
|
4325
|
+
throw new SessionNotFoundError(sessionId);
|
|
4326
|
+
}
|
|
4327
|
+
const now = Date.now();
|
|
4328
|
+
if (now > session.expiresAt) {
|
|
4329
|
+
await this.store.delete(sessionId);
|
|
4330
|
+
throw new SessionExpiredError(sessionId);
|
|
4331
|
+
}
|
|
4332
|
+
if (now - session.lastActiveAt > this.idleTimeoutMs) {
|
|
4333
|
+
await this.store.delete(sessionId);
|
|
4334
|
+
throw new SessionExpiredError(sessionId);
|
|
4335
|
+
}
|
|
4336
|
+
return session;
|
|
4337
|
+
}
|
|
4338
|
+
/**
|
|
4339
|
+
* Touch a session to update its last activity time.
|
|
4340
|
+
* If sliding window is enabled, also extends the absolute expiry.
|
|
4341
|
+
*/
|
|
4342
|
+
async touch(sessionId) {
|
|
4343
|
+
const session = await this.validate(sessionId);
|
|
4344
|
+
const now = Date.now();
|
|
4345
|
+
session.lastActiveAt = now;
|
|
4346
|
+
if (this.slidingWindow) {
|
|
4347
|
+
session.expiresAt = now + this.sessionTtlMs;
|
|
4348
|
+
}
|
|
4349
|
+
await this.store.update(session);
|
|
4350
|
+
return session;
|
|
4351
|
+
}
|
|
4352
|
+
/**
|
|
4353
|
+
* Mark a session as MFA-verified.
|
|
4354
|
+
*/
|
|
4355
|
+
async markMfaVerified(sessionId) {
|
|
4356
|
+
const session = await this.validate(sessionId);
|
|
4357
|
+
session.mfaVerified = true;
|
|
4358
|
+
await this.store.update(session);
|
|
4359
|
+
return session;
|
|
4360
|
+
}
|
|
4361
|
+
/**
|
|
4362
|
+
* Require MFA verification on a session.
|
|
4363
|
+
* Throws SessionMfaRequiredError if MFA is not verified.
|
|
4364
|
+
*/
|
|
4365
|
+
async requireMfa(sessionId) {
|
|
4366
|
+
const session = await this.validate(sessionId);
|
|
4367
|
+
if (!session.mfaVerified) {
|
|
4368
|
+
throw new SessionMfaRequiredError(sessionId);
|
|
4369
|
+
}
|
|
4370
|
+
return session;
|
|
4371
|
+
}
|
|
4372
|
+
/**
|
|
4373
|
+
* Revoke (delete) a session.
|
|
4374
|
+
*/
|
|
4375
|
+
async revoke(sessionId) {
|
|
4376
|
+
await this.store.delete(sessionId);
|
|
4377
|
+
}
|
|
4378
|
+
/**
|
|
4379
|
+
* Revoke all sessions for a user (sign out everywhere).
|
|
4380
|
+
* Returns the number of sessions revoked.
|
|
4381
|
+
*/
|
|
4382
|
+
async revokeAll(userId) {
|
|
4383
|
+
return this.store.deleteAllForUser(userId);
|
|
4384
|
+
}
|
|
4385
|
+
/**
|
|
4386
|
+
* Revoke all sessions for a user except the current one.
|
|
4387
|
+
* Returns the number of sessions revoked.
|
|
4388
|
+
*/
|
|
4389
|
+
async revokeOthers(userId, currentSessionId) {
|
|
4390
|
+
return this.store.deleteAllExcept(userId, currentSessionId);
|
|
4391
|
+
}
|
|
4392
|
+
/**
|
|
4393
|
+
* List all active sessions for a user.
|
|
4394
|
+
*/
|
|
4395
|
+
async listSessions(userId) {
|
|
4396
|
+
const sessions = await this.store.listByUserId(userId);
|
|
4397
|
+
const now = Date.now();
|
|
4398
|
+
return sessions.filter((s) => now <= s.expiresAt && now - s.lastActiveAt <= this.idleTimeoutMs);
|
|
4399
|
+
}
|
|
4400
|
+
/**
|
|
4401
|
+
* Clean up expired sessions.
|
|
4402
|
+
* Returns the number of sessions cleaned.
|
|
4403
|
+
*/
|
|
4404
|
+
async cleanExpired() {
|
|
4405
|
+
return this.store.cleanExpired();
|
|
4406
|
+
}
|
|
4407
|
+
};
|
|
4408
|
+
function generateSessionId() {
|
|
4409
|
+
const bytes = new Uint8Array(32);
|
|
4410
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
4411
|
+
let hex = "";
|
|
4412
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
4413
|
+
hex += bytes[i].toString(16).padStart(2, "0");
|
|
4414
|
+
}
|
|
4415
|
+
return hex;
|
|
4416
|
+
}
|
|
4417
|
+
|
|
4418
|
+
// src/mfa/totp.ts
|
|
4419
|
+
import { KoraError as KoraError11 } from "@korajs/core";
|
|
4420
|
+
var TotpError = class extends KoraError11 {
|
|
4421
|
+
constructor(message, code, context) {
|
|
4422
|
+
super(message, code, context);
|
|
4423
|
+
this.name = "TotpError";
|
|
4424
|
+
}
|
|
4425
|
+
};
|
|
4426
|
+
var TotpInvalidCodeError = class extends TotpError {
|
|
4427
|
+
constructor() {
|
|
4428
|
+
super("Invalid TOTP code.", "TOTP_INVALID_CODE");
|
|
4429
|
+
}
|
|
4430
|
+
};
|
|
4431
|
+
var TotpNotEnabledError = class extends TotpError {
|
|
4432
|
+
constructor(userId) {
|
|
4433
|
+
super("TOTP MFA is not enabled for this user.", "TOTP_NOT_ENABLED", { userId });
|
|
4434
|
+
}
|
|
4435
|
+
};
|
|
4436
|
+
var TotpAlreadyEnabledError = class extends TotpError {
|
|
4437
|
+
constructor(userId) {
|
|
4438
|
+
super("TOTP MFA is already enabled for this user.", "TOTP_ALREADY_ENABLED", { userId });
|
|
4439
|
+
}
|
|
4440
|
+
};
|
|
4441
|
+
var TotpNotVerifiedError = class extends TotpError {
|
|
4442
|
+
constructor(userId) {
|
|
4443
|
+
super(
|
|
4444
|
+
"TOTP MFA setup is pending verification. Verify with a valid code first.",
|
|
4445
|
+
"TOTP_NOT_VERIFIED",
|
|
4446
|
+
{ userId }
|
|
4447
|
+
);
|
|
4448
|
+
}
|
|
4449
|
+
};
|
|
4450
|
+
var TotpRecoveryExhaustedError = class extends TotpError {
|
|
4451
|
+
constructor() {
|
|
4452
|
+
super("All recovery codes have been used. Please regenerate.", "TOTP_RECOVERY_EXHAUSTED");
|
|
4453
|
+
}
|
|
4454
|
+
};
|
|
4455
|
+
var InMemoryTotpStore = class {
|
|
4456
|
+
secrets = /* @__PURE__ */ new Map();
|
|
4457
|
+
async save(secret) {
|
|
4458
|
+
this.secrets.set(secret.userId, secret);
|
|
4459
|
+
}
|
|
4460
|
+
async getByUserId(userId) {
|
|
4461
|
+
return this.secrets.get(userId) ?? null;
|
|
4462
|
+
}
|
|
4463
|
+
async delete(userId) {
|
|
4464
|
+
this.secrets.delete(userId);
|
|
4465
|
+
}
|
|
4466
|
+
};
|
|
4467
|
+
var DEFAULT_DIGITS = 6;
|
|
4468
|
+
var DEFAULT_PERIOD = 30;
|
|
4469
|
+
var DEFAULT_ALGORITHM = "SHA-1";
|
|
4470
|
+
var DEFAULT_WINDOW = 1;
|
|
4471
|
+
var DEFAULT_RECOVERY_CODES = 8;
|
|
4472
|
+
var RECOVERY_CODE_LENGTH = 10;
|
|
4473
|
+
var TotpManager = class {
|
|
4474
|
+
store;
|
|
4475
|
+
issuer;
|
|
4476
|
+
digits;
|
|
4477
|
+
period;
|
|
4478
|
+
algorithm;
|
|
4479
|
+
window;
|
|
4480
|
+
recoveryCodeCount;
|
|
4481
|
+
constructor(config) {
|
|
4482
|
+
this.store = config.store;
|
|
4483
|
+
this.issuer = config.issuer;
|
|
4484
|
+
this.digits = config.digits ?? DEFAULT_DIGITS;
|
|
4485
|
+
this.period = config.period ?? DEFAULT_PERIOD;
|
|
4486
|
+
this.algorithm = config.algorithm ?? DEFAULT_ALGORITHM;
|
|
4487
|
+
this.window = config.window ?? DEFAULT_WINDOW;
|
|
4488
|
+
this.recoveryCodeCount = config.recoveryCodes ?? DEFAULT_RECOVERY_CODES;
|
|
4489
|
+
}
|
|
4490
|
+
/**
|
|
4491
|
+
* Enable TOTP MFA for a user.
|
|
4492
|
+
* Returns the secret URI (for QR code) and recovery codes.
|
|
4493
|
+
* The user must verify setup with a valid code before MFA is active.
|
|
4494
|
+
*/
|
|
4495
|
+
async enable(userId, accountName) {
|
|
4496
|
+
const existing = await this.store.getByUserId(userId);
|
|
4497
|
+
if (existing?.verified) {
|
|
4498
|
+
throw new TotpAlreadyEnabledError(userId);
|
|
4499
|
+
}
|
|
4500
|
+
const secretBytes = generateSecret(20);
|
|
4501
|
+
const secret = base32Encode(secretBytes);
|
|
4502
|
+
const recoveryCodes = generateRecoveryCodes(this.recoveryCodeCount, RECOVERY_CODE_LENGTH);
|
|
4503
|
+
const hashedCodes = await Promise.all(recoveryCodes.map((c) => hashRecoveryCode(c)));
|
|
4504
|
+
const totpSecret = {
|
|
4505
|
+
userId,
|
|
4506
|
+
secret,
|
|
4507
|
+
verified: false,
|
|
4508
|
+
recoveryCodes: hashedCodes,
|
|
4509
|
+
createdAt: Date.now(),
|
|
4510
|
+
verifiedAt: null
|
|
4511
|
+
};
|
|
4512
|
+
await this.store.save(totpSecret);
|
|
4513
|
+
const uri = buildOtpauthUri({
|
|
4514
|
+
issuer: this.issuer,
|
|
4515
|
+
accountName,
|
|
4516
|
+
secret,
|
|
4517
|
+
algorithm: this.algorithm,
|
|
4518
|
+
digits: this.digits,
|
|
4519
|
+
period: this.period
|
|
4520
|
+
});
|
|
4521
|
+
return { secret, uri, recoveryCodes };
|
|
4522
|
+
}
|
|
4523
|
+
/**
|
|
4524
|
+
* Verify the TOTP setup by confirming the user can generate a valid code.
|
|
4525
|
+
* Must be called after `enable()` and before MFA is enforced.
|
|
4526
|
+
*/
|
|
4527
|
+
async verifySetup(userId, code) {
|
|
4528
|
+
const stored = await this.store.getByUserId(userId);
|
|
4529
|
+
if (!stored) {
|
|
4530
|
+
throw new TotpNotEnabledError(userId);
|
|
4531
|
+
}
|
|
4532
|
+
if (stored.verified) {
|
|
4533
|
+
return this.validateCode(stored.secret, code);
|
|
4534
|
+
}
|
|
4535
|
+
const valid = this.validateCode(stored.secret, code);
|
|
4536
|
+
if (!valid) {
|
|
4537
|
+
throw new TotpInvalidCodeError();
|
|
4538
|
+
}
|
|
4539
|
+
stored.verified = true;
|
|
4540
|
+
stored.verifiedAt = Date.now();
|
|
4541
|
+
await this.store.save(stored);
|
|
4542
|
+
return true;
|
|
4543
|
+
}
|
|
4544
|
+
/**
|
|
4545
|
+
* Verify a TOTP code during login.
|
|
4546
|
+
* Returns true if the code is valid, false otherwise.
|
|
4547
|
+
*/
|
|
4548
|
+
async verify(userId, code) {
|
|
4549
|
+
const stored = await this.store.getByUserId(userId);
|
|
4550
|
+
if (!stored) {
|
|
4551
|
+
throw new TotpNotEnabledError(userId);
|
|
4552
|
+
}
|
|
4553
|
+
if (!stored.verified) {
|
|
4554
|
+
throw new TotpNotVerifiedError(userId);
|
|
4555
|
+
}
|
|
4556
|
+
return this.validateCode(stored.secret, code);
|
|
4557
|
+
}
|
|
4558
|
+
/**
|
|
4559
|
+
* Verify a recovery code as an alternative to TOTP.
|
|
4560
|
+
* Recovery codes are single-use.
|
|
4561
|
+
*/
|
|
4562
|
+
async verifyRecoveryCode(userId, recoveryCode) {
|
|
4563
|
+
const stored = await this.store.getByUserId(userId);
|
|
4564
|
+
if (!stored) {
|
|
4565
|
+
throw new TotpNotEnabledError(userId);
|
|
4566
|
+
}
|
|
4567
|
+
if (!stored.verified) {
|
|
4568
|
+
throw new TotpNotVerifiedError(userId);
|
|
4569
|
+
}
|
|
4570
|
+
const hashed = await hashRecoveryCode(recoveryCode.trim());
|
|
4571
|
+
const index = stored.recoveryCodes.indexOf(hashed);
|
|
4572
|
+
if (index === -1) {
|
|
4573
|
+
return false;
|
|
4574
|
+
}
|
|
4575
|
+
stored.recoveryCodes.splice(index, 1);
|
|
4576
|
+
await this.store.save(stored);
|
|
4577
|
+
return true;
|
|
4578
|
+
}
|
|
4579
|
+
/**
|
|
4580
|
+
* Regenerate recovery codes. Requires a valid TOTP code for authorization.
|
|
4581
|
+
* Replaces all existing recovery codes.
|
|
4582
|
+
*/
|
|
4583
|
+
async regenerateRecoveryCodes(userId, totpCode) {
|
|
4584
|
+
const stored = await this.store.getByUserId(userId);
|
|
4585
|
+
if (!stored) {
|
|
4586
|
+
throw new TotpNotEnabledError(userId);
|
|
4587
|
+
}
|
|
4588
|
+
if (!stored.verified) {
|
|
4589
|
+
throw new TotpNotVerifiedError(userId);
|
|
4590
|
+
}
|
|
4591
|
+
const valid = this.validateCode(stored.secret, totpCode);
|
|
4592
|
+
if (!valid) {
|
|
4593
|
+
throw new TotpInvalidCodeError();
|
|
4594
|
+
}
|
|
4595
|
+
const recoveryCodes = generateRecoveryCodes(this.recoveryCodeCount, RECOVERY_CODE_LENGTH);
|
|
4596
|
+
const hashedCodes = await Promise.all(recoveryCodes.map((c) => hashRecoveryCode(c)));
|
|
4597
|
+
stored.recoveryCodes = hashedCodes;
|
|
4598
|
+
await this.store.save(stored);
|
|
4599
|
+
return recoveryCodes;
|
|
4600
|
+
}
|
|
4601
|
+
/**
|
|
4602
|
+
* Disable TOTP MFA for a user.
|
|
4603
|
+
* Requires a valid TOTP code or recovery code for authorization.
|
|
4604
|
+
*/
|
|
4605
|
+
async disable(userId, code) {
|
|
4606
|
+
const stored = await this.store.getByUserId(userId);
|
|
4607
|
+
if (!stored) {
|
|
4608
|
+
throw new TotpNotEnabledError(userId);
|
|
4609
|
+
}
|
|
4610
|
+
let authorized = false;
|
|
4611
|
+
if (stored.verified) {
|
|
4612
|
+
authorized = this.validateCode(stored.secret, code);
|
|
4613
|
+
}
|
|
4614
|
+
if (!authorized) {
|
|
4615
|
+
const hashed = await hashRecoveryCode(code.trim());
|
|
4616
|
+
authorized = stored.recoveryCodes.includes(hashed);
|
|
4617
|
+
}
|
|
4618
|
+
if (!authorized) {
|
|
4619
|
+
throw new TotpInvalidCodeError();
|
|
4620
|
+
}
|
|
4621
|
+
await this.store.delete(userId);
|
|
4622
|
+
}
|
|
4623
|
+
/**
|
|
4624
|
+
* Check if a user has TOTP MFA enabled and verified.
|
|
4625
|
+
*/
|
|
4626
|
+
async isEnabled(userId) {
|
|
4627
|
+
const stored = await this.store.getByUserId(userId);
|
|
4628
|
+
return stored !== null && stored.verified;
|
|
4629
|
+
}
|
|
4630
|
+
/**
|
|
4631
|
+
* Get the number of remaining recovery codes for a user.
|
|
4632
|
+
*/
|
|
4633
|
+
async remainingRecoveryCodes(userId) {
|
|
4634
|
+
const stored = await this.store.getByUserId(userId);
|
|
4635
|
+
if (!stored || !stored.verified) return 0;
|
|
4636
|
+
return stored.recoveryCodes.length;
|
|
4637
|
+
}
|
|
4638
|
+
// --- Private ---
|
|
4639
|
+
validateCode(base32Secret, code) {
|
|
4640
|
+
const secretBytes = base32Decode(base32Secret);
|
|
4641
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
4642
|
+
for (let offset = -this.window; offset <= this.window; offset++) {
|
|
4643
|
+
const timeCounter = Math.floor((now + offset * this.period) / this.period);
|
|
4644
|
+
const expected = generateTotpCode(secretBytes, timeCounter, this.digits, this.algorithm);
|
|
4645
|
+
if (timingSafeEqual(code, expected)) {
|
|
4646
|
+
return true;
|
|
4647
|
+
}
|
|
4648
|
+
}
|
|
4649
|
+
return false;
|
|
4650
|
+
}
|
|
4651
|
+
};
|
|
4652
|
+
function generateTotpCode(secret, counter, digits, algorithm) {
|
|
4653
|
+
const counterBytes = new Uint8Array(8);
|
|
4654
|
+
let c = counter;
|
|
4655
|
+
for (let i = 7; i >= 0; i--) {
|
|
4656
|
+
counterBytes[i] = c & 255;
|
|
4657
|
+
c = Math.floor(c / 256);
|
|
4658
|
+
}
|
|
4659
|
+
const hash = hmacSha(algorithm, secret, counterBytes);
|
|
4660
|
+
const offset = hash[hash.length - 1] & 15;
|
|
4661
|
+
const binary = (hash[offset] & 127) << 24 | (hash[offset + 1] & 255) << 16 | (hash[offset + 2] & 255) << 8 | hash[offset + 3] & 255;
|
|
4662
|
+
const otp = binary % Math.pow(10, digits);
|
|
4663
|
+
return otp.toString().padStart(digits, "0");
|
|
4664
|
+
}
|
|
4665
|
+
function hmacSha(algorithm, key, message) {
|
|
4666
|
+
const blockSize = algorithm === "SHA-512" ? 128 : 64;
|
|
4667
|
+
let keyPad = key;
|
|
4668
|
+
if (keyPad.length > blockSize) {
|
|
4669
|
+
keyPad = sha1(keyPad);
|
|
4670
|
+
}
|
|
4671
|
+
const ipad = new Uint8Array(blockSize);
|
|
4672
|
+
const opad = new Uint8Array(blockSize);
|
|
4673
|
+
for (let i = 0; i < blockSize; i++) {
|
|
4674
|
+
const k = i < keyPad.length ? keyPad[i] : 0;
|
|
4675
|
+
ipad[i] = k ^ 54;
|
|
4676
|
+
opad[i] = k ^ 92;
|
|
4677
|
+
}
|
|
4678
|
+
const innerData = new Uint8Array(blockSize + message.length);
|
|
4679
|
+
innerData.set(ipad);
|
|
4680
|
+
innerData.set(message, blockSize);
|
|
4681
|
+
const innerHash = sha1(innerData);
|
|
4682
|
+
const outerData = new Uint8Array(blockSize + innerHash.length);
|
|
4683
|
+
outerData.set(opad);
|
|
4684
|
+
outerData.set(innerHash, blockSize);
|
|
4685
|
+
return sha1(outerData);
|
|
4686
|
+
}
|
|
4687
|
+
function sha1(data) {
|
|
4688
|
+
let h0 = 1732584193;
|
|
4689
|
+
let h1 = 4023233417;
|
|
4690
|
+
let h2 = 2562383102;
|
|
4691
|
+
let h3 = 271733878;
|
|
4692
|
+
let h4 = 3285377520;
|
|
4693
|
+
const bitLength = data.length * 8;
|
|
4694
|
+
const paddedLength = Math.ceil((data.length + 9) / 64) * 64;
|
|
4695
|
+
const padded = new Uint8Array(paddedLength);
|
|
4696
|
+
padded.set(data);
|
|
4697
|
+
padded[data.length] = 128;
|
|
4698
|
+
const view = new DataView(padded.buffer, padded.byteOffset);
|
|
4699
|
+
view.setUint32(paddedLength - 4, bitLength, false);
|
|
4700
|
+
const w = new Int32Array(80);
|
|
4701
|
+
for (let offset = 0; offset < paddedLength; offset += 64) {
|
|
4702
|
+
for (let i = 0; i < 16; i++) {
|
|
4703
|
+
w[i] = view.getInt32(offset + i * 4, false);
|
|
4704
|
+
}
|
|
4705
|
+
for (let i = 16; i < 80; i++) {
|
|
4706
|
+
w[i] = rotl32(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16] | 0, 1);
|
|
4707
|
+
}
|
|
4708
|
+
let a = h0;
|
|
4709
|
+
let b = h1;
|
|
4710
|
+
let c = h2;
|
|
4711
|
+
let d = h3;
|
|
4712
|
+
let e = h4;
|
|
4713
|
+
for (let i = 0; i < 80; i++) {
|
|
4714
|
+
let f;
|
|
4715
|
+
let k;
|
|
4716
|
+
if (i < 20) {
|
|
4717
|
+
f = b & c | ~b & d;
|
|
4718
|
+
k = 1518500249;
|
|
4719
|
+
} else if (i < 40) {
|
|
4720
|
+
f = b ^ c ^ d;
|
|
4721
|
+
k = 1859775393;
|
|
4722
|
+
} else if (i < 60) {
|
|
4723
|
+
f = b & c | b & d | c & d;
|
|
4724
|
+
k = 2400959708;
|
|
4725
|
+
} else {
|
|
4726
|
+
f = b ^ c ^ d;
|
|
4727
|
+
k = 3395469782;
|
|
4728
|
+
}
|
|
4729
|
+
const temp = rotl32(a, 5) + f + e + k + w[i] | 0;
|
|
4730
|
+
e = d;
|
|
4731
|
+
d = c;
|
|
4732
|
+
c = rotl32(b, 30);
|
|
4733
|
+
b = a;
|
|
4734
|
+
a = temp;
|
|
4735
|
+
}
|
|
4736
|
+
h0 = h0 + a | 0;
|
|
4737
|
+
h1 = h1 + b | 0;
|
|
4738
|
+
h2 = h2 + c | 0;
|
|
4739
|
+
h3 = h3 + d | 0;
|
|
4740
|
+
h4 = h4 + e | 0;
|
|
4741
|
+
}
|
|
4742
|
+
const result = new Uint8Array(20);
|
|
4743
|
+
const rv = new DataView(result.buffer);
|
|
4744
|
+
rv.setInt32(0, h0, false);
|
|
4745
|
+
rv.setInt32(4, h1, false);
|
|
4746
|
+
rv.setInt32(8, h2, false);
|
|
4747
|
+
rv.setInt32(12, h3, false);
|
|
4748
|
+
rv.setInt32(16, h4, false);
|
|
4749
|
+
return result;
|
|
4750
|
+
}
|
|
4751
|
+
function rotl32(value, shift) {
|
|
4752
|
+
return value << shift | value >>> 32 - shift | 0;
|
|
4753
|
+
}
|
|
4754
|
+
var BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
4755
|
+
function base32Encode(data) {
|
|
4756
|
+
let result = "";
|
|
4757
|
+
let bits = 0;
|
|
4758
|
+
let buffer = 0;
|
|
4759
|
+
for (let i = 0; i < data.length; i++) {
|
|
4760
|
+
buffer = buffer << 8 | data[i];
|
|
4761
|
+
bits += 8;
|
|
4762
|
+
while (bits >= 5) {
|
|
4763
|
+
bits -= 5;
|
|
4764
|
+
result += BASE32_ALPHABET[buffer >>> bits & 31];
|
|
4765
|
+
}
|
|
4766
|
+
}
|
|
4767
|
+
if (bits > 0) {
|
|
4768
|
+
result += BASE32_ALPHABET[buffer << 5 - bits & 31];
|
|
4769
|
+
}
|
|
4770
|
+
return result;
|
|
4771
|
+
}
|
|
4772
|
+
function base32Decode(encoded) {
|
|
4773
|
+
const cleaned = encoded.replace(/=+$/, "").toUpperCase();
|
|
4774
|
+
const output = [];
|
|
4775
|
+
let bits = 0;
|
|
4776
|
+
let buffer = 0;
|
|
4777
|
+
for (let i = 0; i < cleaned.length; i++) {
|
|
4778
|
+
const char = cleaned[i];
|
|
4779
|
+
const value = BASE32_ALPHABET.indexOf(char);
|
|
4780
|
+
if (value === -1) continue;
|
|
4781
|
+
buffer = buffer << 5 | value;
|
|
4782
|
+
bits += 5;
|
|
4783
|
+
if (bits >= 8) {
|
|
4784
|
+
bits -= 8;
|
|
4785
|
+
output.push(buffer >>> bits & 255);
|
|
4786
|
+
}
|
|
4787
|
+
}
|
|
4788
|
+
return new Uint8Array(output);
|
|
4789
|
+
}
|
|
4790
|
+
function generateSecret(byteLength) {
|
|
4791
|
+
const bytes = new Uint8Array(byteLength);
|
|
4792
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
4793
|
+
return bytes;
|
|
4794
|
+
}
|
|
4795
|
+
function generateRecoveryCodes(count, length) {
|
|
4796
|
+
const codes = [];
|
|
4797
|
+
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
4798
|
+
for (let i = 0; i < count; i++) {
|
|
4799
|
+
const bytes = new Uint8Array(length);
|
|
4800
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
4801
|
+
let code = "";
|
|
4802
|
+
for (let j = 0; j < length; j++) {
|
|
4803
|
+
code += chars[bytes[j] % chars.length];
|
|
4804
|
+
}
|
|
4805
|
+
codes.push(`${code.slice(0, 5)}-${code.slice(5)}`);
|
|
4806
|
+
}
|
|
4807
|
+
return codes;
|
|
4808
|
+
}
|
|
4809
|
+
async function hashRecoveryCode(code) {
|
|
4810
|
+
const encoded = new TextEncoder().encode(code.toLowerCase().replace(/[-\s]/g, ""));
|
|
4811
|
+
const hash = await globalThis.crypto.subtle.digest("SHA-256", encoded);
|
|
4812
|
+
const bytes = new Uint8Array(hash);
|
|
4813
|
+
let hex = "";
|
|
4814
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
4815
|
+
hex += bytes[i].toString(16).padStart(2, "0");
|
|
4816
|
+
}
|
|
4817
|
+
return hex;
|
|
4818
|
+
}
|
|
4819
|
+
function buildOtpauthUri(params) {
|
|
4820
|
+
const label = `${encodeURIComponent(params.issuer)}:${encodeURIComponent(params.accountName)}`;
|
|
4821
|
+
const query = new URLSearchParams({
|
|
4822
|
+
secret: params.secret,
|
|
4823
|
+
issuer: params.issuer,
|
|
4824
|
+
algorithm: params.algorithm.replace("-", ""),
|
|
4825
|
+
digits: params.digits.toString(),
|
|
4826
|
+
period: params.period.toString()
|
|
4827
|
+
});
|
|
4828
|
+
return `otpauth://totp/${label}?${query.toString()}`;
|
|
4829
|
+
}
|
|
4830
|
+
function timingSafeEqual(a, b) {
|
|
4831
|
+
if (a.length !== b.length) return false;
|
|
4832
|
+
let result = 0;
|
|
4833
|
+
for (let i = 0; i < a.length; i++) {
|
|
4834
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
4835
|
+
}
|
|
4836
|
+
return result === 0;
|
|
4837
|
+
}
|
|
4838
|
+
|
|
4839
|
+
// src/admin/admin-api.ts
|
|
4840
|
+
import { KoraError as KoraError12 } from "@korajs/core";
|
|
4841
|
+
var AdminApiError = class extends KoraError12 {
|
|
4842
|
+
constructor(message, code, context) {
|
|
4843
|
+
super(message, code, context);
|
|
4844
|
+
this.name = "AdminApiError";
|
|
4845
|
+
}
|
|
4846
|
+
};
|
|
4847
|
+
var AdminUserNotFoundError = class extends AdminApiError {
|
|
4848
|
+
constructor(userId) {
|
|
4849
|
+
super(`User "${userId}" not found.`, "ADMIN_USER_NOT_FOUND", { userId });
|
|
4850
|
+
}
|
|
4851
|
+
};
|
|
4852
|
+
var AdminUnauthorizedError = class extends AdminApiError {
|
|
4853
|
+
constructor() {
|
|
4854
|
+
super("Admin privileges required.", "ADMIN_UNAUTHORIZED");
|
|
4855
|
+
}
|
|
4856
|
+
};
|
|
4857
|
+
var AdminApi = class {
|
|
4858
|
+
userStore;
|
|
4859
|
+
sessionStore;
|
|
4860
|
+
auditLogger;
|
|
4861
|
+
constructor(config) {
|
|
4862
|
+
this.userStore = config.userStore;
|
|
4863
|
+
this.sessionStore = config.sessionStore ?? null;
|
|
4864
|
+
this.auditLogger = config.auditLogger ?? null;
|
|
4865
|
+
}
|
|
4866
|
+
/**
|
|
4867
|
+
* Get a user by ID with full details.
|
|
4868
|
+
*/
|
|
4869
|
+
async getUser(adminId, userId) {
|
|
4870
|
+
const user = await this.userStore.findById(userId);
|
|
4871
|
+
if (!user) {
|
|
4872
|
+
throw new AdminUserNotFoundError(userId);
|
|
4873
|
+
}
|
|
4874
|
+
await this.audit("admin.user_lookup", adminId, userId, "user");
|
|
4875
|
+
return toAuthUser2(user);
|
|
4876
|
+
}
|
|
4877
|
+
/**
|
|
4878
|
+
* List users with optional filtering and pagination.
|
|
4879
|
+
*/
|
|
4880
|
+
async listUsers(query = {}) {
|
|
4881
|
+
const limit = query.limit ?? 50;
|
|
4882
|
+
const offset = query.offset ?? 0;
|
|
4883
|
+
const allUsers = await this.userStore.listAll();
|
|
4884
|
+
let filtered = allUsers;
|
|
4885
|
+
if (query.email) {
|
|
4886
|
+
const searchEmail = query.email.toLowerCase();
|
|
4887
|
+
filtered = filtered.filter((u) => u.email.toLowerCase().includes(searchEmail));
|
|
4888
|
+
}
|
|
4889
|
+
if (query.emailVerified !== void 0) {
|
|
4890
|
+
filtered = filtered.filter((u) => u.emailVerified === query.emailVerified);
|
|
4891
|
+
}
|
|
4892
|
+
const total = filtered.length;
|
|
4893
|
+
const data = filtered.slice(offset, offset + limit).map(toAuthUser2);
|
|
4894
|
+
return { data, total, limit, offset };
|
|
4895
|
+
}
|
|
4896
|
+
/**
|
|
4897
|
+
* Update a user's profile (admin-level).
|
|
4898
|
+
*/
|
|
4899
|
+
async updateUser(adminId, userId, updates) {
|
|
4900
|
+
const user = await this.userStore.findById(userId);
|
|
4901
|
+
if (!user) {
|
|
4902
|
+
throw new AdminUserNotFoundError(userId);
|
|
4903
|
+
}
|
|
4904
|
+
if (updates.name !== void 0) {
|
|
4905
|
+
user.name = updates.name;
|
|
4906
|
+
}
|
|
4907
|
+
if (updates.emailVerified !== void 0) {
|
|
4908
|
+
user.emailVerified = updates.emailVerified;
|
|
4909
|
+
}
|
|
4910
|
+
if (updates.email !== void 0) {
|
|
4911
|
+
user.email = updates.email.toLowerCase().trim();
|
|
4912
|
+
}
|
|
4913
|
+
await this.userStore.update(user);
|
|
4914
|
+
await this.audit("user.update", adminId, userId, "user", { updates });
|
|
4915
|
+
return toAuthUser2(user);
|
|
4916
|
+
}
|
|
4917
|
+
/**
|
|
4918
|
+
* Delete a user and all associated sessions.
|
|
4919
|
+
*/
|
|
4920
|
+
async deleteUser(adminId, userId) {
|
|
4921
|
+
const user = await this.userStore.findById(userId);
|
|
4922
|
+
if (!user) {
|
|
4923
|
+
throw new AdminUserNotFoundError(userId);
|
|
4924
|
+
}
|
|
4925
|
+
if (this.sessionStore) {
|
|
4926
|
+
await this.sessionStore.deleteAllForUser(userId);
|
|
4927
|
+
}
|
|
4928
|
+
await this.userStore.delete(userId);
|
|
4929
|
+
await this.audit("user.delete", adminId, userId, "user");
|
|
4930
|
+
}
|
|
4931
|
+
/**
|
|
4932
|
+
* Get all active sessions for a user.
|
|
4933
|
+
*/
|
|
4934
|
+
async getUserSessions(userId) {
|
|
4935
|
+
if (!this.sessionStore) return [];
|
|
4936
|
+
return this.sessionStore.listByUserId(userId);
|
|
4937
|
+
}
|
|
4938
|
+
/**
|
|
4939
|
+
* Revoke all sessions for a user.
|
|
4940
|
+
*/
|
|
4941
|
+
async revokeUserSessions(adminId, userId) {
|
|
4942
|
+
if (!this.sessionStore) return 0;
|
|
4943
|
+
const count = await this.sessionStore.deleteAllForUser(userId);
|
|
4944
|
+
await this.audit("session.revoke_all", adminId, userId, "user", { sessionsRevoked: count });
|
|
4945
|
+
return count;
|
|
4946
|
+
}
|
|
4947
|
+
/**
|
|
4948
|
+
* Revoke a specific session.
|
|
4949
|
+
*/
|
|
4950
|
+
async revokeSession(adminId, sessionId) {
|
|
4951
|
+
if (!this.sessionStore) return;
|
|
4952
|
+
await this.sessionStore.delete(sessionId);
|
|
4953
|
+
await this.audit("session.revoke", adminId, sessionId, "session");
|
|
4954
|
+
}
|
|
4955
|
+
/**
|
|
4956
|
+
* Get system statistics.
|
|
4957
|
+
*/
|
|
4958
|
+
async getStats() {
|
|
4959
|
+
const allUsers = await this.userStore.listAll();
|
|
4960
|
+
const verified = allUsers.filter((u) => u.emailVerified).length;
|
|
4961
|
+
return {
|
|
4962
|
+
totalUsers: allUsers.length,
|
|
4963
|
+
verifiedUsers: verified,
|
|
4964
|
+
unverifiedUsers: allUsers.length - verified
|
|
4965
|
+
};
|
|
4966
|
+
}
|
|
4967
|
+
// --- Private ---
|
|
4968
|
+
async audit(action, actorId, targetId, targetType, metadata) {
|
|
4969
|
+
if (!this.auditLogger) return;
|
|
4970
|
+
await this.auditLogger.log({
|
|
4971
|
+
action,
|
|
4972
|
+
actorId,
|
|
4973
|
+
actorType: "admin",
|
|
4974
|
+
targetId,
|
|
4975
|
+
targetType,
|
|
4976
|
+
metadata
|
|
4977
|
+
});
|
|
4978
|
+
}
|
|
4979
|
+
};
|
|
4980
|
+
function toAuthUser2(stored) {
|
|
4981
|
+
return {
|
|
4982
|
+
id: stored.id,
|
|
4983
|
+
email: stored.email,
|
|
4984
|
+
name: stored.name,
|
|
4985
|
+
emailVerified: stored.emailVerified,
|
|
4986
|
+
createdAt: stored.createdAt
|
|
4987
|
+
};
|
|
4988
|
+
}
|
|
4989
|
+
|
|
4990
|
+
// src/admin/audit-log.ts
|
|
4991
|
+
import { KoraError as KoraError13 } from "@korajs/core";
|
|
4992
|
+
var AuditLogError = class extends KoraError13 {
|
|
4993
|
+
constructor(message, code, context) {
|
|
4994
|
+
super(message, code, context);
|
|
4995
|
+
this.name = "AuditLogError";
|
|
4996
|
+
}
|
|
4997
|
+
};
|
|
4998
|
+
var InMemoryAuditLogStore = class {
|
|
4999
|
+
entries = [];
|
|
5000
|
+
async append(entry) {
|
|
5001
|
+
this.entries.push({ ...entry });
|
|
5002
|
+
}
|
|
5003
|
+
async query(params) {
|
|
5004
|
+
let results = this.filterEntries(params);
|
|
5005
|
+
results.sort((a, b) => b.timestamp - a.timestamp);
|
|
5006
|
+
const offset = params.offset ?? 0;
|
|
5007
|
+
const limit = params.limit ?? 100;
|
|
5008
|
+
results = results.slice(offset, offset + limit);
|
|
5009
|
+
return results.map((e) => ({ ...e }));
|
|
5010
|
+
}
|
|
5011
|
+
async count(params) {
|
|
5012
|
+
return this.filterEntries(params).length;
|
|
5013
|
+
}
|
|
5014
|
+
async purgeOlderThan(timestamp) {
|
|
5015
|
+
const initialLength = this.entries.length;
|
|
5016
|
+
let writeIndex = 0;
|
|
5017
|
+
for (let i = 0; i < this.entries.length; i++) {
|
|
5018
|
+
if (this.entries[i].timestamp >= timestamp) {
|
|
5019
|
+
this.entries[writeIndex] = this.entries[i];
|
|
5020
|
+
writeIndex++;
|
|
5021
|
+
}
|
|
5022
|
+
}
|
|
5023
|
+
this.entries.length = writeIndex;
|
|
5024
|
+
return initialLength - writeIndex;
|
|
5025
|
+
}
|
|
5026
|
+
filterEntries(params) {
|
|
5027
|
+
return this.entries.filter((e) => {
|
|
5028
|
+
if (params.actorId !== void 0 && e.actorId !== params.actorId) return false;
|
|
5029
|
+
if (params.targetId !== void 0 && e.targetId !== params.targetId) return false;
|
|
5030
|
+
if (params.actions !== void 0 && !params.actions.includes(e.action)) return false;
|
|
5031
|
+
if (params.success !== void 0 && e.success !== params.success) return false;
|
|
5032
|
+
if (params.startTime !== void 0 && e.timestamp < params.startTime) return false;
|
|
5033
|
+
if (params.endTime !== void 0 && e.timestamp > params.endTime) return false;
|
|
5034
|
+
return true;
|
|
5035
|
+
});
|
|
5036
|
+
}
|
|
5037
|
+
};
|
|
5038
|
+
var AuditLogger = class {
|
|
5039
|
+
store;
|
|
5040
|
+
retentionMs;
|
|
5041
|
+
constructor(config) {
|
|
5042
|
+
this.store = config.store;
|
|
5043
|
+
this.retentionMs = config.retentionDays ? config.retentionDays * 24 * 60 * 60 * 1e3 : null;
|
|
5044
|
+
}
|
|
5045
|
+
/**
|
|
5046
|
+
* Log an audit event.
|
|
5047
|
+
*/
|
|
5048
|
+
async log(params) {
|
|
5049
|
+
const entry = {
|
|
5050
|
+
id: generateAuditId(),
|
|
5051
|
+
timestamp: Date.now(),
|
|
5052
|
+
action: params.action,
|
|
5053
|
+
actorId: params.actorId,
|
|
5054
|
+
actorType: params.actorType ?? "user",
|
|
5055
|
+
targetId: params.targetId ?? null,
|
|
5056
|
+
targetType: params.targetType ?? null,
|
|
5057
|
+
ipAddress: params.ipAddress ?? null,
|
|
5058
|
+
userAgent: params.userAgent ?? null,
|
|
5059
|
+
success: params.success ?? true,
|
|
5060
|
+
errorMessage: params.errorMessage ?? null,
|
|
5061
|
+
metadata: params.metadata
|
|
5062
|
+
};
|
|
5063
|
+
await this.store.append(entry);
|
|
5064
|
+
return entry;
|
|
5065
|
+
}
|
|
5066
|
+
/**
|
|
5067
|
+
* Query audit log entries.
|
|
5068
|
+
*/
|
|
5069
|
+
async query(params) {
|
|
5070
|
+
return this.store.query(params);
|
|
5071
|
+
}
|
|
5072
|
+
/**
|
|
5073
|
+
* Count matching audit entries.
|
|
5074
|
+
*/
|
|
5075
|
+
async count(params) {
|
|
5076
|
+
return this.store.count(params);
|
|
5077
|
+
}
|
|
5078
|
+
/**
|
|
5079
|
+
* Purge old entries based on retention policy.
|
|
5080
|
+
* Returns the number of entries purged.
|
|
5081
|
+
*/
|
|
5082
|
+
async purge() {
|
|
5083
|
+
if (this.retentionMs === null) return 0;
|
|
5084
|
+
const cutoff = Date.now() - this.retentionMs;
|
|
5085
|
+
return this.store.purgeOlderThan(cutoff);
|
|
5086
|
+
}
|
|
5087
|
+
/**
|
|
5088
|
+
* Get recent activity for a user.
|
|
5089
|
+
*/
|
|
5090
|
+
async getUserActivity(userId, limit = 50) {
|
|
5091
|
+
return this.store.query({ actorId: userId, limit });
|
|
5092
|
+
}
|
|
5093
|
+
/**
|
|
5094
|
+
* Get failed login attempts for a user within a time window.
|
|
5095
|
+
*/
|
|
5096
|
+
async getFailedLogins(userId, windowMs) {
|
|
5097
|
+
return this.store.query({
|
|
5098
|
+
targetId: userId,
|
|
5099
|
+
actions: ["user.signin"],
|
|
5100
|
+
success: false,
|
|
5101
|
+
startTime: Date.now() - windowMs
|
|
5102
|
+
});
|
|
5103
|
+
}
|
|
5104
|
+
};
|
|
5105
|
+
function generateAuditId() {
|
|
5106
|
+
const bytes = new Uint8Array(16);
|
|
5107
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
5108
|
+
let hex = "";
|
|
5109
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
5110
|
+
hex += bytes[i].toString(16).padStart(2, "0");
|
|
5111
|
+
}
|
|
5112
|
+
return hex;
|
|
5113
|
+
}
|
|
5114
|
+
|
|
5115
|
+
// src/admin/webhooks.ts
|
|
5116
|
+
import { KoraError as KoraError14 } from "@korajs/core";
|
|
5117
|
+
var WebhookError = class extends KoraError14 {
|
|
5118
|
+
constructor(message, code, context) {
|
|
5119
|
+
super(message, code, context);
|
|
5120
|
+
this.name = "WebhookError";
|
|
5121
|
+
}
|
|
5122
|
+
};
|
|
5123
|
+
var WebhookEndpointNotFoundError = class extends WebhookError {
|
|
5124
|
+
constructor(id) {
|
|
5125
|
+
super(`Webhook endpoint "${id}" not found.`, "WEBHOOK_ENDPOINT_NOT_FOUND", { id });
|
|
5126
|
+
}
|
|
5127
|
+
};
|
|
5128
|
+
var InMemoryWebhookStore = class {
|
|
5129
|
+
endpoints = /* @__PURE__ */ new Map();
|
|
5130
|
+
deliveries = [];
|
|
5131
|
+
async saveEndpoint(endpoint) {
|
|
5132
|
+
this.endpoints.set(endpoint.id, { ...endpoint });
|
|
5133
|
+
}
|
|
5134
|
+
async getEndpoint(id) {
|
|
5135
|
+
const ep = this.endpoints.get(id);
|
|
5136
|
+
return ep ? { ...ep } : null;
|
|
5137
|
+
}
|
|
5138
|
+
async listEndpoints() {
|
|
5139
|
+
return [...this.endpoints.values()].map((e) => ({ ...e }));
|
|
5140
|
+
}
|
|
5141
|
+
async deleteEndpoint(id) {
|
|
5142
|
+
this.endpoints.delete(id);
|
|
5143
|
+
}
|
|
5144
|
+
async saveDelivery(delivery) {
|
|
5145
|
+
const existing = this.deliveries.findIndex((d) => d.id === delivery.id);
|
|
5146
|
+
if (existing >= 0) {
|
|
5147
|
+
this.deliveries[existing] = { ...delivery };
|
|
5148
|
+
} else {
|
|
5149
|
+
this.deliveries.push({ ...delivery });
|
|
5150
|
+
}
|
|
5151
|
+
}
|
|
5152
|
+
async listDeliveries(endpointId, limit = 50) {
|
|
5153
|
+
return this.deliveries.filter((d) => d.endpointId === endpointId).sort((a, b) => b.createdAt - a.createdAt).slice(0, limit).map((d) => ({ ...d }));
|
|
5154
|
+
}
|
|
5155
|
+
};
|
|
5156
|
+
var MAX_RETRIES = 3;
|
|
5157
|
+
var RETRY_DELAYS_MS = [1e3, 5e3, 3e4];
|
|
5158
|
+
var WebhookManager = class {
|
|
5159
|
+
store;
|
|
5160
|
+
fetchFn;
|
|
5161
|
+
constructor(config) {
|
|
5162
|
+
this.store = config.store;
|
|
5163
|
+
this.fetchFn = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
5164
|
+
}
|
|
5165
|
+
/**
|
|
5166
|
+
* Register a new webhook endpoint.
|
|
5167
|
+
*/
|
|
5168
|
+
async register(params) {
|
|
5169
|
+
const endpoint = {
|
|
5170
|
+
id: generateId2(),
|
|
5171
|
+
url: params.url,
|
|
5172
|
+
events: [...params.events],
|
|
5173
|
+
secret: generateSecret2(),
|
|
5174
|
+
active: true,
|
|
5175
|
+
createdAt: Date.now(),
|
|
5176
|
+
metadata: params.metadata
|
|
5177
|
+
};
|
|
5178
|
+
await this.store.saveEndpoint(endpoint);
|
|
5179
|
+
return endpoint;
|
|
5180
|
+
}
|
|
5181
|
+
/**
|
|
5182
|
+
* Update a webhook endpoint.
|
|
5183
|
+
*/
|
|
5184
|
+
async update(id, updates) {
|
|
5185
|
+
const endpoint = await this.store.getEndpoint(id);
|
|
5186
|
+
if (!endpoint) {
|
|
5187
|
+
throw new WebhookEndpointNotFoundError(id);
|
|
5188
|
+
}
|
|
5189
|
+
if (updates.url !== void 0) endpoint.url = updates.url;
|
|
5190
|
+
if (updates.events !== void 0) endpoint.events = [...updates.events];
|
|
5191
|
+
if (updates.active !== void 0) endpoint.active = updates.active;
|
|
5192
|
+
await this.store.saveEndpoint(endpoint);
|
|
5193
|
+
return endpoint;
|
|
5194
|
+
}
|
|
5195
|
+
/**
|
|
5196
|
+
* Delete a webhook endpoint.
|
|
5197
|
+
*/
|
|
5198
|
+
async remove(id) {
|
|
5199
|
+
await this.store.deleteEndpoint(id);
|
|
5200
|
+
}
|
|
5201
|
+
/**
|
|
5202
|
+
* List all webhook endpoints.
|
|
5203
|
+
*/
|
|
5204
|
+
async list() {
|
|
5205
|
+
return this.store.listEndpoints();
|
|
5206
|
+
}
|
|
5207
|
+
/**
|
|
5208
|
+
* Get a specific endpoint.
|
|
5209
|
+
*/
|
|
5210
|
+
async get(id) {
|
|
5211
|
+
const endpoint = await this.store.getEndpoint(id);
|
|
5212
|
+
if (!endpoint) {
|
|
5213
|
+
throw new WebhookEndpointNotFoundError(id);
|
|
5214
|
+
}
|
|
5215
|
+
return endpoint;
|
|
5216
|
+
}
|
|
5217
|
+
/**
|
|
5218
|
+
* Get recent deliveries for an endpoint.
|
|
5219
|
+
*/
|
|
5220
|
+
async getDeliveries(endpointId, limit) {
|
|
5221
|
+
return this.store.listDeliveries(endpointId, limit);
|
|
5222
|
+
}
|
|
5223
|
+
/**
|
|
5224
|
+
* Dispatch an event to all matching webhook endpoints.
|
|
5225
|
+
* Delivery is best-effort with retries.
|
|
5226
|
+
*/
|
|
5227
|
+
async dispatch(event, data) {
|
|
5228
|
+
const endpoints = await this.store.listEndpoints();
|
|
5229
|
+
const matching = endpoints.filter(
|
|
5230
|
+
(ep) => ep.active && ep.events.includes(event)
|
|
5231
|
+
);
|
|
5232
|
+
const payload = {
|
|
5233
|
+
id: generateId2(),
|
|
5234
|
+
event,
|
|
5235
|
+
timestamp: Date.now(),
|
|
5236
|
+
data
|
|
5237
|
+
};
|
|
5238
|
+
const payloadJson = JSON.stringify(payload);
|
|
5239
|
+
await Promise.allSettled(
|
|
5240
|
+
matching.map((ep) => this.deliverToEndpoint(ep, payloadJson, event))
|
|
5241
|
+
);
|
|
5242
|
+
}
|
|
5243
|
+
// --- Private ---
|
|
5244
|
+
async deliverToEndpoint(endpoint, payloadJson, event) {
|
|
5245
|
+
const signature = await signPayload(payloadJson, endpoint.secret);
|
|
5246
|
+
const delivery = {
|
|
5247
|
+
id: generateId2(),
|
|
5248
|
+
endpointId: endpoint.id,
|
|
5249
|
+
event,
|
|
5250
|
+
payload: payloadJson,
|
|
5251
|
+
responseStatus: null,
|
|
5252
|
+
success: false,
|
|
5253
|
+
error: null,
|
|
5254
|
+
attempts: 0,
|
|
5255
|
+
createdAt: Date.now(),
|
|
5256
|
+
lastAttemptAt: Date.now()
|
|
5257
|
+
};
|
|
5258
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
5259
|
+
delivery.attempts = attempt + 1;
|
|
5260
|
+
delivery.lastAttemptAt = Date.now();
|
|
5261
|
+
try {
|
|
5262
|
+
const response = await this.fetchFn(endpoint.url, {
|
|
5263
|
+
method: "POST",
|
|
5264
|
+
headers: {
|
|
5265
|
+
"Content-Type": "application/json",
|
|
5266
|
+
"X-Webhook-Signature": signature,
|
|
5267
|
+
"X-Webhook-Event": event,
|
|
5268
|
+
"X-Webhook-Delivery": delivery.id
|
|
5269
|
+
},
|
|
5270
|
+
body: payloadJson
|
|
5271
|
+
});
|
|
5272
|
+
delivery.responseStatus = response.status;
|
|
5273
|
+
delivery.success = response.ok;
|
|
5274
|
+
if (response.ok) {
|
|
5275
|
+
await this.store.saveDelivery(delivery);
|
|
5276
|
+
return;
|
|
5277
|
+
}
|
|
5278
|
+
delivery.error = `HTTP ${response.status}`;
|
|
5279
|
+
} catch (err) {
|
|
5280
|
+
delivery.error = err instanceof Error ? err.message : "Network error";
|
|
5281
|
+
}
|
|
5282
|
+
if (attempt < MAX_RETRIES - 1) {
|
|
5283
|
+
await delay(RETRY_DELAYS_MS[attempt] ?? 1e3);
|
|
5284
|
+
}
|
|
5285
|
+
}
|
|
5286
|
+
await this.store.saveDelivery(delivery);
|
|
5287
|
+
}
|
|
5288
|
+
};
|
|
5289
|
+
function generateId2() {
|
|
5290
|
+
const bytes = new Uint8Array(16);
|
|
5291
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
5292
|
+
let hex = "";
|
|
5293
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
5294
|
+
hex += bytes[i].toString(16).padStart(2, "0");
|
|
5295
|
+
}
|
|
5296
|
+
return hex;
|
|
5297
|
+
}
|
|
5298
|
+
function generateSecret2() {
|
|
5299
|
+
const bytes = new Uint8Array(32);
|
|
5300
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
5301
|
+
let hex = "";
|
|
5302
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
5303
|
+
hex += bytes[i].toString(16).padStart(2, "0");
|
|
5304
|
+
}
|
|
5305
|
+
return `whsec_${hex}`;
|
|
5306
|
+
}
|
|
5307
|
+
async function signPayload(payload, secret) {
|
|
5308
|
+
const encoder = new TextEncoder();
|
|
5309
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
5310
|
+
"raw",
|
|
5311
|
+
encoder.encode(secret),
|
|
5312
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
5313
|
+
false,
|
|
5314
|
+
["sign"]
|
|
5315
|
+
);
|
|
5316
|
+
const signature = await globalThis.crypto.subtle.sign(
|
|
5317
|
+
"HMAC",
|
|
5318
|
+
key,
|
|
5319
|
+
encoder.encode(payload)
|
|
5320
|
+
);
|
|
5321
|
+
const bytes = new Uint8Array(signature);
|
|
5322
|
+
let hex = "";
|
|
5323
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
5324
|
+
hex += bytes[i].toString(16).padStart(2, "0");
|
|
5325
|
+
}
|
|
5326
|
+
return `sha256=${hex}`;
|
|
5327
|
+
}
|
|
5328
|
+
async function verifyWebhookSignature(payload, signature, secret) {
|
|
5329
|
+
const expected = await signPayload(payload, secret);
|
|
5330
|
+
if (expected.length !== signature.length) return false;
|
|
5331
|
+
let result = 0;
|
|
5332
|
+
for (let i = 0; i < expected.length; i++) {
|
|
5333
|
+
result |= expected.charCodeAt(i) ^ signature.charCodeAt(i);
|
|
5334
|
+
}
|
|
5335
|
+
return result === 0;
|
|
5336
|
+
}
|
|
5337
|
+
function delay(ms) {
|
|
5338
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
5339
|
+
}
|
|
5340
|
+
export {
|
|
5341
|
+
AdminApi,
|
|
5342
|
+
AdminApiError,
|
|
5343
|
+
AdminUnauthorizedError,
|
|
5344
|
+
AdminUserNotFoundError,
|
|
5345
|
+
AuditLogError,
|
|
5346
|
+
AuditLogger,
|
|
5347
|
+
AuthProviderError,
|
|
5348
|
+
BUILT_IN_ROLES,
|
|
5349
|
+
BuiltInAuthRoutes,
|
|
5350
|
+
BuiltInProvider,
|
|
5351
|
+
CannotRemoveOwnerError,
|
|
5352
|
+
CircularInheritanceError,
|
|
5353
|
+
DuplicateEmailError,
|
|
5354
|
+
EmailVerificationError,
|
|
5355
|
+
EmailVerificationManager,
|
|
5356
|
+
ExternalAuthOperationNotSupportedError,
|
|
5357
|
+
ExternalJwtProvider,
|
|
5358
|
+
ExternalTokenValidationError,
|
|
5359
|
+
INVITATION_STATUSES,
|
|
5360
|
+
InMemoryAuditLogStore,
|
|
5361
|
+
InMemoryChallengeStore,
|
|
5362
|
+
InMemoryEmailVerificationStore,
|
|
5363
|
+
InMemoryOAuthStateStore,
|
|
5364
|
+
InMemoryOrgStore,
|
|
5365
|
+
InMemoryPasswordResetStore,
|
|
5366
|
+
InMemoryRateLimiter,
|
|
5367
|
+
InMemorySessionStore,
|
|
5368
|
+
InMemoryTokenRevocationStore,
|
|
5369
|
+
InMemoryTotpStore,
|
|
5370
|
+
InMemoryUserStore,
|
|
5371
|
+
InMemoryWebhookStore,
|
|
5372
|
+
InsufficientRoleError,
|
|
5373
|
+
InvalidPermissionError,
|
|
5374
|
+
InvitationExpiredError,
|
|
5375
|
+
InvitationNotFoundError,
|
|
5376
|
+
MemberAlreadyExistsError,
|
|
5377
|
+
MembershipNotFoundError,
|
|
5378
|
+
OAuthCodeExchangeError,
|
|
5379
|
+
OAuthError,
|
|
5380
|
+
OAuthManager,
|
|
5381
|
+
OAuthProviderNotFoundError,
|
|
5382
|
+
OAuthStateMismatchError,
|
|
5383
|
+
OAuthUserInfoError,
|
|
5384
|
+
ORG_ROLES,
|
|
5385
|
+
OperationEncryptionError,
|
|
5386
|
+
OperationEncryptor,
|
|
5387
|
+
OrgError,
|
|
5388
|
+
OrgNotFoundError,
|
|
5389
|
+
OrgRoutes,
|
|
5390
|
+
OrgScopeResolver,
|
|
5391
|
+
OrgSlugTakenError,
|
|
5392
|
+
PasskeyVerificationError,
|
|
5393
|
+
PasswordResetError,
|
|
5394
|
+
PasswordResetManager,
|
|
5395
|
+
PostgresUserStore,
|
|
5396
|
+
ROLE_HIERARCHY,
|
|
5397
|
+
RbacEngine,
|
|
5398
|
+
RbacError,
|
|
5399
|
+
ResetRateLimitedError,
|
|
5400
|
+
ResetTokenExpiredError,
|
|
5401
|
+
ResetTokenNotFoundError,
|
|
5402
|
+
RoleNotFoundError,
|
|
5403
|
+
SessionError,
|
|
5404
|
+
SessionExpiredError,
|
|
5405
|
+
SessionLimitExceededError,
|
|
5406
|
+
SessionManager,
|
|
5407
|
+
SessionMfaRequiredError,
|
|
5408
|
+
SessionNotFoundError,
|
|
5409
|
+
SqliteUserStore,
|
|
5410
|
+
TokenManager,
|
|
5411
|
+
TotpAlreadyEnabledError,
|
|
5412
|
+
TotpError,
|
|
5413
|
+
TotpInvalidCodeError,
|
|
5414
|
+
TotpManager,
|
|
5415
|
+
TotpNotEnabledError,
|
|
5416
|
+
TotpNotVerifiedError,
|
|
5417
|
+
TotpRecoveryExhaustedError,
|
|
5418
|
+
VerificationTokenExpiredError,
|
|
5419
|
+
VerificationTokenNotFoundError,
|
|
5420
|
+
WebhookEndpointNotFoundError,
|
|
5421
|
+
WebhookError,
|
|
5422
|
+
WebhookManager,
|
|
5423
|
+
base32Decode,
|
|
5424
|
+
base32Encode,
|
|
5425
|
+
computePublicKeyThumbprint,
|
|
5426
|
+
createClerkAdapter,
|
|
5427
|
+
createPostgresUserStore,
|
|
5428
|
+
createSqliteUserStore,
|
|
5429
|
+
createSupabaseAdapter,
|
|
5430
|
+
decodeJwt,
|
|
5431
|
+
defineRoles,
|
|
5432
|
+
encodeJwt,
|
|
5433
|
+
generateAuthenticationOptions,
|
|
5434
|
+
generateRegistrationOptions,
|
|
5435
|
+
githubProvider,
|
|
5436
|
+
googleProvider,
|
|
5437
|
+
hasRoleLevel,
|
|
5438
|
+
hashPassword,
|
|
5439
|
+
isEncryptedField,
|
|
5440
|
+
isExpired,
|
|
5441
|
+
microsoftProvider,
|
|
5442
|
+
parsePermission,
|
|
5443
|
+
permissionCovers,
|
|
5444
|
+
verifyAuthenticationResponse,
|
|
5445
|
+
verifyChallenge,
|
|
5446
|
+
verifyJwt,
|
|
5447
|
+
verifyPassword,
|
|
5448
|
+
verifyRegistrationResponse,
|
|
5449
|
+
verifyWebhookSignature
|
|
889
5450
|
};
|
|
890
5451
|
//# sourceMappingURL=server.js.map
|