@korajs/auth 0.1.0 → 0.3.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 +69 -30
- package/dist/chunk-FSU4SK32.js +786 -0
- package/dist/chunk-FSU4SK32.js.map +1 -0
- package/dist/chunk-HOZXDR6Y.js +52 -0
- package/dist/chunk-HOZXDR6Y.js.map +1 -0
- package/dist/index.cjs +1538 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +708 -5
- package/dist/index.d.ts +708 -5
- package/dist/index.js +926 -3
- package/dist/index.js.map +1 -1
- package/dist/{device-identity-DiwdLsUB.d.cts → operation-encryptor-DRmKNWpF.d.cts} +148 -2
- package/dist/{device-identity-DiwdLsUB.d.ts → operation-encryptor-DRmKNWpF.d.ts} +148 -2
- package/dist/{auth-client-CrDNuh10.d.cts → org-client-BVTLKcIk.d.cts} +177 -3
- package/dist/{auth-client-CrDNuh10.d.ts → org-client-BVTLKcIk.d.ts} +177 -3
- package/dist/password-hash-HDH6VQCQ.js +9 -0
- package/dist/password-hash-HDH6VQCQ.js.map +1 -0
- package/dist/react.cjs +183 -2
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +107 -2
- package/dist/react.d.ts +107 -2
- package/dist/react.js +186 -1
- package/dist/react.js.map +1 -1
- package/dist/server.cjs +4945 -224
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +3039 -29
- package/dist/server.d.ts +3039 -29
- package/dist/server.js +4272 -92
- package/dist/server.js.map +1 -1
- package/package.json +5 -5
- package/dist/chunk-L554ZDPY.js +0 -174
- package/dist/chunk-L554ZDPY.js.map +0 -1
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
// src/device/device-identity.ts
|
|
2
|
+
import { KoraError } from "@korajs/core";
|
|
3
|
+
var CryptoUnavailableError = class extends KoraError {
|
|
4
|
+
constructor() {
|
|
5
|
+
super(
|
|
6
|
+
"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.",
|
|
7
|
+
"CRYPTO_UNAVAILABLE"
|
|
8
|
+
);
|
|
9
|
+
this.name = "CryptoUnavailableError";
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
var DeviceIdentityError = class extends KoraError {
|
|
13
|
+
constructor(message, context) {
|
|
14
|
+
super(message, "DEVICE_IDENTITY_ERROR", context);
|
|
15
|
+
this.name = "DeviceIdentityError";
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
function toBase64Url(buffer) {
|
|
19
|
+
const bytes = new Uint8Array(buffer);
|
|
20
|
+
let binary = "";
|
|
21
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
22
|
+
binary += String.fromCharCode(bytes[i]);
|
|
23
|
+
}
|
|
24
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
25
|
+
}
|
|
26
|
+
function fromBase64Url(str) {
|
|
27
|
+
let base64 = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
28
|
+
const paddingNeeded = (4 - base64.length % 4) % 4;
|
|
29
|
+
base64 += "=".repeat(paddingNeeded);
|
|
30
|
+
const binary = atob(base64);
|
|
31
|
+
const bytes = new Uint8Array(binary.length);
|
|
32
|
+
for (let i = 0; i < binary.length; i++) {
|
|
33
|
+
bytes[i] = binary.charCodeAt(i);
|
|
34
|
+
}
|
|
35
|
+
return bytes;
|
|
36
|
+
}
|
|
37
|
+
function assertCryptoAvailable() {
|
|
38
|
+
if (typeof globalThis.crypto === "undefined" || typeof globalThis.crypto.subtle === "undefined") {
|
|
39
|
+
throw new CryptoUnavailableError();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
var ECDSA_ALGORITHM = {
|
|
43
|
+
name: "ECDSA",
|
|
44
|
+
namedCurve: "P-256"
|
|
45
|
+
};
|
|
46
|
+
var ECDSA_SIGN_ALGORITHM = {
|
|
47
|
+
name: "ECDSA",
|
|
48
|
+
hash: { name: "SHA-256" }
|
|
49
|
+
};
|
|
50
|
+
async function generateDeviceKeyPair() {
|
|
51
|
+
assertCryptoAvailable();
|
|
52
|
+
try {
|
|
53
|
+
const keyPair = await globalThis.crypto.subtle.generateKey(
|
|
54
|
+
ECDSA_ALGORITHM,
|
|
55
|
+
// extractable: false makes the private key non-extractable.
|
|
56
|
+
// The public key is always extractable regardless of this flag.
|
|
57
|
+
false,
|
|
58
|
+
["sign", "verify"]
|
|
59
|
+
);
|
|
60
|
+
return keyPair;
|
|
61
|
+
} catch (cause) {
|
|
62
|
+
throw new DeviceIdentityError(
|
|
63
|
+
"Failed to generate ECDSA P-256 device key pair. Ensure the runtime supports the ECDSA algorithm with the P-256 curve.",
|
|
64
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) }
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function exportPublicKeyJwk(keyPair) {
|
|
69
|
+
assertCryptoAvailable();
|
|
70
|
+
try {
|
|
71
|
+
const jwk = await globalThis.crypto.subtle.exportKey("jwk", keyPair.publicKey);
|
|
72
|
+
return jwk;
|
|
73
|
+
} catch (cause) {
|
|
74
|
+
throw new DeviceIdentityError(
|
|
75
|
+
"Failed to export public key as JWK. The key pair may be invalid or the public key may not support JWK export.",
|
|
76
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) }
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function signChallenge(privateKey, challenge) {
|
|
81
|
+
assertCryptoAvailable();
|
|
82
|
+
try {
|
|
83
|
+
const encoded = new TextEncoder().encode(challenge);
|
|
84
|
+
const signatureBuffer = await globalThis.crypto.subtle.sign(
|
|
85
|
+
ECDSA_SIGN_ALGORITHM,
|
|
86
|
+
privateKey,
|
|
87
|
+
encoded
|
|
88
|
+
);
|
|
89
|
+
return toBase64Url(signatureBuffer);
|
|
90
|
+
} catch (cause) {
|
|
91
|
+
throw new DeviceIdentityError(
|
|
92
|
+
'Failed to sign challenge. Ensure the key is a valid ECDSA P-256 private key with "sign" usage.',
|
|
93
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) }
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function verifyChallenge(publicKeyJwk, challenge, signature) {
|
|
98
|
+
assertCryptoAvailable();
|
|
99
|
+
try {
|
|
100
|
+
const publicKey = await globalThis.crypto.subtle.importKey(
|
|
101
|
+
"jwk",
|
|
102
|
+
publicKeyJwk,
|
|
103
|
+
ECDSA_ALGORITHM,
|
|
104
|
+
true,
|
|
105
|
+
["verify"]
|
|
106
|
+
);
|
|
107
|
+
const encoded = new TextEncoder().encode(challenge);
|
|
108
|
+
const signatureBytes = fromBase64Url(signature);
|
|
109
|
+
const isValid = await globalThis.crypto.subtle.verify(
|
|
110
|
+
ECDSA_SIGN_ALGORITHM,
|
|
111
|
+
publicKey,
|
|
112
|
+
signatureBytes,
|
|
113
|
+
encoded
|
|
114
|
+
);
|
|
115
|
+
return isValid;
|
|
116
|
+
} catch (cause) {
|
|
117
|
+
throw new DeviceIdentityError(
|
|
118
|
+
"Failed to verify challenge signature. The public key JWK or signature format may be invalid.",
|
|
119
|
+
{
|
|
120
|
+
cause: cause instanceof Error ? cause.message : String(cause),
|
|
121
|
+
publicKeyKty: publicKeyJwk.kty,
|
|
122
|
+
publicKeyCrv: publicKeyJwk.crv
|
|
123
|
+
}
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function computePublicKeyThumbprint(publicKeyJwk) {
|
|
128
|
+
assertCryptoAvailable();
|
|
129
|
+
if (publicKeyJwk.kty !== "EC") {
|
|
130
|
+
throw new DeviceIdentityError(
|
|
131
|
+
`Expected JWK key type "EC" but received "${publicKeyJwk.kty ?? "undefined"}". Only ECDSA public keys are supported for device identity.`,
|
|
132
|
+
{ kty: publicKeyJwk.kty }
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
if (!publicKeyJwk.crv || !publicKeyJwk.x || !publicKeyJwk.y) {
|
|
136
|
+
throw new DeviceIdentityError(
|
|
137
|
+
'JWK is missing required EC fields. An EC public key JWK must include "crv", "x", and "y" members.',
|
|
138
|
+
{
|
|
139
|
+
hasCrv: Boolean(publicKeyJwk.crv),
|
|
140
|
+
hasX: Boolean(publicKeyJwk.x),
|
|
141
|
+
hasY: Boolean(publicKeyJwk.y)
|
|
142
|
+
}
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const canonicalJson = JSON.stringify({
|
|
146
|
+
crv: publicKeyJwk.crv,
|
|
147
|
+
kty: publicKeyJwk.kty,
|
|
148
|
+
x: publicKeyJwk.x,
|
|
149
|
+
y: publicKeyJwk.y
|
|
150
|
+
});
|
|
151
|
+
try {
|
|
152
|
+
const encoded = new TextEncoder().encode(canonicalJson);
|
|
153
|
+
const hashBuffer = await globalThis.crypto.subtle.digest("SHA-256", encoded);
|
|
154
|
+
return toBase64Url(hashBuffer);
|
|
155
|
+
} catch (cause) {
|
|
156
|
+
throw new DeviceIdentityError(
|
|
157
|
+
"Failed to compute SHA-256 thumbprint of the public key JWK.",
|
|
158
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) }
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// src/encryption/database-encryption.ts
|
|
164
|
+
import { KoraError as KoraError2 } from "@korajs/core";
|
|
165
|
+
var EncryptionError = class extends KoraError2 {
|
|
166
|
+
constructor(message, context) {
|
|
167
|
+
super(message, "ENCRYPTION_ERROR", context);
|
|
168
|
+
this.name = "EncryptionError";
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
var CryptoUnavailableError2 = class extends KoraError2 {
|
|
172
|
+
constructor() {
|
|
173
|
+
super(
|
|
174
|
+
"Web Crypto API (crypto.subtle) is not available in this environment. Database encryption 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.",
|
|
175
|
+
"CRYPTO_UNAVAILABLE"
|
|
176
|
+
);
|
|
177
|
+
this.name = "CryptoUnavailableError";
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
var AES_GCM = "AES-GCM";
|
|
181
|
+
var AES_KEY_LENGTH = 256;
|
|
182
|
+
var IV_LENGTH = 12;
|
|
183
|
+
function assertCryptoAvailable2() {
|
|
184
|
+
if (typeof globalThis.crypto === "undefined" || typeof globalThis.crypto.subtle === "undefined") {
|
|
185
|
+
throw new CryptoUnavailableError2();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
async function generateEncryptionKey() {
|
|
189
|
+
assertCryptoAvailable2();
|
|
190
|
+
try {
|
|
191
|
+
const key = await globalThis.crypto.subtle.generateKey(
|
|
192
|
+
{ name: AES_GCM, length: AES_KEY_LENGTH },
|
|
193
|
+
// extractable: true so the key can be exported and persisted
|
|
194
|
+
true,
|
|
195
|
+
["encrypt", "decrypt"]
|
|
196
|
+
);
|
|
197
|
+
return key;
|
|
198
|
+
} catch (cause) {
|
|
199
|
+
throw new EncryptionError(
|
|
200
|
+
"Failed to generate AES-256-GCM encryption key. Ensure the runtime supports the AES-GCM algorithm.",
|
|
201
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) }
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async function encryptData(key, plaintext) {
|
|
206
|
+
assertCryptoAvailable2();
|
|
207
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
208
|
+
try {
|
|
209
|
+
const ciphertextBuffer = await globalThis.crypto.subtle.encrypt(
|
|
210
|
+
{ name: AES_GCM, iv },
|
|
211
|
+
key,
|
|
212
|
+
plaintext
|
|
213
|
+
);
|
|
214
|
+
return {
|
|
215
|
+
ciphertext: new Uint8Array(ciphertextBuffer),
|
|
216
|
+
iv
|
|
217
|
+
};
|
|
218
|
+
} catch (cause) {
|
|
219
|
+
throw new EncryptionError(
|
|
220
|
+
'Failed to encrypt data with AES-256-GCM. Ensure the key is a valid AES-GCM CryptoKey with "encrypt" usage.',
|
|
221
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) }
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async function decryptData(key, ciphertext, iv) {
|
|
226
|
+
assertCryptoAvailable2();
|
|
227
|
+
try {
|
|
228
|
+
const plaintextBuffer = await globalThis.crypto.subtle.decrypt(
|
|
229
|
+
{ name: AES_GCM, iv },
|
|
230
|
+
key,
|
|
231
|
+
ciphertext
|
|
232
|
+
);
|
|
233
|
+
return new Uint8Array(plaintextBuffer);
|
|
234
|
+
} catch (cause) {
|
|
235
|
+
throw new EncryptionError(
|
|
236
|
+
"Failed to decrypt data with AES-256-GCM. This may indicate a wrong key, tampered ciphertext, or incorrect IV.",
|
|
237
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) }
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
async function exportKey(key) {
|
|
242
|
+
assertCryptoAvailable2();
|
|
243
|
+
try {
|
|
244
|
+
const rawBuffer = await globalThis.crypto.subtle.exportKey("raw", key);
|
|
245
|
+
return new Uint8Array(rawBuffer);
|
|
246
|
+
} catch (cause) {
|
|
247
|
+
throw new EncryptionError(
|
|
248
|
+
"Failed to export AES-256-GCM key. The key may not be extractable. Only keys generated with extractable=true can be exported.",
|
|
249
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) }
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async function importKey(rawKey) {
|
|
254
|
+
assertCryptoAvailable2();
|
|
255
|
+
if (rawKey.length !== 32) {
|
|
256
|
+
throw new EncryptionError(
|
|
257
|
+
`Invalid key length: expected 32 bytes (256 bits) for AES-256, but received ${rawKey.length} bytes.`,
|
|
258
|
+
{ actualLength: rawKey.length, expectedLength: 32 }
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
263
|
+
"raw",
|
|
264
|
+
rawKey,
|
|
265
|
+
{ name: AES_GCM, length: AES_KEY_LENGTH },
|
|
266
|
+
true,
|
|
267
|
+
["encrypt", "decrypt"]
|
|
268
|
+
);
|
|
269
|
+
return key;
|
|
270
|
+
} catch (cause) {
|
|
271
|
+
throw new EncryptionError(
|
|
272
|
+
"Failed to import raw key bytes as AES-256-GCM key. Ensure the key material is valid.",
|
|
273
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) }
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// src/passkey/passkey-client.ts
|
|
279
|
+
import { KoraError as KoraError3 } from "@korajs/core";
|
|
280
|
+
var PasskeyError = class extends KoraError3 {
|
|
281
|
+
constructor(message, context) {
|
|
282
|
+
super(message, "PASSKEY_ERROR", context);
|
|
283
|
+
this.name = "PasskeyError";
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
var PasskeyUnsupportedError = class extends KoraError3 {
|
|
287
|
+
constructor() {
|
|
288
|
+
super(
|
|
289
|
+
"WebAuthn is not supported in this browser. Passkey authentication requires a modern browser with WebAuthn support.",
|
|
290
|
+
"PASSKEY_UNSUPPORTED"
|
|
291
|
+
);
|
|
292
|
+
this.name = "PasskeyUnsupportedError";
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
function isPasskeySupported() {
|
|
296
|
+
return typeof globalThis.navigator !== "undefined" && typeof globalThis.navigator.credentials !== "undefined" && typeof globalThis.navigator.credentials.create === "function" && typeof globalThis.navigator.credentials.get === "function";
|
|
297
|
+
}
|
|
298
|
+
async function isPlatformAuthenticatorAvailable() {
|
|
299
|
+
if (!isPasskeySupported()) {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
if (typeof PublicKeyCredential !== "undefined" && typeof PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === "function") {
|
|
303
|
+
try {
|
|
304
|
+
return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
|
305
|
+
} catch {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
async function createPasskeyCredential(options) {
|
|
312
|
+
if (!isPasskeySupported()) {
|
|
313
|
+
throw new PasskeyUnsupportedError();
|
|
314
|
+
}
|
|
315
|
+
const excludeCredentials = (options.excludeCredentialIds ?? []).map((id) => ({
|
|
316
|
+
type: "public-key",
|
|
317
|
+
id: fromBase64Url(id).buffer
|
|
318
|
+
}));
|
|
319
|
+
const authenticatorSelection = {
|
|
320
|
+
authenticatorAttachment: options.authenticatorSelection?.authenticatorAttachment ?? "platform",
|
|
321
|
+
residentKey: options.authenticatorSelection?.residentKey ?? "preferred",
|
|
322
|
+
userVerification: options.authenticatorSelection?.userVerification ?? "required"
|
|
323
|
+
};
|
|
324
|
+
if (authenticatorSelection.residentKey === "required") {
|
|
325
|
+
authenticatorSelection.requireResidentKey = true;
|
|
326
|
+
}
|
|
327
|
+
const publicKeyOptions = {
|
|
328
|
+
challenge: fromBase64Url(options.challenge).buffer,
|
|
329
|
+
rp: {
|
|
330
|
+
id: options.rpId,
|
|
331
|
+
name: options.rpName
|
|
332
|
+
},
|
|
333
|
+
user: {
|
|
334
|
+
id: fromBase64Url(options.userId).buffer,
|
|
335
|
+
name: options.userName,
|
|
336
|
+
displayName: options.userDisplayName
|
|
337
|
+
},
|
|
338
|
+
pubKeyCredParams: [
|
|
339
|
+
// ES256 (ECDSA w/ SHA-256) — the most widely supported algorithm
|
|
340
|
+
{ type: "public-key", alg: -7 },
|
|
341
|
+
// RS256 (RSASSA-PKCS1-v1_5 w/ SHA-256) — fallback for older authenticators
|
|
342
|
+
{ type: "public-key", alg: -257 }
|
|
343
|
+
],
|
|
344
|
+
excludeCredentials,
|
|
345
|
+
authenticatorSelection,
|
|
346
|
+
timeout: 6e4,
|
|
347
|
+
attestation: "none"
|
|
348
|
+
};
|
|
349
|
+
let credential;
|
|
350
|
+
try {
|
|
351
|
+
const result = await navigator.credentials.create({
|
|
352
|
+
publicKey: publicKeyOptions
|
|
353
|
+
});
|
|
354
|
+
if (result === null) {
|
|
355
|
+
throw new PasskeyError(
|
|
356
|
+
"Credential creation returned null. The user may have cancelled the operation.",
|
|
357
|
+
{ rpId: options.rpId }
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
credential = result;
|
|
361
|
+
} catch (error) {
|
|
362
|
+
if (error instanceof PasskeyError) {
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
365
|
+
const domError = error;
|
|
366
|
+
if (domError.name === "NotAllowedError") {
|
|
367
|
+
throw new PasskeyError(
|
|
368
|
+
"Passkey creation was cancelled or not allowed. The user may have dismissed the prompt or the operation timed out.",
|
|
369
|
+
{ rpId: options.rpId, errorName: domError.name }
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
if (domError.name === "InvalidStateError") {
|
|
373
|
+
throw new PasskeyError(
|
|
374
|
+
"A passkey already exists for this user on this authenticator. Use the existing passkey to sign in.",
|
|
375
|
+
{ rpId: options.rpId, errorName: domError.name }
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
throw new PasskeyError(
|
|
379
|
+
`Passkey creation failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
380
|
+
{
|
|
381
|
+
rpId: options.rpId,
|
|
382
|
+
errorName: error instanceof Error ? error.name : void 0
|
|
383
|
+
}
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
const response = credential.response;
|
|
387
|
+
const attestationObject = new Uint8Array(response.attestationObject);
|
|
388
|
+
const clientDataJSON = new Uint8Array(response.clientDataJSON);
|
|
389
|
+
const publicKeyBytes = extractPublicKeyFromAttestationObject(attestationObject);
|
|
390
|
+
return {
|
|
391
|
+
credentialId: toBase64Url(credential.rawId),
|
|
392
|
+
publicKey: toBase64Url(publicKeyBytes.buffer),
|
|
393
|
+
clientDataJSON: toBase64Url(clientDataJSON.buffer),
|
|
394
|
+
attestationObject: toBase64Url(attestationObject.buffer)
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
async function authenticateWithPasskey(options) {
|
|
398
|
+
if (!isPasskeySupported()) {
|
|
399
|
+
throw new PasskeyUnsupportedError();
|
|
400
|
+
}
|
|
401
|
+
const allowCredentials = options.allowCredentialIds?.map((id) => ({
|
|
402
|
+
type: "public-key",
|
|
403
|
+
id: fromBase64Url(id).buffer
|
|
404
|
+
}));
|
|
405
|
+
const publicKeyOptions = {
|
|
406
|
+
challenge: fromBase64Url(options.challenge).buffer,
|
|
407
|
+
rpId: options.rpId,
|
|
408
|
+
allowCredentials,
|
|
409
|
+
userVerification: options.userVerification ?? "preferred",
|
|
410
|
+
timeout: options.timeout ?? 6e4
|
|
411
|
+
};
|
|
412
|
+
let credential;
|
|
413
|
+
try {
|
|
414
|
+
const result = await navigator.credentials.get({
|
|
415
|
+
publicKey: publicKeyOptions
|
|
416
|
+
});
|
|
417
|
+
if (result === null) {
|
|
418
|
+
throw new PasskeyError(
|
|
419
|
+
"Authentication returned null. The user may have cancelled the operation.",
|
|
420
|
+
{ rpId: options.rpId }
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
credential = result;
|
|
424
|
+
} catch (error) {
|
|
425
|
+
if (error instanceof PasskeyError) {
|
|
426
|
+
throw error;
|
|
427
|
+
}
|
|
428
|
+
const domError = error;
|
|
429
|
+
if (domError.name === "NotAllowedError") {
|
|
430
|
+
throw new PasskeyError(
|
|
431
|
+
"Passkey authentication was cancelled or not allowed. The user may have dismissed the prompt or the operation timed out.",
|
|
432
|
+
{ rpId: options.rpId, errorName: domError.name }
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
throw new PasskeyError(
|
|
436
|
+
`Passkey authentication failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
437
|
+
{
|
|
438
|
+
rpId: options.rpId,
|
|
439
|
+
errorName: error instanceof Error ? error.name : void 0
|
|
440
|
+
}
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
const response = credential.response;
|
|
444
|
+
const authenticatorData = new Uint8Array(response.authenticatorData);
|
|
445
|
+
const clientDataJSON = new Uint8Array(response.clientDataJSON);
|
|
446
|
+
const signature = new Uint8Array(response.signature);
|
|
447
|
+
const userHandle = response.userHandle !== null && response.userHandle.byteLength > 0 ? toBase64Url(response.userHandle) : null;
|
|
448
|
+
return {
|
|
449
|
+
credentialId: toBase64Url(credential.rawId),
|
|
450
|
+
authenticatorData: toBase64Url(authenticatorData.buffer),
|
|
451
|
+
clientDataJSON: toBase64Url(clientDataJSON.buffer),
|
|
452
|
+
signature: toBase64Url(signature.buffer),
|
|
453
|
+
userHandle
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
function extractPublicKeyFromAttestationObject(attestationObject) {
|
|
457
|
+
const decoded = decodeCbor(attestationObject, 0);
|
|
458
|
+
const topMap = decoded.value;
|
|
459
|
+
const authData = topMap.get("authData");
|
|
460
|
+
if (!(authData instanceof Uint8Array)) {
|
|
461
|
+
throw new PasskeyError(
|
|
462
|
+
"Invalid attestation object: authData is missing or not a byte string."
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
let offset = 0;
|
|
466
|
+
offset += 32;
|
|
467
|
+
const flags = authData[offset];
|
|
468
|
+
offset += 1;
|
|
469
|
+
offset += 4;
|
|
470
|
+
const hasAttestedCredentialData = (flags & 64) !== 0;
|
|
471
|
+
if (!hasAttestedCredentialData) {
|
|
472
|
+
throw new PasskeyError(
|
|
473
|
+
"Attestation object does not contain attested credential data. The authenticator did not include a public key."
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
offset += 16;
|
|
477
|
+
const credentialIdLength = authData[offset] << 8 | authData[offset + 1];
|
|
478
|
+
offset += 2;
|
|
479
|
+
offset += credentialIdLength;
|
|
480
|
+
const coseKeyResult = decodeCbor(authData, offset);
|
|
481
|
+
const coseKeyLength = coseKeyResult.offset - offset;
|
|
482
|
+
return authData.slice(offset, offset + coseKeyLength);
|
|
483
|
+
}
|
|
484
|
+
function decodeCbor(data, offset) {
|
|
485
|
+
if (offset >= data.length) {
|
|
486
|
+
throw new PasskeyError("CBOR decode error: unexpected end of data.");
|
|
487
|
+
}
|
|
488
|
+
const initialByte = data[offset];
|
|
489
|
+
const majorType = initialByte >> 5;
|
|
490
|
+
const additionalInfo = initialByte & 31;
|
|
491
|
+
offset += 1;
|
|
492
|
+
let argument;
|
|
493
|
+
if (additionalInfo < 24) {
|
|
494
|
+
argument = additionalInfo;
|
|
495
|
+
} else if (additionalInfo === 24) {
|
|
496
|
+
argument = data[offset];
|
|
497
|
+
offset += 1;
|
|
498
|
+
} else if (additionalInfo === 25) {
|
|
499
|
+
argument = data[offset] << 8 | data[offset + 1];
|
|
500
|
+
offset += 2;
|
|
501
|
+
} else if (additionalInfo === 26) {
|
|
502
|
+
argument = data[offset] << 24 | data[offset + 1] << 16 | data[offset + 2] << 8 | data[offset + 3];
|
|
503
|
+
argument = argument >>> 0;
|
|
504
|
+
offset += 4;
|
|
505
|
+
} else {
|
|
506
|
+
throw new PasskeyError(
|
|
507
|
+
`CBOR decode error: unsupported additional info ${additionalInfo} at byte ${offset - 1}. This CBOR decoder only supports definite-length encodings.`
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
switch (majorType) {
|
|
511
|
+
// Major type 0: Unsigned integer
|
|
512
|
+
case 0:
|
|
513
|
+
return { value: argument, offset };
|
|
514
|
+
// Major type 1: Negative integer (-1 - argument)
|
|
515
|
+
case 1:
|
|
516
|
+
return { value: -1 - argument, offset };
|
|
517
|
+
// Major type 2: Byte string
|
|
518
|
+
case 2: {
|
|
519
|
+
const bytes = data.slice(offset, offset + argument);
|
|
520
|
+
return { value: bytes, offset: offset + argument };
|
|
521
|
+
}
|
|
522
|
+
// Major type 3: Text string (UTF-8)
|
|
523
|
+
case 3: {
|
|
524
|
+
const textBytes = data.slice(offset, offset + argument);
|
|
525
|
+
const text = new TextDecoder().decode(textBytes);
|
|
526
|
+
return { value: text, offset: offset + argument };
|
|
527
|
+
}
|
|
528
|
+
// Major type 4: Array
|
|
529
|
+
case 4: {
|
|
530
|
+
const arr = [];
|
|
531
|
+
let currentOffset = offset;
|
|
532
|
+
for (let i = 0; i < argument; i++) {
|
|
533
|
+
const item = decodeCbor(data, currentOffset);
|
|
534
|
+
arr.push(item.value);
|
|
535
|
+
currentOffset = item.offset;
|
|
536
|
+
}
|
|
537
|
+
return { value: arr, offset: currentOffset };
|
|
538
|
+
}
|
|
539
|
+
// Major type 5: Map
|
|
540
|
+
case 5: {
|
|
541
|
+
const map = /* @__PURE__ */ new Map();
|
|
542
|
+
let currentOffset = offset;
|
|
543
|
+
for (let i = 0; i < argument; i++) {
|
|
544
|
+
const keyResult = decodeCbor(data, currentOffset);
|
|
545
|
+
const valResult = decodeCbor(data, keyResult.offset);
|
|
546
|
+
map.set(keyResult.value, valResult.value);
|
|
547
|
+
currentOffset = valResult.offset;
|
|
548
|
+
}
|
|
549
|
+
return { value: map, offset: currentOffset };
|
|
550
|
+
}
|
|
551
|
+
default:
|
|
552
|
+
throw new PasskeyError(
|
|
553
|
+
`CBOR decode error: unsupported major type ${majorType} at byte ${offset - 1}. This CBOR decoder only supports types 0-5 (integers, byte/text strings, arrays, maps).`
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// src/encryption/operation-encryptor.ts
|
|
559
|
+
import { KoraError as KoraError4 } from "@korajs/core";
|
|
560
|
+
var ENCRYPTED_MARKER = "__kora_encrypted";
|
|
561
|
+
var ENCRYPTION_VERSION = 1;
|
|
562
|
+
var OperationEncryptionError = class extends KoraError4 {
|
|
563
|
+
constructor(message, context) {
|
|
564
|
+
super(message, "OPERATION_ENCRYPTION_ERROR", context);
|
|
565
|
+
this.name = "OperationEncryptionError";
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
function toBase64Url2(bytes) {
|
|
569
|
+
let binary = "";
|
|
570
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
571
|
+
binary += String.fromCharCode(bytes[i]);
|
|
572
|
+
}
|
|
573
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
574
|
+
}
|
|
575
|
+
function fromBase64Url2(str) {
|
|
576
|
+
let base64 = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
577
|
+
const paddingNeeded = (4 - base64.length % 4) % 4;
|
|
578
|
+
base64 += "=".repeat(paddingNeeded);
|
|
579
|
+
const binary = atob(base64);
|
|
580
|
+
const bytes = new Uint8Array(binary.length);
|
|
581
|
+
for (let i = 0; i < binary.length; i++) {
|
|
582
|
+
bytes[i] = binary.charCodeAt(i);
|
|
583
|
+
}
|
|
584
|
+
return bytes;
|
|
585
|
+
}
|
|
586
|
+
var OperationEncryptor = class {
|
|
587
|
+
key;
|
|
588
|
+
constructor(config) {
|
|
589
|
+
this.key = config.key;
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Encrypt an operation's data fields.
|
|
593
|
+
*
|
|
594
|
+
* Returns a new Operation with `data` and `previousData` replaced by
|
|
595
|
+
* encrypted envelopes. The original operation is not mutated.
|
|
596
|
+
*
|
|
597
|
+
* If `data` or `previousData` is null (e.g., delete operations),
|
|
598
|
+
* that field remains null — there is nothing to encrypt.
|
|
599
|
+
*
|
|
600
|
+
* @param operation - The operation to encrypt
|
|
601
|
+
* @returns A new operation with encrypted data fields
|
|
602
|
+
* @throws {OperationEncryptionError} If encryption fails
|
|
603
|
+
*/
|
|
604
|
+
async encryptOperation(operation) {
|
|
605
|
+
const [encryptedData, encryptedPreviousData] = await Promise.all([
|
|
606
|
+
this.encryptField(operation.data, operation.id, "data"),
|
|
607
|
+
this.encryptField(operation.previousData, operation.id, "previousData")
|
|
608
|
+
]);
|
|
609
|
+
return {
|
|
610
|
+
...operation,
|
|
611
|
+
data: encryptedData,
|
|
612
|
+
previousData: encryptedPreviousData
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Decrypt an operation's data fields.
|
|
617
|
+
*
|
|
618
|
+
* Returns a new Operation with the original `data` and `previousData`
|
|
619
|
+
* restored from their encrypted envelopes. The original operation is
|
|
620
|
+
* not mutated.
|
|
621
|
+
*
|
|
622
|
+
* If a field is null or not encrypted (no marker), it passes through unchanged.
|
|
623
|
+
* This enables mixed plaintext/encrypted operations during migration.
|
|
624
|
+
*
|
|
625
|
+
* @param operation - The operation to decrypt
|
|
626
|
+
* @returns A new operation with decrypted data fields
|
|
627
|
+
* @throws {OperationEncryptionError} If decryption fails (wrong key, tampered data)
|
|
628
|
+
*/
|
|
629
|
+
async decryptOperation(operation) {
|
|
630
|
+
const [decryptedData, decryptedPreviousData] = await Promise.all([
|
|
631
|
+
this.decryptField(operation.data, operation.id, "data"),
|
|
632
|
+
this.decryptField(operation.previousData, operation.id, "previousData")
|
|
633
|
+
]);
|
|
634
|
+
return {
|
|
635
|
+
...operation,
|
|
636
|
+
data: decryptedData,
|
|
637
|
+
previousData: decryptedPreviousData
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Check if an operation's data fields are encrypted.
|
|
642
|
+
*
|
|
643
|
+
* Returns true if either `data` or `previousData` contains an encrypted
|
|
644
|
+
* envelope marker. Useful for determining whether decryption is needed
|
|
645
|
+
* before applying an operation.
|
|
646
|
+
*
|
|
647
|
+
* @param operation - The operation to check
|
|
648
|
+
* @returns true if any data field is encrypted
|
|
649
|
+
*/
|
|
650
|
+
isEncrypted(operation) {
|
|
651
|
+
return isEncryptedEnvelope(operation.data) || isEncryptedEnvelope(operation.previousData);
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Encrypt a batch of operations.
|
|
655
|
+
*
|
|
656
|
+
* Convenience method for encrypting multiple operations at once.
|
|
657
|
+
* Operations are encrypted in parallel for performance.
|
|
658
|
+
*
|
|
659
|
+
* @param operations - The operations to encrypt
|
|
660
|
+
* @returns New operations with encrypted data fields
|
|
661
|
+
*/
|
|
662
|
+
async encryptBatch(operations) {
|
|
663
|
+
return Promise.all(operations.map((op) => this.encryptOperation(op)));
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Decrypt a batch of operations.
|
|
667
|
+
*
|
|
668
|
+
* Convenience method for decrypting multiple operations at once.
|
|
669
|
+
* Operations are decrypted in parallel for performance.
|
|
670
|
+
*
|
|
671
|
+
* @param operations - The operations to decrypt
|
|
672
|
+
* @returns New operations with decrypted data fields
|
|
673
|
+
*/
|
|
674
|
+
async decryptBatch(operations) {
|
|
675
|
+
return Promise.all(operations.map((op) => this.decryptOperation(op)));
|
|
676
|
+
}
|
|
677
|
+
// --- Private helpers ---
|
|
678
|
+
async encryptField(field, operationId, fieldName) {
|
|
679
|
+
if (field === null) {
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(field));
|
|
683
|
+
try {
|
|
684
|
+
const { ciphertext, iv } = await encryptData(this.key, plaintext);
|
|
685
|
+
const envelope = {
|
|
686
|
+
[ENCRYPTED_MARKER]: true,
|
|
687
|
+
ciphertext: toBase64Url2(ciphertext),
|
|
688
|
+
iv: toBase64Url2(iv),
|
|
689
|
+
algorithm: "AES-256-GCM",
|
|
690
|
+
version: ENCRYPTION_VERSION
|
|
691
|
+
};
|
|
692
|
+
return envelope;
|
|
693
|
+
} catch (cause) {
|
|
694
|
+
if (cause instanceof OperationEncryptionError) {
|
|
695
|
+
throw cause;
|
|
696
|
+
}
|
|
697
|
+
throw new OperationEncryptionError(
|
|
698
|
+
`Failed to encrypt operation ${fieldName} field.`,
|
|
699
|
+
{
|
|
700
|
+
operationId,
|
|
701
|
+
fieldName,
|
|
702
|
+
cause: cause instanceof Error ? cause.message : String(cause)
|
|
703
|
+
}
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
async decryptField(field, operationId, fieldName) {
|
|
708
|
+
if (field === null) {
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
if (!isEncryptedEnvelope(field)) {
|
|
712
|
+
return field;
|
|
713
|
+
}
|
|
714
|
+
const envelope = field;
|
|
715
|
+
if (envelope.version > ENCRYPTION_VERSION) {
|
|
716
|
+
throw new OperationEncryptionError(
|
|
717
|
+
`Encrypted field uses version ${envelope.version}, but this client only supports version ${ENCRYPTION_VERSION}. Update your @korajs/auth package to decrypt this operation.`,
|
|
718
|
+
{ operationId, fieldName, version: envelope.version }
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
try {
|
|
722
|
+
const ciphertext = fromBase64Url2(envelope.ciphertext);
|
|
723
|
+
const iv = fromBase64Url2(envelope.iv);
|
|
724
|
+
const plaintextBytes = await decryptData(this.key, ciphertext, iv);
|
|
725
|
+
const json = new TextDecoder().decode(plaintextBytes);
|
|
726
|
+
const parsed = JSON.parse(json);
|
|
727
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
728
|
+
throw new OperationEncryptionError(
|
|
729
|
+
`Decrypted ${fieldName} is not a valid record object.`,
|
|
730
|
+
{ operationId, fieldName }
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
return parsed;
|
|
734
|
+
} catch (cause) {
|
|
735
|
+
if (cause instanceof OperationEncryptionError) {
|
|
736
|
+
throw cause;
|
|
737
|
+
}
|
|
738
|
+
throw new OperationEncryptionError(
|
|
739
|
+
`Failed to decrypt operation ${fieldName} field. This may indicate a wrong encryption key or tampered data.`,
|
|
740
|
+
{
|
|
741
|
+
operationId,
|
|
742
|
+
fieldName,
|
|
743
|
+
cause: cause instanceof Error ? cause.message : String(cause)
|
|
744
|
+
}
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
};
|
|
749
|
+
function isEncryptedField(field) {
|
|
750
|
+
return isEncryptedEnvelope(field);
|
|
751
|
+
}
|
|
752
|
+
function isEncryptedEnvelope(field) {
|
|
753
|
+
if (field === null || typeof field !== "object") {
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
return field[ENCRYPTED_MARKER] === true && typeof field["ciphertext"] === "string" && typeof field["iv"] === "string" && field["algorithm"] === "AES-256-GCM";
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
export {
|
|
760
|
+
CryptoUnavailableError,
|
|
761
|
+
DeviceIdentityError,
|
|
762
|
+
toBase64Url,
|
|
763
|
+
fromBase64Url,
|
|
764
|
+
generateDeviceKeyPair,
|
|
765
|
+
exportPublicKeyJwk,
|
|
766
|
+
signChallenge,
|
|
767
|
+
verifyChallenge,
|
|
768
|
+
computePublicKeyThumbprint,
|
|
769
|
+
EncryptionError,
|
|
770
|
+
generateEncryptionKey,
|
|
771
|
+
encryptData,
|
|
772
|
+
decryptData,
|
|
773
|
+
exportKey,
|
|
774
|
+
importKey,
|
|
775
|
+
PasskeyError,
|
|
776
|
+
PasskeyUnsupportedError,
|
|
777
|
+
isPasskeySupported,
|
|
778
|
+
isPlatformAuthenticatorAvailable,
|
|
779
|
+
createPasskeyCredential,
|
|
780
|
+
authenticateWithPasskey,
|
|
781
|
+
decodeCbor,
|
|
782
|
+
OperationEncryptionError,
|
|
783
|
+
OperationEncryptor,
|
|
784
|
+
isEncryptedField
|
|
785
|
+
};
|
|
786
|
+
//# sourceMappingURL=chunk-FSU4SK32.js.map
|