@korajs/auth 0.1.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 +156 -0
- package/dist/auth-client-CrDNuh10.d.cts +181 -0
- package/dist/auth-client-CrDNuh10.d.ts +181 -0
- package/dist/chunk-L554ZDPY.js +174 -0
- package/dist/chunk-L554ZDPY.js.map +1 -0
- package/dist/device-identity-DiwdLsUB.d.cts +247 -0
- package/dist/device-identity-DiwdLsUB.d.ts +247 -0
- package/dist/index.cjs +740 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +80 -0
- package/dist/index.d.ts +80 -0
- package/dist/index.js +552 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +206 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +189 -0
- package/dist/react.d.ts +189 -0
- package/dist/react.js +175 -0
- package/dist/react.js.map +1 -0
- package/dist/server.cjs +1040 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +780 -0
- package/dist/server.d.ts +780 -0
- package/dist/server.js +890 -0
- package/dist/server.js.map +1 -0
- package/package.json +74 -0
package/dist/server.cjs
ADDED
|
@@ -0,0 +1,1040 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/server.ts
|
|
21
|
+
var server_exports = {};
|
|
22
|
+
__export(server_exports, {
|
|
23
|
+
AuthProviderError: () => AuthProviderError,
|
|
24
|
+
BuiltInAuthRoutes: () => BuiltInAuthRoutes,
|
|
25
|
+
BuiltInProvider: () => BuiltInProvider,
|
|
26
|
+
DuplicateEmailError: () => DuplicateEmailError,
|
|
27
|
+
InMemoryUserStore: () => InMemoryUserStore,
|
|
28
|
+
TokenManager: () => TokenManager,
|
|
29
|
+
computePublicKeyThumbprint: () => computePublicKeyThumbprint,
|
|
30
|
+
decodeJwt: () => decodeJwt,
|
|
31
|
+
encodeJwt: () => encodeJwt,
|
|
32
|
+
hashPassword: () => hashPassword,
|
|
33
|
+
isExpired: () => isExpired,
|
|
34
|
+
verifyChallenge: () => verifyChallenge,
|
|
35
|
+
verifyJwt: () => verifyJwt,
|
|
36
|
+
verifyPassword: () => verifyPassword
|
|
37
|
+
});
|
|
38
|
+
module.exports = __toCommonJS(server_exports);
|
|
39
|
+
|
|
40
|
+
// src/types.ts
|
|
41
|
+
var import_core = require("@korajs/core");
|
|
42
|
+
var DEFAULT_ACCESS_TOKEN_LIFETIME = 15 * 60 * 1e3;
|
|
43
|
+
var DEFAULT_REFRESH_TOKEN_LIFETIME = 90 * 24 * 60 * 60 * 1e3;
|
|
44
|
+
var DEFAULT_DEVICE_CREDENTIAL_LIFETIME = 90 * 24 * 60 * 60 * 1e3;
|
|
45
|
+
var DEFAULT_MAX_OFFLINE_DURATION = 30 * 24 * 60 * 60 * 1e3;
|
|
46
|
+
var DEFAULT_AUTO_LOCK_TIMEOUT = 15 * 60 * 1e3;
|
|
47
|
+
|
|
48
|
+
// src/tokens/jwt.ts
|
|
49
|
+
var import_node_crypto = require("crypto");
|
|
50
|
+
function base64urlEncode(input) {
|
|
51
|
+
return Buffer.from(input, "utf-8").toString("base64url");
|
|
52
|
+
}
|
|
53
|
+
function base64urlDecode(input) {
|
|
54
|
+
return Buffer.from(input, "base64url").toString("utf-8");
|
|
55
|
+
}
|
|
56
|
+
function hmacSha256Base64url(data, secret) {
|
|
57
|
+
return (0, import_node_crypto.createHmac)("sha256", secret).update(data).digest("base64url");
|
|
58
|
+
}
|
|
59
|
+
var ENCODED_HEADER = base64urlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
|
60
|
+
function encodeJwt(payload, secret) {
|
|
61
|
+
const encodedPayload = base64urlEncode(JSON.stringify(payload));
|
|
62
|
+
const signingInput = `${ENCODED_HEADER}.${encodedPayload}`;
|
|
63
|
+
const signature = hmacSha256Base64url(signingInput, secret);
|
|
64
|
+
return `${signingInput}.${signature}`;
|
|
65
|
+
}
|
|
66
|
+
function decodeJwt(token) {
|
|
67
|
+
const parts = token.split(".");
|
|
68
|
+
if (parts.length !== 3) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const payloadSegment = parts[1];
|
|
72
|
+
if (payloadSegment === void 0) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const decoded = base64urlDecode(payloadSegment);
|
|
77
|
+
const parsed = JSON.parse(decoded);
|
|
78
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
return parsed;
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function verifyJwt(token, secret) {
|
|
87
|
+
const parts = token.split(".");
|
|
88
|
+
if (parts.length !== 3) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const headerSegment = parts[0];
|
|
92
|
+
const payloadSegment = parts[1];
|
|
93
|
+
const signatureSegment = parts[2];
|
|
94
|
+
if (headerSegment === void 0 || payloadSegment === void 0 || signatureSegment === void 0) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const signingInput = `${headerSegment}.${payloadSegment}`;
|
|
98
|
+
const expectedSignature = hmacSha256Base64url(signingInput, secret);
|
|
99
|
+
if (expectedSignature.length !== signatureSegment.length) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
let mismatch = 0;
|
|
103
|
+
for (let i = 0; i < expectedSignature.length; i++) {
|
|
104
|
+
mismatch |= expectedSignature.charCodeAt(i) ^ signatureSegment.charCodeAt(i);
|
|
105
|
+
}
|
|
106
|
+
if (mismatch !== 0) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const decoded = base64urlDecode(payloadSegment);
|
|
111
|
+
const parsed = JSON.parse(decoded);
|
|
112
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return parsed;
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function isExpired(payload) {
|
|
121
|
+
if (typeof payload.exp !== "number") {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
125
|
+
return nowSeconds >= payload.exp;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// src/tokens/token-manager.ts
|
|
129
|
+
var TokenManager = class {
|
|
130
|
+
secret;
|
|
131
|
+
accessTokenLifetime;
|
|
132
|
+
refreshTokenLifetime;
|
|
133
|
+
deviceCredentialLifetime;
|
|
134
|
+
constructor(config) {
|
|
135
|
+
this.secret = config.secret;
|
|
136
|
+
this.accessTokenLifetime = config.accessTokenLifetime ?? DEFAULT_ACCESS_TOKEN_LIFETIME;
|
|
137
|
+
this.refreshTokenLifetime = config.refreshTokenLifetime ?? DEFAULT_REFRESH_TOKEN_LIFETIME;
|
|
138
|
+
this.deviceCredentialLifetime = config.deviceCredentialLifetime ?? DEFAULT_DEVICE_CREDENTIAL_LIFETIME;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Issue a signed JWT access token.
|
|
142
|
+
*
|
|
143
|
+
* Access tokens are short-lived (default 15 minutes) and used to authorize
|
|
144
|
+
* API requests. When expired, use {@link refreshAccessToken} with a valid
|
|
145
|
+
* refresh token to obtain a new one.
|
|
146
|
+
*
|
|
147
|
+
* @param userId - The subject (user ID) to encode in the token
|
|
148
|
+
* @param deviceId - The device ID of the requesting device
|
|
149
|
+
* @returns A signed JWT string with type 'access'
|
|
150
|
+
*/
|
|
151
|
+
issueAccessToken(userId, deviceId) {
|
|
152
|
+
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
153
|
+
const payload = {
|
|
154
|
+
sub: userId,
|
|
155
|
+
dev: deviceId,
|
|
156
|
+
type: "access",
|
|
157
|
+
iat: nowSeconds,
|
|
158
|
+
exp: nowSeconds + Math.floor(this.accessTokenLifetime / 1e3)
|
|
159
|
+
};
|
|
160
|
+
return encodeJwt(payload, this.secret);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Issue a signed JWT refresh token.
|
|
164
|
+
*
|
|
165
|
+
* Refresh tokens are longer-lived (default 90 days) and used exclusively
|
|
166
|
+
* to obtain new access tokens via {@link refreshAccessToken}. They should
|
|
167
|
+
* be stored securely and never sent to resource APIs.
|
|
168
|
+
*
|
|
169
|
+
* @param userId - The subject (user ID) to encode in the token
|
|
170
|
+
* @param deviceId - The device ID of the requesting device
|
|
171
|
+
* @returns A signed JWT string with type 'refresh'
|
|
172
|
+
*/
|
|
173
|
+
issueRefreshToken(userId, deviceId) {
|
|
174
|
+
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
175
|
+
const payload = {
|
|
176
|
+
sub: userId,
|
|
177
|
+
dev: deviceId,
|
|
178
|
+
type: "refresh",
|
|
179
|
+
iat: nowSeconds,
|
|
180
|
+
exp: nowSeconds + Math.floor(this.refreshTokenLifetime / 1e3)
|
|
181
|
+
};
|
|
182
|
+
return encodeJwt(payload, this.secret);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Issue a signed device credential token.
|
|
186
|
+
*
|
|
187
|
+
* Device credentials are long-lived tokens bound to a device's public key.
|
|
188
|
+
* They include a `mustCheckinBy` deadline; if the device does not check in
|
|
189
|
+
* before this deadline, the credential should be treated as revoked.
|
|
190
|
+
*
|
|
191
|
+
* @param userId - The subject (user ID) to encode in the token
|
|
192
|
+
* @param deviceId - The device ID of the requesting device
|
|
193
|
+
* @param publicKeyThumbprint - SHA-256 thumbprint of the device's public key
|
|
194
|
+
* @returns A signed JWT string with type 'device_credential'
|
|
195
|
+
*/
|
|
196
|
+
issueDeviceCredential(userId, deviceId, publicKeyThumbprint) {
|
|
197
|
+
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
198
|
+
const lifetimeSeconds = Math.floor(this.deviceCredentialLifetime / 1e3);
|
|
199
|
+
const payload = {
|
|
200
|
+
sub: userId,
|
|
201
|
+
dev: deviceId,
|
|
202
|
+
type: "device_credential",
|
|
203
|
+
iat: nowSeconds,
|
|
204
|
+
exp: nowSeconds + lifetimeSeconds,
|
|
205
|
+
dpk: publicKeyThumbprint,
|
|
206
|
+
mustCheckinBy: nowSeconds + lifetimeSeconds
|
|
207
|
+
};
|
|
208
|
+
return encodeJwt(payload, this.secret);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Issue a complete set of authentication tokens.
|
|
212
|
+
*
|
|
213
|
+
* Always issues an access token and refresh token. If a `publicKeyThumbprint`
|
|
214
|
+
* is provided, also issues a device credential.
|
|
215
|
+
*
|
|
216
|
+
* @param userId - The subject (user ID) to encode in the tokens
|
|
217
|
+
* @param deviceId - The device ID of the requesting device
|
|
218
|
+
* @param publicKeyThumbprint - Optional SHA-256 thumbprint of the device's public key.
|
|
219
|
+
* When provided, a device credential is included in the returned tokens.
|
|
220
|
+
* @returns An {@link AuthTokens} object containing the issued tokens
|
|
221
|
+
*/
|
|
222
|
+
issueTokens(userId, deviceId, publicKeyThumbprint) {
|
|
223
|
+
const tokens = {
|
|
224
|
+
accessToken: this.issueAccessToken(userId, deviceId),
|
|
225
|
+
refreshToken: this.issueRefreshToken(userId, deviceId)
|
|
226
|
+
};
|
|
227
|
+
if (publicKeyThumbprint !== void 0) {
|
|
228
|
+
tokens.deviceCredential = this.issueDeviceCredential(
|
|
229
|
+
userId,
|
|
230
|
+
deviceId,
|
|
231
|
+
publicKeyThumbprint
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return tokens;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Validate and decode a token.
|
|
238
|
+
*
|
|
239
|
+
* Verifies the HMAC-SHA256 signature and checks that the token has not expired.
|
|
240
|
+
* Returns null (rather than throwing) for invalid or expired tokens, so callers
|
|
241
|
+
* can handle authentication failure without try/catch.
|
|
242
|
+
*
|
|
243
|
+
* @param token - The JWT string to validate
|
|
244
|
+
* @returns The decoded {@link TokenPayload} if valid, or null if the token is
|
|
245
|
+
* invalid, expired, or missing required claims
|
|
246
|
+
*/
|
|
247
|
+
validateToken(token) {
|
|
248
|
+
const decoded = verifyJwt(token, this.secret);
|
|
249
|
+
if (decoded === null) {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
if (isExpired(decoded)) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
if (typeof decoded["sub"] !== "string" || typeof decoded["dev"] !== "string" || typeof decoded["type"] !== "string" || typeof decoded["iat"] !== "number" || typeof decoded["exp"] !== "number") {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
const type = decoded["type"];
|
|
259
|
+
if (type !== "access" && type !== "refresh" && type !== "device_credential") {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
sub: decoded["sub"],
|
|
264
|
+
dev: decoded["dev"],
|
|
265
|
+
type,
|
|
266
|
+
iat: decoded["iat"],
|
|
267
|
+
exp: decoded["exp"]
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Refresh an access token using a valid refresh token.
|
|
272
|
+
*
|
|
273
|
+
* Implements **refresh token rotation**: a new refresh token is issued alongside
|
|
274
|
+
* the new access token, and the old refresh token should be considered consumed.
|
|
275
|
+
* This limits the window of vulnerability if a refresh token is compromised.
|
|
276
|
+
*
|
|
277
|
+
* Returns null if the provided token is invalid, expired, or not a refresh token.
|
|
278
|
+
*
|
|
279
|
+
* @param refreshToken - The refresh token JWT string
|
|
280
|
+
* @returns A new access/refresh token pair, or null if the refresh token is invalid
|
|
281
|
+
*/
|
|
282
|
+
refreshAccessToken(refreshToken) {
|
|
283
|
+
const payload = this.validateToken(refreshToken);
|
|
284
|
+
if (payload === null) {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
if (payload.type !== "refresh") {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
accessToken: this.issueAccessToken(payload.sub, payload.dev),
|
|
292
|
+
refreshToken: this.issueRefreshToken(payload.sub, payload.dev)
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// src/provider/built-in/password-hash.ts
|
|
298
|
+
var import_node_crypto2 = require("crypto");
|
|
299
|
+
var PBKDF2_ITERATIONS = 6e5;
|
|
300
|
+
var PBKDF2_DIGEST = "sha512";
|
|
301
|
+
var KEY_LENGTH = 64;
|
|
302
|
+
var SALT_LENGTH = 32;
|
|
303
|
+
async function hashPassword(password) {
|
|
304
|
+
const salt = (0, import_node_crypto2.randomBytes)(SALT_LENGTH);
|
|
305
|
+
const derivedKey = await pbkdf2Async(
|
|
306
|
+
password,
|
|
307
|
+
salt,
|
|
308
|
+
PBKDF2_ITERATIONS,
|
|
309
|
+
KEY_LENGTH,
|
|
310
|
+
PBKDF2_DIGEST
|
|
311
|
+
);
|
|
312
|
+
return {
|
|
313
|
+
hash: derivedKey.toString("hex"),
|
|
314
|
+
salt: salt.toString("hex")
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
async function verifyPassword(password, hash, salt) {
|
|
318
|
+
const saltBuffer = Buffer.from(salt, "hex");
|
|
319
|
+
const derivedKey = await pbkdf2Async(
|
|
320
|
+
password,
|
|
321
|
+
saltBuffer,
|
|
322
|
+
PBKDF2_ITERATIONS,
|
|
323
|
+
KEY_LENGTH,
|
|
324
|
+
PBKDF2_DIGEST
|
|
325
|
+
);
|
|
326
|
+
const hashBuffer = Buffer.from(hash, "hex");
|
|
327
|
+
if (derivedKey.length !== hashBuffer.length) {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
return (0, import_node_crypto2.timingSafeEqual)(derivedKey, hashBuffer);
|
|
331
|
+
}
|
|
332
|
+
function pbkdf2Async(password, salt, iterations, keyLength, digest) {
|
|
333
|
+
return new Promise((resolve, reject) => {
|
|
334
|
+
(0, import_node_crypto2.pbkdf2)(password, salt, iterations, keyLength, digest, (err, derivedKey) => {
|
|
335
|
+
if (err) {
|
|
336
|
+
reject(err);
|
|
337
|
+
} else {
|
|
338
|
+
resolve(derivedKey);
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// src/provider/built-in/user-store.ts
|
|
345
|
+
var import_core2 = require("@korajs/core");
|
|
346
|
+
var import_node_crypto3 = require("crypto");
|
|
347
|
+
var DuplicateEmailError = class extends import_core2.KoraError {
|
|
348
|
+
constructor(email) {
|
|
349
|
+
super(
|
|
350
|
+
`A user with email "${email}" already exists. Use a different email address or sign in to the existing account.`,
|
|
351
|
+
"DUPLICATE_EMAIL",
|
|
352
|
+
{ email }
|
|
353
|
+
);
|
|
354
|
+
this.name = "DuplicateEmailError";
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
var InMemoryUserStore = class {
|
|
358
|
+
/** Users indexed by ID */
|
|
359
|
+
usersById = /* @__PURE__ */ new Map();
|
|
360
|
+
/** Users indexed by email (lowercase) for fast lookup */
|
|
361
|
+
usersByEmail = /* @__PURE__ */ new Map();
|
|
362
|
+
/** Devices indexed by device ID */
|
|
363
|
+
devicesById = /* @__PURE__ */ new Map();
|
|
364
|
+
/** Device IDs indexed by user ID for fast listing */
|
|
365
|
+
devicesByUserId = /* @__PURE__ */ new Map();
|
|
366
|
+
/**
|
|
367
|
+
* Create a new user account.
|
|
368
|
+
*
|
|
369
|
+
* @param params - User creation parameters
|
|
370
|
+
* @param params.email - The user's email address (must be unique, case-insensitive)
|
|
371
|
+
* @param params.passwordHash - Hex-encoded PBKDF2 derived key
|
|
372
|
+
* @param params.salt - Hex-encoded salt used during hashing
|
|
373
|
+
* @param params.name - The user's display name
|
|
374
|
+
* @returns The created user (without sensitive credential fields)
|
|
375
|
+
* @throws {DuplicateEmailError} If a user with the same email already exists
|
|
376
|
+
*/
|
|
377
|
+
async createUser(params) {
|
|
378
|
+
const normalizedEmail = params.email.toLowerCase();
|
|
379
|
+
if (this.usersByEmail.has(normalizedEmail)) {
|
|
380
|
+
throw new DuplicateEmailError(params.email);
|
|
381
|
+
}
|
|
382
|
+
const now = Date.now();
|
|
383
|
+
const id = (0, import_node_crypto3.randomUUID)();
|
|
384
|
+
const storedUser = {
|
|
385
|
+
id,
|
|
386
|
+
email: normalizedEmail,
|
|
387
|
+
name: params.name,
|
|
388
|
+
createdAt: now,
|
|
389
|
+
passwordHash: params.passwordHash,
|
|
390
|
+
salt: params.salt
|
|
391
|
+
};
|
|
392
|
+
this.usersById.set(id, storedUser);
|
|
393
|
+
this.usersByEmail.set(normalizedEmail, storedUser);
|
|
394
|
+
return toAuthUser(storedUser);
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Find a user by email address.
|
|
398
|
+
*
|
|
399
|
+
* @param email - The email to search for (case-insensitive)
|
|
400
|
+
* @returns The stored user record including credentials, or null if not found
|
|
401
|
+
*/
|
|
402
|
+
async findByEmail(email) {
|
|
403
|
+
return this.usersByEmail.get(email.toLowerCase()) ?? null;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Find a user by ID.
|
|
407
|
+
*
|
|
408
|
+
* @param id - The user ID to search for
|
|
409
|
+
* @returns The stored user record including credentials, or null if not found
|
|
410
|
+
*/
|
|
411
|
+
async findById(id) {
|
|
412
|
+
return this.usersById.get(id) ?? null;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Register a device for a user.
|
|
416
|
+
*
|
|
417
|
+
* If a device with the same ID already exists and is not revoked, it is
|
|
418
|
+
* returned as-is (idempotent registration). If it was previously revoked,
|
|
419
|
+
* it is re-activated with updated details.
|
|
420
|
+
*
|
|
421
|
+
* @param params - Device registration parameters
|
|
422
|
+
* @param params.id - Unique device identifier
|
|
423
|
+
* @param params.userId - ID of the user who owns the device
|
|
424
|
+
* @param params.publicKey - Base64url-encoded device public key or thumbprint
|
|
425
|
+
* @param params.name - Human-readable device name
|
|
426
|
+
* @returns The registered device record
|
|
427
|
+
*/
|
|
428
|
+
async registerDevice(params) {
|
|
429
|
+
const existing = this.devicesById.get(params.id);
|
|
430
|
+
if (existing !== void 0 && !existing.revoked) {
|
|
431
|
+
return existing;
|
|
432
|
+
}
|
|
433
|
+
const now = Date.now();
|
|
434
|
+
const device = {
|
|
435
|
+
id: params.id,
|
|
436
|
+
userId: params.userId,
|
|
437
|
+
publicKey: params.publicKey,
|
|
438
|
+
name: params.name,
|
|
439
|
+
revoked: false,
|
|
440
|
+
createdAt: now,
|
|
441
|
+
lastSeenAt: now
|
|
442
|
+
};
|
|
443
|
+
this.devicesById.set(params.id, device);
|
|
444
|
+
let userDevices = this.devicesByUserId.get(params.userId);
|
|
445
|
+
if (userDevices === void 0) {
|
|
446
|
+
userDevices = /* @__PURE__ */ new Set();
|
|
447
|
+
this.devicesByUserId.set(params.userId, userDevices);
|
|
448
|
+
}
|
|
449
|
+
userDevices.add(params.id);
|
|
450
|
+
return device;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Find a device by its ID.
|
|
454
|
+
*
|
|
455
|
+
* @param deviceId - The device ID to search for
|
|
456
|
+
* @returns The device record, or null if not found
|
|
457
|
+
*/
|
|
458
|
+
async findDevice(deviceId) {
|
|
459
|
+
return this.devicesById.get(deviceId) ?? null;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* List all devices registered for a user.
|
|
463
|
+
*
|
|
464
|
+
* @param userId - The user ID whose devices to list
|
|
465
|
+
* @returns Array of device records (includes revoked devices)
|
|
466
|
+
*/
|
|
467
|
+
async listDevices(userId) {
|
|
468
|
+
const deviceIds = this.devicesByUserId.get(userId);
|
|
469
|
+
if (deviceIds === void 0) {
|
|
470
|
+
return [];
|
|
471
|
+
}
|
|
472
|
+
const devices = [];
|
|
473
|
+
for (const deviceId of deviceIds) {
|
|
474
|
+
const device = this.devicesById.get(deviceId);
|
|
475
|
+
if (device !== void 0) {
|
|
476
|
+
devices.push(device);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return devices;
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Revoke a device, preventing it from being used for authentication.
|
|
483
|
+
*
|
|
484
|
+
* This is a soft revoke — the device record remains but is marked as revoked.
|
|
485
|
+
* If the device does not exist, this is a no-op.
|
|
486
|
+
*
|
|
487
|
+
* @param deviceId - The ID of the device to revoke
|
|
488
|
+
*/
|
|
489
|
+
async revokeDevice(deviceId) {
|
|
490
|
+
const device = this.devicesById.get(deviceId);
|
|
491
|
+
if (device !== void 0) {
|
|
492
|
+
device.revoked = true;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Update the last-seen timestamp for a device.
|
|
497
|
+
*
|
|
498
|
+
* Called when a device authenticates or syncs to track activity.
|
|
499
|
+
* If the device does not exist, this is a no-op.
|
|
500
|
+
*
|
|
501
|
+
* @param deviceId - The ID of the device to update
|
|
502
|
+
*/
|
|
503
|
+
async touchDevice(deviceId) {
|
|
504
|
+
const device = this.devicesById.get(deviceId);
|
|
505
|
+
if (device !== void 0) {
|
|
506
|
+
device.lastSeenAt = Date.now();
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
function toAuthUser(stored) {
|
|
511
|
+
return {
|
|
512
|
+
id: stored.id,
|
|
513
|
+
email: stored.email,
|
|
514
|
+
name: stored.name,
|
|
515
|
+
createdAt: stored.createdAt
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// src/provider/built-in/auth-routes.ts
|
|
520
|
+
var MIN_PASSWORD_LENGTH = 8;
|
|
521
|
+
function isValidEmail(email) {
|
|
522
|
+
if (email.length === 0 || email.length > 254) {
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
const atIndex = email.indexOf("@");
|
|
526
|
+
if (atIndex < 1) {
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
const domain = email.slice(atIndex + 1);
|
|
530
|
+
if (domain.length === 0 || !domain.includes(".")) {
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
if (email.indexOf("@", atIndex + 1) !== -1) {
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
if (email.includes(" ")) {
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
return true;
|
|
540
|
+
}
|
|
541
|
+
var BuiltInAuthRoutes = class {
|
|
542
|
+
userStore;
|
|
543
|
+
tokenManager;
|
|
544
|
+
constructor(config) {
|
|
545
|
+
this.userStore = config.userStore;
|
|
546
|
+
this.tokenManager = config.tokenManager;
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Handle user sign-up (POST /auth/signup).
|
|
550
|
+
*
|
|
551
|
+
* Validates email format and password length, hashes the password,
|
|
552
|
+
* creates the user, optionally registers a device, and issues tokens.
|
|
553
|
+
*
|
|
554
|
+
* @param body - Sign-up request body
|
|
555
|
+
* @param body.email - The user's email address
|
|
556
|
+
* @param body.password - The plaintext password (min 8 characters)
|
|
557
|
+
* @param body.name - Optional display name (defaults to email local part)
|
|
558
|
+
* @param body.deviceId - Optional device ID to register
|
|
559
|
+
* @param body.devicePublicKey - Optional device public key (base64url)
|
|
560
|
+
* @returns Auth response with the created user and tokens, or an error
|
|
561
|
+
*/
|
|
562
|
+
async handleSignUp(body) {
|
|
563
|
+
if (!isValidEmail(body.email)) {
|
|
564
|
+
return {
|
|
565
|
+
status: 400,
|
|
566
|
+
body: {
|
|
567
|
+
error: "Invalid email address. Please provide a valid email in the format user@domain.com."
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
if (body.password.length < MIN_PASSWORD_LENGTH) {
|
|
572
|
+
return {
|
|
573
|
+
status: 400,
|
|
574
|
+
body: {
|
|
575
|
+
error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long.`
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
const { hash, salt } = await hashPassword(body.password);
|
|
580
|
+
let user;
|
|
581
|
+
try {
|
|
582
|
+
user = await this.userStore.createUser({
|
|
583
|
+
email: body.email,
|
|
584
|
+
passwordHash: hash,
|
|
585
|
+
salt,
|
|
586
|
+
name: body.name ?? body.email.split("@")[0] ?? body.email
|
|
587
|
+
});
|
|
588
|
+
} catch (err) {
|
|
589
|
+
if (err instanceof Error && err.name === "DuplicateEmailError") {
|
|
590
|
+
return {
|
|
591
|
+
status: 409,
|
|
592
|
+
body: { error: "An account with this email already exists." }
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
throw err;
|
|
596
|
+
}
|
|
597
|
+
const deviceId = body.deviceId ?? `device-${user.id}`;
|
|
598
|
+
if (body.deviceId !== void 0 && body.devicePublicKey !== void 0) {
|
|
599
|
+
await this.userStore.registerDevice({
|
|
600
|
+
id: body.deviceId,
|
|
601
|
+
userId: user.id,
|
|
602
|
+
publicKey: body.devicePublicKey,
|
|
603
|
+
name: "Primary Device"
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
const tokens = this.tokenManager.issueTokens(user.id, deviceId);
|
|
607
|
+
return {
|
|
608
|
+
status: 201,
|
|
609
|
+
body: { data: { user, tokens } }
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Handle user sign-in (POST /auth/signin).
|
|
614
|
+
*
|
|
615
|
+
* Looks up the user by email, verifies the password, optionally registers
|
|
616
|
+
* a new device, and issues tokens.
|
|
617
|
+
*
|
|
618
|
+
* @param body - Sign-in request body
|
|
619
|
+
* @param body.email - The user's email address
|
|
620
|
+
* @param body.password - The plaintext password
|
|
621
|
+
* @param body.deviceId - Optional device ID to register
|
|
622
|
+
* @param body.devicePublicKey - Optional device public key (base64url)
|
|
623
|
+
* @returns Auth response with the user and tokens, or an error
|
|
624
|
+
*/
|
|
625
|
+
async handleSignIn(body) {
|
|
626
|
+
const storedUser = await this.userStore.findByEmail(body.email);
|
|
627
|
+
if (storedUser === null) {
|
|
628
|
+
return {
|
|
629
|
+
status: 401,
|
|
630
|
+
body: { error: "Invalid email or password." }
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
const passwordValid = await verifyPassword(
|
|
634
|
+
body.password,
|
|
635
|
+
storedUser.passwordHash,
|
|
636
|
+
storedUser.salt
|
|
637
|
+
);
|
|
638
|
+
if (!passwordValid) {
|
|
639
|
+
return {
|
|
640
|
+
status: 401,
|
|
641
|
+
body: { error: "Invalid email or password." }
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
const deviceId = body.deviceId ?? `device-${storedUser.id}`;
|
|
645
|
+
if (body.deviceId !== void 0 && body.devicePublicKey !== void 0) {
|
|
646
|
+
await this.userStore.registerDevice({
|
|
647
|
+
id: body.deviceId,
|
|
648
|
+
userId: storedUser.id,
|
|
649
|
+
publicKey: body.devicePublicKey,
|
|
650
|
+
name: "Device"
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
const tokens = this.tokenManager.issueTokens(storedUser.id, deviceId);
|
|
654
|
+
const user = {
|
|
655
|
+
id: storedUser.id,
|
|
656
|
+
email: storedUser.email,
|
|
657
|
+
name: storedUser.name,
|
|
658
|
+
createdAt: storedUser.createdAt
|
|
659
|
+
};
|
|
660
|
+
return {
|
|
661
|
+
status: 200,
|
|
662
|
+
body: { data: { user, tokens } }
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Handle token refresh (POST /auth/refresh).
|
|
667
|
+
*
|
|
668
|
+
* Validates the provided refresh token and issues a new token pair
|
|
669
|
+
* (refresh token rotation). The old refresh token should be considered
|
|
670
|
+
* consumed after this call.
|
|
671
|
+
*
|
|
672
|
+
* @param body - Refresh request body
|
|
673
|
+
* @param body.refreshToken - The current refresh token
|
|
674
|
+
* @returns Auth response with new tokens, or an error
|
|
675
|
+
*/
|
|
676
|
+
async handleRefresh(body) {
|
|
677
|
+
const result = this.tokenManager.refreshAccessToken(body.refreshToken);
|
|
678
|
+
if (result === null) {
|
|
679
|
+
return {
|
|
680
|
+
status: 401,
|
|
681
|
+
body: { error: "Invalid or expired refresh token." }
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
return {
|
|
685
|
+
status: 200,
|
|
686
|
+
body: { data: result }
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Handle get-current-user (GET /auth/me).
|
|
691
|
+
*
|
|
692
|
+
* Validates the access token and returns the authenticated user's profile.
|
|
693
|
+
*
|
|
694
|
+
* @param accessToken - The JWT access token (without "Bearer " prefix)
|
|
695
|
+
* @returns Auth response with the user profile, or an error
|
|
696
|
+
*/
|
|
697
|
+
async handleGetMe(accessToken) {
|
|
698
|
+
const payload = this.tokenManager.validateToken(accessToken);
|
|
699
|
+
if (payload === null || payload.type !== "access") {
|
|
700
|
+
return {
|
|
701
|
+
status: 401,
|
|
702
|
+
body: { error: "Invalid or expired access token." }
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
const storedUser = await this.userStore.findById(payload.sub);
|
|
706
|
+
if (storedUser === null) {
|
|
707
|
+
return {
|
|
708
|
+
status: 404,
|
|
709
|
+
body: { error: "User not found." }
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
const user = {
|
|
713
|
+
id: storedUser.id,
|
|
714
|
+
email: storedUser.email,
|
|
715
|
+
name: storedUser.name,
|
|
716
|
+
createdAt: storedUser.createdAt
|
|
717
|
+
};
|
|
718
|
+
return {
|
|
719
|
+
status: 200,
|
|
720
|
+
body: { data: user }
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Handle list-devices (GET /auth/devices).
|
|
725
|
+
*
|
|
726
|
+
* Validates the access token and returns all devices registered for the user.
|
|
727
|
+
*
|
|
728
|
+
* @param accessToken - The JWT access token (without "Bearer " prefix)
|
|
729
|
+
* @returns Auth response with the device list, or an error
|
|
730
|
+
*/
|
|
731
|
+
async handleListDevices(accessToken) {
|
|
732
|
+
const payload = this.tokenManager.validateToken(accessToken);
|
|
733
|
+
if (payload === null || payload.type !== "access") {
|
|
734
|
+
return {
|
|
735
|
+
status: 401,
|
|
736
|
+
body: { error: "Invalid or expired access token." }
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
const devices = await this.userStore.listDevices(payload.sub);
|
|
740
|
+
return {
|
|
741
|
+
status: 200,
|
|
742
|
+
body: { data: devices }
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Handle device revocation (DELETE /auth/device/:id).
|
|
747
|
+
*
|
|
748
|
+
* Validates the access token and revokes the specified device.
|
|
749
|
+
* Only the device's owner can revoke it.
|
|
750
|
+
*
|
|
751
|
+
* @param accessToken - The JWT access token (without "Bearer " prefix)
|
|
752
|
+
* @param deviceId - The ID of the device to revoke
|
|
753
|
+
* @returns Auth response with success flag, or an error
|
|
754
|
+
*/
|
|
755
|
+
async handleRevokeDevice(accessToken, deviceId) {
|
|
756
|
+
const payload = this.tokenManager.validateToken(accessToken);
|
|
757
|
+
if (payload === null || payload.type !== "access") {
|
|
758
|
+
return {
|
|
759
|
+
status: 401,
|
|
760
|
+
body: { error: "Invalid or expired access token." }
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
const device = await this.userStore.findDevice(deviceId);
|
|
764
|
+
if (device === null) {
|
|
765
|
+
return {
|
|
766
|
+
status: 404,
|
|
767
|
+
body: { error: "Device not found." }
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
if (device.userId !== payload.sub) {
|
|
771
|
+
return {
|
|
772
|
+
status: 403,
|
|
773
|
+
body: { error: "You can only revoke your own devices." }
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
await this.userStore.revokeDevice(deviceId);
|
|
777
|
+
return {
|
|
778
|
+
status: 200,
|
|
779
|
+
body: { data: { success: true } }
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Creates a sync server auth provider compatible with `@korajs/server`.
|
|
784
|
+
*
|
|
785
|
+
* The returned object implements the `AuthProvider` interface from
|
|
786
|
+
* `@korajs/server`, validating access tokens and returning an auth
|
|
787
|
+
* context containing the user ID and device metadata. This bridges
|
|
788
|
+
* the built-in auth system with the sync server's authentication layer.
|
|
789
|
+
*
|
|
790
|
+
* @returns An object with an `authenticate` method suitable for KoraSyncServer's `auth` config
|
|
791
|
+
*
|
|
792
|
+
* @example
|
|
793
|
+
* ```typescript
|
|
794
|
+
* const routes = new BuiltInAuthRoutes({ userStore, tokenManager })
|
|
795
|
+
* const syncServer = new KoraSyncServer({
|
|
796
|
+
* store,
|
|
797
|
+
* auth: routes.toSyncAuthProvider(),
|
|
798
|
+
* })
|
|
799
|
+
* ```
|
|
800
|
+
*/
|
|
801
|
+
toSyncAuthProvider() {
|
|
802
|
+
const tokenManager = this.tokenManager;
|
|
803
|
+
const userStore = this.userStore;
|
|
804
|
+
return {
|
|
805
|
+
async authenticate(token) {
|
|
806
|
+
const payload = tokenManager.validateToken(token);
|
|
807
|
+
if (payload === null || payload.type !== "access") {
|
|
808
|
+
return null;
|
|
809
|
+
}
|
|
810
|
+
const user = await userStore.findById(payload.sub);
|
|
811
|
+
if (user === null) {
|
|
812
|
+
return null;
|
|
813
|
+
}
|
|
814
|
+
await userStore.touchDevice(payload.dev);
|
|
815
|
+
return {
|
|
816
|
+
userId: payload.sub,
|
|
817
|
+
metadata: {
|
|
818
|
+
deviceId: payload.dev,
|
|
819
|
+
email: user.email,
|
|
820
|
+
name: user.name
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
// src/provider/adapter.ts
|
|
829
|
+
var AuthProviderError = class extends Error {
|
|
830
|
+
/** HTTP-style status code */
|
|
831
|
+
status;
|
|
832
|
+
constructor(message, status) {
|
|
833
|
+
super(message);
|
|
834
|
+
this.name = "AuthProviderError";
|
|
835
|
+
this.status = status;
|
|
836
|
+
}
|
|
837
|
+
};
|
|
838
|
+
var BuiltInProvider = class {
|
|
839
|
+
routes;
|
|
840
|
+
tokenManager;
|
|
841
|
+
userStore;
|
|
842
|
+
constructor(config) {
|
|
843
|
+
this.routes = new BuiltInAuthRoutes(config);
|
|
844
|
+
this.tokenManager = config.tokenManager;
|
|
845
|
+
this.userStore = config.userStore;
|
|
846
|
+
}
|
|
847
|
+
/** @inheritdoc */
|
|
848
|
+
async signUp(params) {
|
|
849
|
+
const result = await this.routes.handleSignUp(params);
|
|
850
|
+
if ("error" in result.body) {
|
|
851
|
+
throw new AuthProviderError(result.body.error, result.status);
|
|
852
|
+
}
|
|
853
|
+
return result.body.data;
|
|
854
|
+
}
|
|
855
|
+
/** @inheritdoc */
|
|
856
|
+
async signIn(params) {
|
|
857
|
+
const result = await this.routes.handleSignIn(params);
|
|
858
|
+
if ("error" in result.body) {
|
|
859
|
+
throw new AuthProviderError(result.body.error, result.status);
|
|
860
|
+
}
|
|
861
|
+
return result.body.data;
|
|
862
|
+
}
|
|
863
|
+
/** @inheritdoc */
|
|
864
|
+
async refreshTokens(refreshToken) {
|
|
865
|
+
const result = await this.routes.handleRefresh({ refreshToken });
|
|
866
|
+
if ("error" in result.body) {
|
|
867
|
+
throw new AuthProviderError(result.body.error, result.status);
|
|
868
|
+
}
|
|
869
|
+
return result.body.data;
|
|
870
|
+
}
|
|
871
|
+
/** @inheritdoc */
|
|
872
|
+
async validateAccessToken(token) {
|
|
873
|
+
const payload = this.tokenManager.validateToken(token);
|
|
874
|
+
if (payload === null || payload.type !== "access") {
|
|
875
|
+
return null;
|
|
876
|
+
}
|
|
877
|
+
return { userId: payload.sub, deviceId: payload.dev };
|
|
878
|
+
}
|
|
879
|
+
/** @inheritdoc */
|
|
880
|
+
async getUser(userId) {
|
|
881
|
+
const stored = await this.userStore.findById(userId);
|
|
882
|
+
if (stored === null) {
|
|
883
|
+
return null;
|
|
884
|
+
}
|
|
885
|
+
return {
|
|
886
|
+
id: stored.id,
|
|
887
|
+
email: stored.email,
|
|
888
|
+
name: stored.name,
|
|
889
|
+
createdAt: stored.createdAt
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
/** @inheritdoc */
|
|
893
|
+
async revokeDevice(accessToken, deviceId) {
|
|
894
|
+
const result = await this.routes.handleRevokeDevice(accessToken, deviceId);
|
|
895
|
+
if ("error" in result.body) {
|
|
896
|
+
throw new AuthProviderError(result.body.error, result.status);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
/** @inheritdoc */
|
|
900
|
+
async listDevices(accessToken) {
|
|
901
|
+
const result = await this.routes.handleListDevices(accessToken);
|
|
902
|
+
if ("error" in result.body) {
|
|
903
|
+
throw new AuthProviderError(result.body.error, result.status);
|
|
904
|
+
}
|
|
905
|
+
return result.body.data;
|
|
906
|
+
}
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
// src/device/device-identity.ts
|
|
910
|
+
var import_core3 = require("@korajs/core");
|
|
911
|
+
var CryptoUnavailableError = class extends import_core3.KoraError {
|
|
912
|
+
constructor() {
|
|
913
|
+
super(
|
|
914
|
+
"Web Crypto API (crypto.subtle) is not available in this environment. Device identity requires crypto.subtle, which is available in modern browsers and Node.js 20+. If running in SSR, ensure your runtime provides the Web Crypto API.",
|
|
915
|
+
"CRYPTO_UNAVAILABLE"
|
|
916
|
+
);
|
|
917
|
+
this.name = "CryptoUnavailableError";
|
|
918
|
+
}
|
|
919
|
+
};
|
|
920
|
+
var DeviceIdentityError = class extends import_core3.KoraError {
|
|
921
|
+
constructor(message, context) {
|
|
922
|
+
super(message, "DEVICE_IDENTITY_ERROR", context);
|
|
923
|
+
this.name = "DeviceIdentityError";
|
|
924
|
+
}
|
|
925
|
+
};
|
|
926
|
+
function toBase64Url(buffer) {
|
|
927
|
+
const bytes = new Uint8Array(buffer);
|
|
928
|
+
let binary = "";
|
|
929
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
930
|
+
binary += String.fromCharCode(bytes[i]);
|
|
931
|
+
}
|
|
932
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
933
|
+
}
|
|
934
|
+
function fromBase64Url(str) {
|
|
935
|
+
let base64 = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
936
|
+
const paddingNeeded = (4 - base64.length % 4) % 4;
|
|
937
|
+
base64 += "=".repeat(paddingNeeded);
|
|
938
|
+
const binary = atob(base64);
|
|
939
|
+
const bytes = new Uint8Array(binary.length);
|
|
940
|
+
for (let i = 0; i < binary.length; i++) {
|
|
941
|
+
bytes[i] = binary.charCodeAt(i);
|
|
942
|
+
}
|
|
943
|
+
return bytes;
|
|
944
|
+
}
|
|
945
|
+
function assertCryptoAvailable() {
|
|
946
|
+
if (typeof globalThis.crypto === "undefined" || typeof globalThis.crypto.subtle === "undefined") {
|
|
947
|
+
throw new CryptoUnavailableError();
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
var ECDSA_ALGORITHM = {
|
|
951
|
+
name: "ECDSA",
|
|
952
|
+
namedCurve: "P-256"
|
|
953
|
+
};
|
|
954
|
+
var ECDSA_SIGN_ALGORITHM = {
|
|
955
|
+
name: "ECDSA",
|
|
956
|
+
hash: { name: "SHA-256" }
|
|
957
|
+
};
|
|
958
|
+
async function verifyChallenge(publicKeyJwk, challenge, signature) {
|
|
959
|
+
assertCryptoAvailable();
|
|
960
|
+
try {
|
|
961
|
+
const publicKey = await globalThis.crypto.subtle.importKey(
|
|
962
|
+
"jwk",
|
|
963
|
+
publicKeyJwk,
|
|
964
|
+
ECDSA_ALGORITHM,
|
|
965
|
+
true,
|
|
966
|
+
["verify"]
|
|
967
|
+
);
|
|
968
|
+
const encoded = new TextEncoder().encode(challenge);
|
|
969
|
+
const signatureBytes = fromBase64Url(signature);
|
|
970
|
+
const isValid = await globalThis.crypto.subtle.verify(
|
|
971
|
+
ECDSA_SIGN_ALGORITHM,
|
|
972
|
+
publicKey,
|
|
973
|
+
signatureBytes,
|
|
974
|
+
encoded
|
|
975
|
+
);
|
|
976
|
+
return isValid;
|
|
977
|
+
} catch (cause) {
|
|
978
|
+
throw new DeviceIdentityError(
|
|
979
|
+
"Failed to verify challenge signature. The public key JWK or signature format may be invalid.",
|
|
980
|
+
{
|
|
981
|
+
cause: cause instanceof Error ? cause.message : String(cause),
|
|
982
|
+
publicKeyKty: publicKeyJwk.kty,
|
|
983
|
+
publicKeyCrv: publicKeyJwk.crv
|
|
984
|
+
}
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
async function computePublicKeyThumbprint(publicKeyJwk) {
|
|
989
|
+
assertCryptoAvailable();
|
|
990
|
+
if (publicKeyJwk.kty !== "EC") {
|
|
991
|
+
throw new DeviceIdentityError(
|
|
992
|
+
`Expected JWK key type "EC" but received "${publicKeyJwk.kty ?? "undefined"}". Only ECDSA public keys are supported for device identity.`,
|
|
993
|
+
{ kty: publicKeyJwk.kty }
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
if (!publicKeyJwk.crv || !publicKeyJwk.x || !publicKeyJwk.y) {
|
|
997
|
+
throw new DeviceIdentityError(
|
|
998
|
+
'JWK is missing required EC fields. An EC public key JWK must include "crv", "x", and "y" members.',
|
|
999
|
+
{
|
|
1000
|
+
hasCrv: Boolean(publicKeyJwk.crv),
|
|
1001
|
+
hasX: Boolean(publicKeyJwk.x),
|
|
1002
|
+
hasY: Boolean(publicKeyJwk.y)
|
|
1003
|
+
}
|
|
1004
|
+
);
|
|
1005
|
+
}
|
|
1006
|
+
const canonicalJson = JSON.stringify({
|
|
1007
|
+
crv: publicKeyJwk.crv,
|
|
1008
|
+
kty: publicKeyJwk.kty,
|
|
1009
|
+
x: publicKeyJwk.x,
|
|
1010
|
+
y: publicKeyJwk.y
|
|
1011
|
+
});
|
|
1012
|
+
try {
|
|
1013
|
+
const encoded = new TextEncoder().encode(canonicalJson);
|
|
1014
|
+
const hashBuffer = await globalThis.crypto.subtle.digest("SHA-256", encoded);
|
|
1015
|
+
return toBase64Url(hashBuffer);
|
|
1016
|
+
} catch (cause) {
|
|
1017
|
+
throw new DeviceIdentityError(
|
|
1018
|
+
"Failed to compute SHA-256 thumbprint of the public key JWK.",
|
|
1019
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) }
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1024
|
+
0 && (module.exports = {
|
|
1025
|
+
AuthProviderError,
|
|
1026
|
+
BuiltInAuthRoutes,
|
|
1027
|
+
BuiltInProvider,
|
|
1028
|
+
DuplicateEmailError,
|
|
1029
|
+
InMemoryUserStore,
|
|
1030
|
+
TokenManager,
|
|
1031
|
+
computePublicKeyThumbprint,
|
|
1032
|
+
decodeJwt,
|
|
1033
|
+
encodeJwt,
|
|
1034
|
+
hashPassword,
|
|
1035
|
+
isExpired,
|
|
1036
|
+
verifyChallenge,
|
|
1037
|
+
verifyJwt,
|
|
1038
|
+
verifyPassword
|
|
1039
|
+
});
|
|
1040
|
+
//# sourceMappingURL=server.cjs.map
|