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