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