@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
package/dist/index.cjs
CHANGED
|
@@ -22,13 +22,40 @@ var index_exports = {};
|
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
AuthClient: () => AuthClient,
|
|
24
24
|
AuthError: () => AuthError,
|
|
25
|
+
AutoLockManager: () => AutoLockManager,
|
|
25
26
|
CryptoUnavailableError: () => CryptoUnavailableError,
|
|
26
27
|
DeviceIdentityError: () => DeviceIdentityError,
|
|
28
|
+
DeviceKeyStoreError: () => DeviceKeyStoreError,
|
|
29
|
+
EncryptedTokenStore: () => EncryptedTokenStore,
|
|
30
|
+
EncryptedTokenStoreError: () => EncryptedTokenStoreError,
|
|
31
|
+
EncryptionError: () => EncryptionError,
|
|
32
|
+
InMemoryDeviceKeyStore: () => InMemoryDeviceKeyStore,
|
|
33
|
+
IndexedDBDeviceKeyStore: () => IndexedDBDeviceKeyStore,
|
|
34
|
+
KeyDerivationError: () => KeyDerivationError,
|
|
35
|
+
OperationEncryptionError: () => OperationEncryptionError,
|
|
36
|
+
OperationEncryptor: () => OperationEncryptor,
|
|
37
|
+
OrgClient: () => OrgClient,
|
|
38
|
+
OrgClientError: () => OrgClientError,
|
|
39
|
+
PasskeyError: () => PasskeyError,
|
|
40
|
+
PasskeyUnsupportedError: () => PasskeyUnsupportedError,
|
|
27
41
|
TokenStore: () => TokenStore,
|
|
42
|
+
authenticateWithPasskey: () => authenticateWithPasskey,
|
|
28
43
|
computePublicKeyThumbprint: () => computePublicKeyThumbprint,
|
|
44
|
+
createDeviceKeyStore: () => createDeviceKeyStore,
|
|
45
|
+
createPasskeyCredential: () => createPasskeyCredential,
|
|
46
|
+
decryptData: () => decryptData,
|
|
47
|
+
deriveEncryptionKey: () => deriveEncryptionKey,
|
|
48
|
+
encryptData: () => encryptData,
|
|
49
|
+
exportKey: () => exportKey,
|
|
29
50
|
exportPublicKeyJwk: () => exportPublicKeyJwk,
|
|
30
51
|
fromBase64Url: () => fromBase64Url,
|
|
31
52
|
generateDeviceKeyPair: () => generateDeviceKeyPair,
|
|
53
|
+
generateEncryptionKey: () => generateEncryptionKey,
|
|
54
|
+
generateSalt: () => generateSalt,
|
|
55
|
+
importKey: () => importKey,
|
|
56
|
+
isEncryptedField: () => isEncryptedField,
|
|
57
|
+
isPasskeySupported: () => isPasskeySupported,
|
|
58
|
+
isPlatformAuthenticatorAvailable: () => isPlatformAuthenticatorAvailable,
|
|
32
59
|
signChallenge: () => signChallenge,
|
|
33
60
|
toBase64Url: () => toBase64Url,
|
|
34
61
|
verifyChallenge: () => verifyChallenge
|
|
@@ -129,6 +156,7 @@ var AuthClient = class {
|
|
|
129
156
|
_state = "loading";
|
|
130
157
|
_user = null;
|
|
131
158
|
_refreshPromise = null;
|
|
159
|
+
_initialized = false;
|
|
132
160
|
/**
|
|
133
161
|
* Creates a new AuthClient.
|
|
134
162
|
*
|
|
@@ -165,6 +193,10 @@ var AuthClient = class {
|
|
|
165
193
|
* Safe to call multiple times -- subsequent calls are no-ops once initialized.
|
|
166
194
|
*/
|
|
167
195
|
async initialize() {
|
|
196
|
+
if (this._initialized) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
this._initialized = true;
|
|
168
200
|
const accessToken = this.storage.getAccessToken();
|
|
169
201
|
const refreshToken = this.storage.getRefreshToken();
|
|
170
202
|
if (!accessToken || !refreshToken) {
|
|
@@ -226,13 +258,26 @@ var AuthClient = class {
|
|
|
226
258
|
/**
|
|
227
259
|
* Sign out the current user.
|
|
228
260
|
*
|
|
229
|
-
* Clears local tokens and
|
|
230
|
-
*
|
|
261
|
+
* Clears local tokens and attempts to revoke the refresh token on the server
|
|
262
|
+
* (best-effort — succeeds even if the server is unreachable). This ensures that
|
|
263
|
+
* stolen refresh tokens cannot be used after the user explicitly signs out.
|
|
231
264
|
*/
|
|
232
265
|
async signOut() {
|
|
266
|
+
const accessToken = this.storage.getAccessToken();
|
|
267
|
+
const refreshToken = this.storage.getRefreshToken();
|
|
233
268
|
this.storage.clear();
|
|
234
269
|
this._refreshPromise = null;
|
|
235
270
|
this.setState("unauthenticated", null);
|
|
271
|
+
if (accessToken) {
|
|
272
|
+
try {
|
|
273
|
+
await this.request("/auth/signout", {
|
|
274
|
+
method: "POST",
|
|
275
|
+
body: { refreshToken: refreshToken ?? void 0 },
|
|
276
|
+
token: accessToken
|
|
277
|
+
});
|
|
278
|
+
} catch {
|
|
279
|
+
}
|
|
280
|
+
}
|
|
236
281
|
}
|
|
237
282
|
// -----------------------------------------------------------------------
|
|
238
283
|
// Token access
|
|
@@ -439,9 +484,272 @@ var AuthClient = class {
|
|
|
439
484
|
}
|
|
440
485
|
};
|
|
441
486
|
|
|
442
|
-
// src/
|
|
487
|
+
// src/client/org-client.ts
|
|
443
488
|
var import_core2 = require("@korajs/core");
|
|
444
|
-
var
|
|
489
|
+
var OrgClientError = class extends import_core2.KoraError {
|
|
490
|
+
constructor(message, code, context) {
|
|
491
|
+
super(message, code, context);
|
|
492
|
+
this.name = "OrgClientError";
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
var OrgClient = class {
|
|
496
|
+
serverUrl;
|
|
497
|
+
getAccessToken;
|
|
498
|
+
listeners = /* @__PURE__ */ new Set();
|
|
499
|
+
_activeOrgId = null;
|
|
500
|
+
_activeOrg = null;
|
|
501
|
+
_activeRole = null;
|
|
502
|
+
constructor(config) {
|
|
503
|
+
this.serverUrl = config.serverUrl.replace(/\/+$/, "");
|
|
504
|
+
this.getAccessToken = config.getAccessToken;
|
|
505
|
+
}
|
|
506
|
+
// --- Getters ---
|
|
507
|
+
/** Currently active organization ID */
|
|
508
|
+
get activeOrgId() {
|
|
509
|
+
return this._activeOrgId;
|
|
510
|
+
}
|
|
511
|
+
/** Currently active organization */
|
|
512
|
+
get activeOrg() {
|
|
513
|
+
return this._activeOrg;
|
|
514
|
+
}
|
|
515
|
+
/** Current user's role in the active organization */
|
|
516
|
+
get activeRole() {
|
|
517
|
+
return this._activeRole;
|
|
518
|
+
}
|
|
519
|
+
// --- Organization Operations ---
|
|
520
|
+
/**
|
|
521
|
+
* Create a new organization.
|
|
522
|
+
*/
|
|
523
|
+
async createOrg(params) {
|
|
524
|
+
return this.request("/orgs", {
|
|
525
|
+
method: "POST",
|
|
526
|
+
body: params
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* List all organizations the current user belongs to.
|
|
531
|
+
*/
|
|
532
|
+
async listOrgs() {
|
|
533
|
+
return this.request("/orgs", { method: "GET" });
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Get an organization by ID.
|
|
537
|
+
*/
|
|
538
|
+
async getOrg(orgId) {
|
|
539
|
+
return this.request(`/orgs/${orgId}`, { method: "GET" });
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Update an organization.
|
|
543
|
+
*/
|
|
544
|
+
async updateOrg(orgId, params) {
|
|
545
|
+
const result = await this.request(`/orgs/${orgId}`, {
|
|
546
|
+
method: "PATCH",
|
|
547
|
+
body: params
|
|
548
|
+
});
|
|
549
|
+
if (this._activeOrgId === orgId) {
|
|
550
|
+
this._activeOrg = result;
|
|
551
|
+
}
|
|
552
|
+
return result;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Delete an organization.
|
|
556
|
+
*/
|
|
557
|
+
async deleteOrg(orgId) {
|
|
558
|
+
await this.request(`/orgs/${orgId}`, { method: "DELETE" });
|
|
559
|
+
if (this._activeOrgId === orgId) {
|
|
560
|
+
this._activeOrgId = null;
|
|
561
|
+
this._activeOrg = null;
|
|
562
|
+
this._activeRole = null;
|
|
563
|
+
this.notifyListeners();
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
// --- Org Switching ---
|
|
567
|
+
/**
|
|
568
|
+
* Switch the active organization context.
|
|
569
|
+
* Fetches the org details and the user's membership/role.
|
|
570
|
+
*/
|
|
571
|
+
async switchOrg(orgId) {
|
|
572
|
+
const org = await this.request(`/orgs/${orgId}`, { method: "GET" });
|
|
573
|
+
const membership = await this.request(`/orgs/${orgId}/membership`, {
|
|
574
|
+
method: "GET"
|
|
575
|
+
});
|
|
576
|
+
this._activeOrgId = orgId;
|
|
577
|
+
this._activeOrg = org;
|
|
578
|
+
this._activeRole = membership.role;
|
|
579
|
+
this.notifyListeners();
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Clear the active organization (no org selected).
|
|
583
|
+
*/
|
|
584
|
+
clearActiveOrg() {
|
|
585
|
+
this._activeOrgId = null;
|
|
586
|
+
this._activeOrg = null;
|
|
587
|
+
this._activeRole = null;
|
|
588
|
+
this.notifyListeners();
|
|
589
|
+
}
|
|
590
|
+
// --- Member Management ---
|
|
591
|
+
/**
|
|
592
|
+
* List members of an organization.
|
|
593
|
+
*/
|
|
594
|
+
async listMembers(orgId) {
|
|
595
|
+
return this.request(`/orgs/${orgId}/members`, { method: "GET" });
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Remove a member from an organization.
|
|
599
|
+
*/
|
|
600
|
+
async removeMember(orgId, userId) {
|
|
601
|
+
await this.request(`/orgs/${orgId}/members/${userId}`, { method: "DELETE" });
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Update a member's role.
|
|
605
|
+
*/
|
|
606
|
+
async updateMemberRole(orgId, userId, role) {
|
|
607
|
+
return this.request(`/orgs/${orgId}/members/${userId}/role`, {
|
|
608
|
+
method: "PATCH",
|
|
609
|
+
body: { role }
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Transfer ownership to another member.
|
|
614
|
+
*/
|
|
615
|
+
async transferOwnership(orgId, newOwnerId) {
|
|
616
|
+
await this.request(`/orgs/${orgId}/transfer`, {
|
|
617
|
+
method: "POST",
|
|
618
|
+
body: { newOwnerId }
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Leave an organization (remove yourself).
|
|
623
|
+
*/
|
|
624
|
+
async leaveOrg(orgId) {
|
|
625
|
+
await this.request(`/orgs/${orgId}/leave`, { method: "POST" });
|
|
626
|
+
if (this._activeOrgId === orgId) {
|
|
627
|
+
this._activeOrgId = null;
|
|
628
|
+
this._activeOrg = null;
|
|
629
|
+
this._activeRole = null;
|
|
630
|
+
this.notifyListeners();
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
// --- Invitations ---
|
|
634
|
+
/**
|
|
635
|
+
* Invite a user to an organization by email.
|
|
636
|
+
*/
|
|
637
|
+
async inviteMember(orgId, params) {
|
|
638
|
+
return this.request(`/orgs/${orgId}/invitations`, {
|
|
639
|
+
method: "POST",
|
|
640
|
+
body: params
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Accept an invitation by token.
|
|
645
|
+
*/
|
|
646
|
+
async acceptInvitation(token) {
|
|
647
|
+
return this.request("/invitations/accept", {
|
|
648
|
+
method: "POST",
|
|
649
|
+
body: { token }
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* List pending invitations for an organization.
|
|
654
|
+
*/
|
|
655
|
+
async listInvitations(orgId) {
|
|
656
|
+
return this.request(`/orgs/${orgId}/invitations`, { method: "GET" });
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Revoke a pending invitation.
|
|
660
|
+
*/
|
|
661
|
+
async revokeInvitation(orgId, invitationId) {
|
|
662
|
+
await this.request(`/orgs/${orgId}/invitations/${invitationId}`, { method: "DELETE" });
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* List pending invitations for the current user's email.
|
|
666
|
+
*/
|
|
667
|
+
async listMyInvitations(email) {
|
|
668
|
+
return this.request(
|
|
669
|
+
`/invitations?email=${encodeURIComponent(email)}`,
|
|
670
|
+
{ method: "GET" }
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
// --- Subscriptions ---
|
|
674
|
+
/**
|
|
675
|
+
* Subscribe to active org changes.
|
|
676
|
+
* @returns Unsubscribe function
|
|
677
|
+
*/
|
|
678
|
+
onOrgChange(callback) {
|
|
679
|
+
this.listeners.add(callback);
|
|
680
|
+
return () => {
|
|
681
|
+
this.listeners.delete(callback);
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
// --- Internal ---
|
|
685
|
+
notifyListeners() {
|
|
686
|
+
for (const listener of this.listeners) {
|
|
687
|
+
try {
|
|
688
|
+
listener(this._activeOrgId);
|
|
689
|
+
} catch {
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
async request(path, options) {
|
|
694
|
+
const token = await this.getAccessToken();
|
|
695
|
+
if (!token) {
|
|
696
|
+
throw new OrgClientError(
|
|
697
|
+
"Not authenticated. Sign in before performing organization operations.",
|
|
698
|
+
"ORG_NOT_AUTHENTICATED"
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
const url = `${this.serverUrl}${path}`;
|
|
702
|
+
const headers = {
|
|
703
|
+
Authorization: `Bearer ${token}`
|
|
704
|
+
};
|
|
705
|
+
if (options.body) {
|
|
706
|
+
headers["Content-Type"] = "application/json";
|
|
707
|
+
}
|
|
708
|
+
let response;
|
|
709
|
+
try {
|
|
710
|
+
response = await fetch(url, {
|
|
711
|
+
method: options.method,
|
|
712
|
+
headers,
|
|
713
|
+
body: options.body ? JSON.stringify(options.body) : void 0
|
|
714
|
+
});
|
|
715
|
+
} catch (cause) {
|
|
716
|
+
throw new OrgClientError(
|
|
717
|
+
`Network request to ${path} failed.`,
|
|
718
|
+
"ORG_NETWORK_ERROR",
|
|
719
|
+
{ path, cause: cause instanceof Error ? cause.message : String(cause) }
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
if (!response.ok) {
|
|
723
|
+
let errorMessage = `Server returned HTTP ${response.status}`;
|
|
724
|
+
try {
|
|
725
|
+
const body = await response.json();
|
|
726
|
+
if (typeof body["error"] === "string") {
|
|
727
|
+
errorMessage = body["error"];
|
|
728
|
+
}
|
|
729
|
+
} catch {
|
|
730
|
+
}
|
|
731
|
+
throw new OrgClientError(errorMessage, "ORG_SERVER_ERROR", {
|
|
732
|
+
path,
|
|
733
|
+
status: response.status
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
const text = await response.text();
|
|
737
|
+
if (text.length === 0) return void 0;
|
|
738
|
+
try {
|
|
739
|
+
const body = JSON.parse(text);
|
|
740
|
+
if (body && typeof body === "object" && "data" in body) {
|
|
741
|
+
return body.data;
|
|
742
|
+
}
|
|
743
|
+
return body;
|
|
744
|
+
} catch {
|
|
745
|
+
return void 0;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
// src/device/device-identity.ts
|
|
751
|
+
var import_core3 = require("@korajs/core");
|
|
752
|
+
var CryptoUnavailableError = class extends import_core3.KoraError {
|
|
445
753
|
constructor() {
|
|
446
754
|
super(
|
|
447
755
|
"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.",
|
|
@@ -450,7 +758,7 @@ var CryptoUnavailableError = class extends import_core2.KoraError {
|
|
|
450
758
|
this.name = "CryptoUnavailableError";
|
|
451
759
|
}
|
|
452
760
|
};
|
|
453
|
-
var DeviceIdentityError = class extends
|
|
761
|
+
var DeviceIdentityError = class extends import_core3.KoraError {
|
|
454
762
|
constructor(message, context) {
|
|
455
763
|
super(message, "DEVICE_IDENTITY_ERROR", context);
|
|
456
764
|
this.name = "DeviceIdentityError";
|
|
@@ -601,6 +909,229 @@ async function computePublicKeyThumbprint(publicKeyJwk) {
|
|
|
601
909
|
}
|
|
602
910
|
}
|
|
603
911
|
|
|
912
|
+
// src/device/device-store.ts
|
|
913
|
+
var import_core4 = require("@korajs/core");
|
|
914
|
+
var DeviceKeyStoreError = class extends import_core4.KoraError {
|
|
915
|
+
constructor(message, context) {
|
|
916
|
+
super(message, "DEVICE_KEY_STORE_ERROR", context);
|
|
917
|
+
this.name = "DeviceKeyStoreError";
|
|
918
|
+
}
|
|
919
|
+
};
|
|
920
|
+
var IDB_DATABASE_NAME = "kora_device_keys";
|
|
921
|
+
var IDB_STORE_NAME = "keypairs";
|
|
922
|
+
var IDB_VERSION = 1;
|
|
923
|
+
var IndexedDBDeviceKeyStore = class {
|
|
924
|
+
dbPromise = null;
|
|
925
|
+
/**
|
|
926
|
+
* Opens (or creates) the IndexedDB database.
|
|
927
|
+
*
|
|
928
|
+
* The database connection is lazily initialized on first use and
|
|
929
|
+
* reused for subsequent operations. If the database does not exist,
|
|
930
|
+
* it is created with the `keypairs` object store.
|
|
931
|
+
*/
|
|
932
|
+
openDatabase() {
|
|
933
|
+
if (this.dbPromise !== null) {
|
|
934
|
+
return this.dbPromise;
|
|
935
|
+
}
|
|
936
|
+
this.dbPromise = new Promise((resolve, reject) => {
|
|
937
|
+
let request;
|
|
938
|
+
try {
|
|
939
|
+
request = globalThis.indexedDB.open(IDB_DATABASE_NAME, IDB_VERSION);
|
|
940
|
+
} catch (cause) {
|
|
941
|
+
this.dbPromise = null;
|
|
942
|
+
reject(
|
|
943
|
+
new DeviceKeyStoreError(
|
|
944
|
+
"Failed to open IndexedDB database for device key storage. IndexedDB may be unavailable or access may be denied.",
|
|
945
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) }
|
|
946
|
+
)
|
|
947
|
+
);
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
request.onupgradeneeded = () => {
|
|
951
|
+
const db = request.result;
|
|
952
|
+
if (!db.objectStoreNames.contains(IDB_STORE_NAME)) {
|
|
953
|
+
db.createObjectStore(IDB_STORE_NAME);
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
request.onsuccess = () => {
|
|
957
|
+
resolve(request.result);
|
|
958
|
+
};
|
|
959
|
+
request.onerror = () => {
|
|
960
|
+
this.dbPromise = null;
|
|
961
|
+
reject(
|
|
962
|
+
new DeviceKeyStoreError(
|
|
963
|
+
"Failed to open IndexedDB database for device key storage.",
|
|
964
|
+
{ error: request.error?.message }
|
|
965
|
+
)
|
|
966
|
+
);
|
|
967
|
+
};
|
|
968
|
+
request.onblocked = () => {
|
|
969
|
+
this.dbPromise = null;
|
|
970
|
+
reject(
|
|
971
|
+
new DeviceKeyStoreError(
|
|
972
|
+
"IndexedDB database open was blocked. Another tab may have an older version of the database open. Close other tabs and try again."
|
|
973
|
+
)
|
|
974
|
+
);
|
|
975
|
+
};
|
|
976
|
+
});
|
|
977
|
+
return this.dbPromise;
|
|
978
|
+
}
|
|
979
|
+
/** @inheritdoc */
|
|
980
|
+
async saveKeyPair(deviceId, keyPair) {
|
|
981
|
+
const db = await this.openDatabase();
|
|
982
|
+
return new Promise((resolve, reject) => {
|
|
983
|
+
try {
|
|
984
|
+
const tx = db.transaction(IDB_STORE_NAME, "readwrite");
|
|
985
|
+
const store = tx.objectStore(IDB_STORE_NAME);
|
|
986
|
+
store.put(keyPair, deviceId);
|
|
987
|
+
tx.oncomplete = () => {
|
|
988
|
+
resolve();
|
|
989
|
+
};
|
|
990
|
+
tx.onerror = () => {
|
|
991
|
+
reject(
|
|
992
|
+
new DeviceKeyStoreError(
|
|
993
|
+
`Failed to save key pair for device "${deviceId}".`,
|
|
994
|
+
{ deviceId, error: tx.error?.message }
|
|
995
|
+
)
|
|
996
|
+
);
|
|
997
|
+
};
|
|
998
|
+
} catch (cause) {
|
|
999
|
+
reject(
|
|
1000
|
+
new DeviceKeyStoreError(
|
|
1001
|
+
`Failed to save key pair for device "${deviceId}".`,
|
|
1002
|
+
{
|
|
1003
|
+
deviceId,
|
|
1004
|
+
cause: cause instanceof Error ? cause.message : String(cause)
|
|
1005
|
+
}
|
|
1006
|
+
)
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
/** @inheritdoc */
|
|
1012
|
+
async loadKeyPair(deviceId) {
|
|
1013
|
+
const db = await this.openDatabase();
|
|
1014
|
+
return new Promise((resolve, reject) => {
|
|
1015
|
+
try {
|
|
1016
|
+
const tx = db.transaction(IDB_STORE_NAME, "readonly");
|
|
1017
|
+
const store = tx.objectStore(IDB_STORE_NAME);
|
|
1018
|
+
const request = store.get(deviceId);
|
|
1019
|
+
request.onsuccess = () => {
|
|
1020
|
+
const result = request.result;
|
|
1021
|
+
resolve(result ?? null);
|
|
1022
|
+
};
|
|
1023
|
+
request.onerror = () => {
|
|
1024
|
+
reject(
|
|
1025
|
+
new DeviceKeyStoreError(
|
|
1026
|
+
`Failed to load key pair for device "${deviceId}".`,
|
|
1027
|
+
{ deviceId, error: request.error?.message }
|
|
1028
|
+
)
|
|
1029
|
+
);
|
|
1030
|
+
};
|
|
1031
|
+
} catch (cause) {
|
|
1032
|
+
reject(
|
|
1033
|
+
new DeviceKeyStoreError(
|
|
1034
|
+
`Failed to load key pair for device "${deviceId}".`,
|
|
1035
|
+
{
|
|
1036
|
+
deviceId,
|
|
1037
|
+
cause: cause instanceof Error ? cause.message : String(cause)
|
|
1038
|
+
}
|
|
1039
|
+
)
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
/** @inheritdoc */
|
|
1045
|
+
async deleteKeyPair(deviceId) {
|
|
1046
|
+
const db = await this.openDatabase();
|
|
1047
|
+
return new Promise((resolve, reject) => {
|
|
1048
|
+
try {
|
|
1049
|
+
const tx = db.transaction(IDB_STORE_NAME, "readwrite");
|
|
1050
|
+
const store = tx.objectStore(IDB_STORE_NAME);
|
|
1051
|
+
store.delete(deviceId);
|
|
1052
|
+
tx.oncomplete = () => {
|
|
1053
|
+
resolve();
|
|
1054
|
+
};
|
|
1055
|
+
tx.onerror = () => {
|
|
1056
|
+
reject(
|
|
1057
|
+
new DeviceKeyStoreError(
|
|
1058
|
+
`Failed to delete key pair for device "${deviceId}".`,
|
|
1059
|
+
{ deviceId, error: tx.error?.message }
|
|
1060
|
+
)
|
|
1061
|
+
);
|
|
1062
|
+
};
|
|
1063
|
+
} catch (cause) {
|
|
1064
|
+
reject(
|
|
1065
|
+
new DeviceKeyStoreError(
|
|
1066
|
+
`Failed to delete key pair for device "${deviceId}".`,
|
|
1067
|
+
{
|
|
1068
|
+
deviceId,
|
|
1069
|
+
cause: cause instanceof Error ? cause.message : String(cause)
|
|
1070
|
+
}
|
|
1071
|
+
)
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
/** @inheritdoc */
|
|
1077
|
+
async hasKeyPair(deviceId) {
|
|
1078
|
+
const db = await this.openDatabase();
|
|
1079
|
+
return new Promise((resolve, reject) => {
|
|
1080
|
+
try {
|
|
1081
|
+
const tx = db.transaction(IDB_STORE_NAME, "readonly");
|
|
1082
|
+
const store = tx.objectStore(IDB_STORE_NAME);
|
|
1083
|
+
const request = store.count(deviceId);
|
|
1084
|
+
request.onsuccess = () => {
|
|
1085
|
+
resolve(request.result > 0);
|
|
1086
|
+
};
|
|
1087
|
+
request.onerror = () => {
|
|
1088
|
+
reject(
|
|
1089
|
+
new DeviceKeyStoreError(
|
|
1090
|
+
`Failed to check if key pair exists for device "${deviceId}".`,
|
|
1091
|
+
{ deviceId, error: request.error?.message }
|
|
1092
|
+
)
|
|
1093
|
+
);
|
|
1094
|
+
};
|
|
1095
|
+
} catch (cause) {
|
|
1096
|
+
reject(
|
|
1097
|
+
new DeviceKeyStoreError(
|
|
1098
|
+
`Failed to check if key pair exists for device "${deviceId}".`,
|
|
1099
|
+
{
|
|
1100
|
+
deviceId,
|
|
1101
|
+
cause: cause instanceof Error ? cause.message : String(cause)
|
|
1102
|
+
}
|
|
1103
|
+
)
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
};
|
|
1109
|
+
var InMemoryDeviceKeyStore = class {
|
|
1110
|
+
store = /* @__PURE__ */ new Map();
|
|
1111
|
+
/** @inheritdoc */
|
|
1112
|
+
async saveKeyPair(deviceId, keyPair) {
|
|
1113
|
+
this.store.set(deviceId, keyPair);
|
|
1114
|
+
}
|
|
1115
|
+
/** @inheritdoc */
|
|
1116
|
+
async loadKeyPair(deviceId) {
|
|
1117
|
+
return this.store.get(deviceId) ?? null;
|
|
1118
|
+
}
|
|
1119
|
+
/** @inheritdoc */
|
|
1120
|
+
async deleteKeyPair(deviceId) {
|
|
1121
|
+
this.store.delete(deviceId);
|
|
1122
|
+
}
|
|
1123
|
+
/** @inheritdoc */
|
|
1124
|
+
async hasKeyPair(deviceId) {
|
|
1125
|
+
return this.store.has(deviceId);
|
|
1126
|
+
}
|
|
1127
|
+
};
|
|
1128
|
+
function createDeviceKeyStore() {
|
|
1129
|
+
if (typeof globalThis.indexedDB !== "undefined") {
|
|
1130
|
+
return new IndexedDBDeviceKeyStore();
|
|
1131
|
+
}
|
|
1132
|
+
return new InMemoryDeviceKeyStore();
|
|
1133
|
+
}
|
|
1134
|
+
|
|
604
1135
|
// src/tokens/token-store.ts
|
|
605
1136
|
var DEFAULT_STORAGE_KEY = "kora_auth_tokens";
|
|
606
1137
|
var MemoryStorage = class {
|
|
@@ -722,17 +1253,1019 @@ var TokenStore = class {
|
|
|
722
1253
|
return tokens?.refreshToken ?? null;
|
|
723
1254
|
}
|
|
724
1255
|
};
|
|
1256
|
+
|
|
1257
|
+
// src/tokens/encrypted-token-store.ts
|
|
1258
|
+
var import_core6 = require("@korajs/core");
|
|
1259
|
+
|
|
1260
|
+
// src/encryption/database-encryption.ts
|
|
1261
|
+
var import_core5 = require("@korajs/core");
|
|
1262
|
+
var EncryptionError = class extends import_core5.KoraError {
|
|
1263
|
+
constructor(message, context) {
|
|
1264
|
+
super(message, "ENCRYPTION_ERROR", context);
|
|
1265
|
+
this.name = "EncryptionError";
|
|
1266
|
+
}
|
|
1267
|
+
};
|
|
1268
|
+
var CryptoUnavailableError2 = class extends import_core5.KoraError {
|
|
1269
|
+
constructor() {
|
|
1270
|
+
super(
|
|
1271
|
+
"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.",
|
|
1272
|
+
"CRYPTO_UNAVAILABLE"
|
|
1273
|
+
);
|
|
1274
|
+
this.name = "CryptoUnavailableError";
|
|
1275
|
+
}
|
|
1276
|
+
};
|
|
1277
|
+
var AES_GCM = "AES-GCM";
|
|
1278
|
+
var AES_KEY_LENGTH = 256;
|
|
1279
|
+
var IV_LENGTH = 12;
|
|
1280
|
+
function assertCryptoAvailable2() {
|
|
1281
|
+
if (typeof globalThis.crypto === "undefined" || typeof globalThis.crypto.subtle === "undefined") {
|
|
1282
|
+
throw new CryptoUnavailableError2();
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
async function generateEncryptionKey() {
|
|
1286
|
+
assertCryptoAvailable2();
|
|
1287
|
+
try {
|
|
1288
|
+
const key = await globalThis.crypto.subtle.generateKey(
|
|
1289
|
+
{ name: AES_GCM, length: AES_KEY_LENGTH },
|
|
1290
|
+
// extractable: true so the key can be exported and persisted
|
|
1291
|
+
true,
|
|
1292
|
+
["encrypt", "decrypt"]
|
|
1293
|
+
);
|
|
1294
|
+
return key;
|
|
1295
|
+
} catch (cause) {
|
|
1296
|
+
throw new EncryptionError(
|
|
1297
|
+
"Failed to generate AES-256-GCM encryption key. Ensure the runtime supports the AES-GCM algorithm.",
|
|
1298
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) }
|
|
1299
|
+
);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
async function encryptData(key, plaintext) {
|
|
1303
|
+
assertCryptoAvailable2();
|
|
1304
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
1305
|
+
try {
|
|
1306
|
+
const ciphertextBuffer = await globalThis.crypto.subtle.encrypt(
|
|
1307
|
+
{ name: AES_GCM, iv },
|
|
1308
|
+
key,
|
|
1309
|
+
plaintext
|
|
1310
|
+
);
|
|
1311
|
+
return {
|
|
1312
|
+
ciphertext: new Uint8Array(ciphertextBuffer),
|
|
1313
|
+
iv
|
|
1314
|
+
};
|
|
1315
|
+
} catch (cause) {
|
|
1316
|
+
throw new EncryptionError(
|
|
1317
|
+
'Failed to encrypt data with AES-256-GCM. Ensure the key is a valid AES-GCM CryptoKey with "encrypt" usage.',
|
|
1318
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) }
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
async function decryptData(key, ciphertext, iv) {
|
|
1323
|
+
assertCryptoAvailable2();
|
|
1324
|
+
try {
|
|
1325
|
+
const plaintextBuffer = await globalThis.crypto.subtle.decrypt(
|
|
1326
|
+
{ name: AES_GCM, iv },
|
|
1327
|
+
key,
|
|
1328
|
+
ciphertext
|
|
1329
|
+
);
|
|
1330
|
+
return new Uint8Array(plaintextBuffer);
|
|
1331
|
+
} catch (cause) {
|
|
1332
|
+
throw new EncryptionError(
|
|
1333
|
+
"Failed to decrypt data with AES-256-GCM. This may indicate a wrong key, tampered ciphertext, or incorrect IV.",
|
|
1334
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) }
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
async function exportKey(key) {
|
|
1339
|
+
assertCryptoAvailable2();
|
|
1340
|
+
try {
|
|
1341
|
+
const rawBuffer = await globalThis.crypto.subtle.exportKey("raw", key);
|
|
1342
|
+
return new Uint8Array(rawBuffer);
|
|
1343
|
+
} catch (cause) {
|
|
1344
|
+
throw new EncryptionError(
|
|
1345
|
+
"Failed to export AES-256-GCM key. The key may not be extractable. Only keys generated with extractable=true can be exported.",
|
|
1346
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) }
|
|
1347
|
+
);
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
async function importKey(rawKey) {
|
|
1351
|
+
assertCryptoAvailable2();
|
|
1352
|
+
if (rawKey.length !== 32) {
|
|
1353
|
+
throw new EncryptionError(
|
|
1354
|
+
`Invalid key length: expected 32 bytes (256 bits) for AES-256, but received ${rawKey.length} bytes.`,
|
|
1355
|
+
{ actualLength: rawKey.length, expectedLength: 32 }
|
|
1356
|
+
);
|
|
1357
|
+
}
|
|
1358
|
+
try {
|
|
1359
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
1360
|
+
"raw",
|
|
1361
|
+
rawKey,
|
|
1362
|
+
{ name: AES_GCM, length: AES_KEY_LENGTH },
|
|
1363
|
+
true,
|
|
1364
|
+
["encrypt", "decrypt"]
|
|
1365
|
+
);
|
|
1366
|
+
return key;
|
|
1367
|
+
} catch (cause) {
|
|
1368
|
+
throw new EncryptionError(
|
|
1369
|
+
"Failed to import raw key bytes as AES-256-GCM key. Ensure the key material is valid.",
|
|
1370
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) }
|
|
1371
|
+
);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// src/tokens/encrypted-token-store.ts
|
|
1376
|
+
var EncryptedTokenStoreError = class extends import_core6.KoraError {
|
|
1377
|
+
constructor(message, context) {
|
|
1378
|
+
super(message, "ENCRYPTED_TOKEN_STORE_ERROR", context);
|
|
1379
|
+
this.name = "EncryptedTokenStoreError";
|
|
1380
|
+
}
|
|
1381
|
+
};
|
|
1382
|
+
function toBase64Url2(bytes) {
|
|
1383
|
+
let binary = "";
|
|
1384
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
1385
|
+
binary += String.fromCharCode(bytes[i]);
|
|
1386
|
+
}
|
|
1387
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1388
|
+
}
|
|
1389
|
+
function fromBase64Url2(str) {
|
|
1390
|
+
let base64 = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
1391
|
+
const paddingNeeded = (4 - base64.length % 4) % 4;
|
|
1392
|
+
base64 += "=".repeat(paddingNeeded);
|
|
1393
|
+
const binary = atob(base64);
|
|
1394
|
+
const bytes = new Uint8Array(binary.length);
|
|
1395
|
+
for (let i = 0; i < binary.length; i++) {
|
|
1396
|
+
bytes[i] = binary.charCodeAt(i);
|
|
1397
|
+
}
|
|
1398
|
+
return bytes;
|
|
1399
|
+
}
|
|
1400
|
+
var MemoryStorage2 = class {
|
|
1401
|
+
store = /* @__PURE__ */ new Map();
|
|
1402
|
+
getItem(key) {
|
|
1403
|
+
return this.store.get(key) ?? null;
|
|
1404
|
+
}
|
|
1405
|
+
setItem(key, value) {
|
|
1406
|
+
this.store.set(key, value);
|
|
1407
|
+
}
|
|
1408
|
+
removeItem(key) {
|
|
1409
|
+
this.store.delete(key);
|
|
1410
|
+
}
|
|
1411
|
+
};
|
|
1412
|
+
function tryGetLocalStorage2() {
|
|
1413
|
+
try {
|
|
1414
|
+
if (typeof globalThis !== "undefined" && "localStorage" in globalThis) {
|
|
1415
|
+
const storage = globalThis.localStorage;
|
|
1416
|
+
const testKey = "__kora_encrypted_storage_test__";
|
|
1417
|
+
storage.setItem(testKey, "1");
|
|
1418
|
+
storage.removeItem(testKey);
|
|
1419
|
+
return storage;
|
|
1420
|
+
}
|
|
1421
|
+
} catch {
|
|
1422
|
+
}
|
|
1423
|
+
return null;
|
|
1424
|
+
}
|
|
1425
|
+
var DEFAULT_STORAGE_KEY2 = "kora_auth_encrypted";
|
|
1426
|
+
var EncryptedTokenStore = class {
|
|
1427
|
+
storageKey;
|
|
1428
|
+
key;
|
|
1429
|
+
storage;
|
|
1430
|
+
/**
|
|
1431
|
+
* Creates a new EncryptedTokenStore instance.
|
|
1432
|
+
*
|
|
1433
|
+
* @param config - Configuration including the encryption key and optional storage key
|
|
1434
|
+
*/
|
|
1435
|
+
constructor(config) {
|
|
1436
|
+
this.storageKey = config.storageKey ?? DEFAULT_STORAGE_KEY2;
|
|
1437
|
+
this.key = config.key;
|
|
1438
|
+
this.storage = tryGetLocalStorage2() ?? new MemoryStorage2();
|
|
1439
|
+
}
|
|
1440
|
+
/**
|
|
1441
|
+
* Encrypt and save tokens to persistent storage.
|
|
1442
|
+
*
|
|
1443
|
+
* Serializes the tokens as JSON, encrypts with AES-256-GCM using a fresh
|
|
1444
|
+
* random IV, then stores the result as a JSON object containing the
|
|
1445
|
+
* base64url-encoded IV and ciphertext.
|
|
1446
|
+
*
|
|
1447
|
+
* Overwrites any previously stored tokens.
|
|
1448
|
+
*
|
|
1449
|
+
* @param tokens - The token set to encrypt and store
|
|
1450
|
+
* @throws {EncryptedTokenStoreError} If encryption fails
|
|
1451
|
+
*/
|
|
1452
|
+
async saveTokens(tokens) {
|
|
1453
|
+
const serialized = {
|
|
1454
|
+
accessToken: tokens.accessToken,
|
|
1455
|
+
refreshToken: tokens.refreshToken
|
|
1456
|
+
};
|
|
1457
|
+
if (tokens.deviceCredential !== void 0) {
|
|
1458
|
+
serialized.deviceCredential = tokens.deviceCredential;
|
|
1459
|
+
}
|
|
1460
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(serialized));
|
|
1461
|
+
try {
|
|
1462
|
+
const { ciphertext, iv } = await encryptData(this.key, plaintext);
|
|
1463
|
+
const payload = {
|
|
1464
|
+
iv: toBase64Url2(iv),
|
|
1465
|
+
data: toBase64Url2(ciphertext)
|
|
1466
|
+
};
|
|
1467
|
+
this.storage.setItem(this.storageKey, JSON.stringify(payload));
|
|
1468
|
+
} catch (cause) {
|
|
1469
|
+
if (cause instanceof EncryptedTokenStoreError) {
|
|
1470
|
+
throw cause;
|
|
1471
|
+
}
|
|
1472
|
+
throw new EncryptedTokenStoreError(
|
|
1473
|
+
"Failed to encrypt and save auth tokens. Ensure the encryption key is a valid AES-256-GCM CryptoKey.",
|
|
1474
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) }
|
|
1475
|
+
);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
/**
|
|
1479
|
+
* Load and decrypt tokens from storage.
|
|
1480
|
+
*
|
|
1481
|
+
* Reads the encrypted payload from localStorage, decodes the base64url IV
|
|
1482
|
+
* and ciphertext, decrypts with AES-256-GCM, and parses the resulting JSON.
|
|
1483
|
+
*
|
|
1484
|
+
* Returns null (without throwing) if:
|
|
1485
|
+
* - No tokens have been saved
|
|
1486
|
+
* - The stored data is corrupted or not valid JSON
|
|
1487
|
+
* - Decryption fails (wrong key, tampered ciphertext, or wrong IV)
|
|
1488
|
+
* - The decrypted data does not contain valid token fields
|
|
1489
|
+
*
|
|
1490
|
+
* This fail-silent design prevents decryption errors from crashing the
|
|
1491
|
+
* application. The caller should treat null as "no valid tokens available"
|
|
1492
|
+
* and initiate a re-authentication flow.
|
|
1493
|
+
*
|
|
1494
|
+
* @returns The decrypted {@link AuthTokens}, or null if unavailable or decryption fails
|
|
1495
|
+
*/
|
|
1496
|
+
async loadTokens() {
|
|
1497
|
+
const raw = this.storage.getItem(this.storageKey);
|
|
1498
|
+
if (raw === null) {
|
|
1499
|
+
return null;
|
|
1500
|
+
}
|
|
1501
|
+
try {
|
|
1502
|
+
const parsed = JSON.parse(raw);
|
|
1503
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
1504
|
+
return null;
|
|
1505
|
+
}
|
|
1506
|
+
const record = parsed;
|
|
1507
|
+
if (typeof record["iv"] !== "string" || typeof record["data"] !== "string") {
|
|
1508
|
+
return null;
|
|
1509
|
+
}
|
|
1510
|
+
const iv = fromBase64Url2(record["iv"]);
|
|
1511
|
+
const ciphertext = fromBase64Url2(record["data"]);
|
|
1512
|
+
const plaintextBytes = await decryptData(this.key, ciphertext, iv);
|
|
1513
|
+
const json = new TextDecoder().decode(plaintextBytes);
|
|
1514
|
+
const tokenData = JSON.parse(json);
|
|
1515
|
+
if (typeof tokenData !== "object" || tokenData === null || Array.isArray(tokenData)) {
|
|
1516
|
+
return null;
|
|
1517
|
+
}
|
|
1518
|
+
const tokenRecord = tokenData;
|
|
1519
|
+
if (typeof tokenRecord["accessToken"] !== "string" || typeof tokenRecord["refreshToken"] !== "string") {
|
|
1520
|
+
return null;
|
|
1521
|
+
}
|
|
1522
|
+
const tokens = {
|
|
1523
|
+
accessToken: tokenRecord["accessToken"],
|
|
1524
|
+
refreshToken: tokenRecord["refreshToken"]
|
|
1525
|
+
};
|
|
1526
|
+
if (typeof tokenRecord["deviceCredential"] === "string") {
|
|
1527
|
+
tokens.deviceCredential = tokenRecord["deviceCredential"];
|
|
1528
|
+
}
|
|
1529
|
+
return tokens;
|
|
1530
|
+
} catch {
|
|
1531
|
+
return null;
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
/**
|
|
1535
|
+
* Clear all stored encrypted tokens.
|
|
1536
|
+
*
|
|
1537
|
+
* Removes the encrypted payload from storage. This is a synchronous
|
|
1538
|
+
* operation since it only removes the localStorage entry.
|
|
1539
|
+
*
|
|
1540
|
+
* Call this on logout to ensure no encrypted credential material
|
|
1541
|
+
* remains in persistent storage.
|
|
1542
|
+
*/
|
|
1543
|
+
clearTokens() {
|
|
1544
|
+
this.storage.removeItem(this.storageKey);
|
|
1545
|
+
}
|
|
1546
|
+
/**
|
|
1547
|
+
* Get the current access token by decrypting stored tokens.
|
|
1548
|
+
*
|
|
1549
|
+
* Returns the raw token string without validating expiration.
|
|
1550
|
+
* The caller is responsible for checking whether the token is
|
|
1551
|
+
* still valid and initiating a refresh if needed.
|
|
1552
|
+
*
|
|
1553
|
+
* @returns The decrypted access token string, or null if no valid tokens are stored
|
|
1554
|
+
*/
|
|
1555
|
+
async getAccessToken() {
|
|
1556
|
+
const tokens = await this.loadTokens();
|
|
1557
|
+
return tokens?.accessToken ?? null;
|
|
1558
|
+
}
|
|
1559
|
+
/**
|
|
1560
|
+
* Get the current refresh token by decrypting stored tokens.
|
|
1561
|
+
*
|
|
1562
|
+
* @returns The decrypted refresh token string, or null if no valid tokens are stored
|
|
1563
|
+
*/
|
|
1564
|
+
async getRefreshToken() {
|
|
1565
|
+
const tokens = await this.loadTokens();
|
|
1566
|
+
return tokens?.refreshToken ?? null;
|
|
1567
|
+
}
|
|
1568
|
+
};
|
|
1569
|
+
|
|
1570
|
+
// src/passkey/passkey-client.ts
|
|
1571
|
+
var import_core7 = require("@korajs/core");
|
|
1572
|
+
var PasskeyError = class extends import_core7.KoraError {
|
|
1573
|
+
constructor(message, context) {
|
|
1574
|
+
super(message, "PASSKEY_ERROR", context);
|
|
1575
|
+
this.name = "PasskeyError";
|
|
1576
|
+
}
|
|
1577
|
+
};
|
|
1578
|
+
var PasskeyUnsupportedError = class extends import_core7.KoraError {
|
|
1579
|
+
constructor() {
|
|
1580
|
+
super(
|
|
1581
|
+
"WebAuthn is not supported in this browser. Passkey authentication requires a modern browser with WebAuthn support.",
|
|
1582
|
+
"PASSKEY_UNSUPPORTED"
|
|
1583
|
+
);
|
|
1584
|
+
this.name = "PasskeyUnsupportedError";
|
|
1585
|
+
}
|
|
1586
|
+
};
|
|
1587
|
+
function isPasskeySupported() {
|
|
1588
|
+
return typeof globalThis.navigator !== "undefined" && typeof globalThis.navigator.credentials !== "undefined" && typeof globalThis.navigator.credentials.create === "function" && typeof globalThis.navigator.credentials.get === "function";
|
|
1589
|
+
}
|
|
1590
|
+
async function isPlatformAuthenticatorAvailable() {
|
|
1591
|
+
if (!isPasskeySupported()) {
|
|
1592
|
+
return false;
|
|
1593
|
+
}
|
|
1594
|
+
if (typeof PublicKeyCredential !== "undefined" && typeof PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === "function") {
|
|
1595
|
+
try {
|
|
1596
|
+
return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
|
1597
|
+
} catch {
|
|
1598
|
+
return false;
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
return false;
|
|
1602
|
+
}
|
|
1603
|
+
async function createPasskeyCredential(options) {
|
|
1604
|
+
if (!isPasskeySupported()) {
|
|
1605
|
+
throw new PasskeyUnsupportedError();
|
|
1606
|
+
}
|
|
1607
|
+
const excludeCredentials = (options.excludeCredentialIds ?? []).map((id) => ({
|
|
1608
|
+
type: "public-key",
|
|
1609
|
+
id: fromBase64Url(id).buffer
|
|
1610
|
+
}));
|
|
1611
|
+
const authenticatorSelection = {
|
|
1612
|
+
authenticatorAttachment: options.authenticatorSelection?.authenticatorAttachment ?? "platform",
|
|
1613
|
+
residentKey: options.authenticatorSelection?.residentKey ?? "preferred",
|
|
1614
|
+
userVerification: options.authenticatorSelection?.userVerification ?? "required"
|
|
1615
|
+
};
|
|
1616
|
+
if (authenticatorSelection.residentKey === "required") {
|
|
1617
|
+
authenticatorSelection.requireResidentKey = true;
|
|
1618
|
+
}
|
|
1619
|
+
const publicKeyOptions = {
|
|
1620
|
+
challenge: fromBase64Url(options.challenge).buffer,
|
|
1621
|
+
rp: {
|
|
1622
|
+
id: options.rpId,
|
|
1623
|
+
name: options.rpName
|
|
1624
|
+
},
|
|
1625
|
+
user: {
|
|
1626
|
+
id: fromBase64Url(options.userId).buffer,
|
|
1627
|
+
name: options.userName,
|
|
1628
|
+
displayName: options.userDisplayName
|
|
1629
|
+
},
|
|
1630
|
+
pubKeyCredParams: [
|
|
1631
|
+
// ES256 (ECDSA w/ SHA-256) — the most widely supported algorithm
|
|
1632
|
+
{ type: "public-key", alg: -7 },
|
|
1633
|
+
// RS256 (RSASSA-PKCS1-v1_5 w/ SHA-256) — fallback for older authenticators
|
|
1634
|
+
{ type: "public-key", alg: -257 }
|
|
1635
|
+
],
|
|
1636
|
+
excludeCredentials,
|
|
1637
|
+
authenticatorSelection,
|
|
1638
|
+
timeout: 6e4,
|
|
1639
|
+
attestation: "none"
|
|
1640
|
+
};
|
|
1641
|
+
let credential;
|
|
1642
|
+
try {
|
|
1643
|
+
const result = await navigator.credentials.create({
|
|
1644
|
+
publicKey: publicKeyOptions
|
|
1645
|
+
});
|
|
1646
|
+
if (result === null) {
|
|
1647
|
+
throw new PasskeyError(
|
|
1648
|
+
"Credential creation returned null. The user may have cancelled the operation.",
|
|
1649
|
+
{ rpId: options.rpId }
|
|
1650
|
+
);
|
|
1651
|
+
}
|
|
1652
|
+
credential = result;
|
|
1653
|
+
} catch (error) {
|
|
1654
|
+
if (error instanceof PasskeyError) {
|
|
1655
|
+
throw error;
|
|
1656
|
+
}
|
|
1657
|
+
const domError = error;
|
|
1658
|
+
if (domError.name === "NotAllowedError") {
|
|
1659
|
+
throw new PasskeyError(
|
|
1660
|
+
"Passkey creation was cancelled or not allowed. The user may have dismissed the prompt or the operation timed out.",
|
|
1661
|
+
{ rpId: options.rpId, errorName: domError.name }
|
|
1662
|
+
);
|
|
1663
|
+
}
|
|
1664
|
+
if (domError.name === "InvalidStateError") {
|
|
1665
|
+
throw new PasskeyError(
|
|
1666
|
+
"A passkey already exists for this user on this authenticator. Use the existing passkey to sign in.",
|
|
1667
|
+
{ rpId: options.rpId, errorName: domError.name }
|
|
1668
|
+
);
|
|
1669
|
+
}
|
|
1670
|
+
throw new PasskeyError(
|
|
1671
|
+
`Passkey creation failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
1672
|
+
{
|
|
1673
|
+
rpId: options.rpId,
|
|
1674
|
+
errorName: error instanceof Error ? error.name : void 0
|
|
1675
|
+
}
|
|
1676
|
+
);
|
|
1677
|
+
}
|
|
1678
|
+
const response = credential.response;
|
|
1679
|
+
const attestationObject = new Uint8Array(response.attestationObject);
|
|
1680
|
+
const clientDataJSON = new Uint8Array(response.clientDataJSON);
|
|
1681
|
+
const publicKeyBytes = extractPublicKeyFromAttestationObject(attestationObject);
|
|
1682
|
+
return {
|
|
1683
|
+
credentialId: toBase64Url(credential.rawId),
|
|
1684
|
+
publicKey: toBase64Url(publicKeyBytes.buffer),
|
|
1685
|
+
clientDataJSON: toBase64Url(clientDataJSON.buffer),
|
|
1686
|
+
attestationObject: toBase64Url(attestationObject.buffer)
|
|
1687
|
+
};
|
|
1688
|
+
}
|
|
1689
|
+
async function authenticateWithPasskey(options) {
|
|
1690
|
+
if (!isPasskeySupported()) {
|
|
1691
|
+
throw new PasskeyUnsupportedError();
|
|
1692
|
+
}
|
|
1693
|
+
const allowCredentials = options.allowCredentialIds?.map((id) => ({
|
|
1694
|
+
type: "public-key",
|
|
1695
|
+
id: fromBase64Url(id).buffer
|
|
1696
|
+
}));
|
|
1697
|
+
const publicKeyOptions = {
|
|
1698
|
+
challenge: fromBase64Url(options.challenge).buffer,
|
|
1699
|
+
rpId: options.rpId,
|
|
1700
|
+
allowCredentials,
|
|
1701
|
+
userVerification: options.userVerification ?? "preferred",
|
|
1702
|
+
timeout: options.timeout ?? 6e4
|
|
1703
|
+
};
|
|
1704
|
+
let credential;
|
|
1705
|
+
try {
|
|
1706
|
+
const result = await navigator.credentials.get({
|
|
1707
|
+
publicKey: publicKeyOptions
|
|
1708
|
+
});
|
|
1709
|
+
if (result === null) {
|
|
1710
|
+
throw new PasskeyError(
|
|
1711
|
+
"Authentication returned null. The user may have cancelled the operation.",
|
|
1712
|
+
{ rpId: options.rpId }
|
|
1713
|
+
);
|
|
1714
|
+
}
|
|
1715
|
+
credential = result;
|
|
1716
|
+
} catch (error) {
|
|
1717
|
+
if (error instanceof PasskeyError) {
|
|
1718
|
+
throw error;
|
|
1719
|
+
}
|
|
1720
|
+
const domError = error;
|
|
1721
|
+
if (domError.name === "NotAllowedError") {
|
|
1722
|
+
throw new PasskeyError(
|
|
1723
|
+
"Passkey authentication was cancelled or not allowed. The user may have dismissed the prompt or the operation timed out.",
|
|
1724
|
+
{ rpId: options.rpId, errorName: domError.name }
|
|
1725
|
+
);
|
|
1726
|
+
}
|
|
1727
|
+
throw new PasskeyError(
|
|
1728
|
+
`Passkey authentication failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
1729
|
+
{
|
|
1730
|
+
rpId: options.rpId,
|
|
1731
|
+
errorName: error instanceof Error ? error.name : void 0
|
|
1732
|
+
}
|
|
1733
|
+
);
|
|
1734
|
+
}
|
|
1735
|
+
const response = credential.response;
|
|
1736
|
+
const authenticatorData = new Uint8Array(response.authenticatorData);
|
|
1737
|
+
const clientDataJSON = new Uint8Array(response.clientDataJSON);
|
|
1738
|
+
const signature = new Uint8Array(response.signature);
|
|
1739
|
+
const userHandle = response.userHandle !== null && response.userHandle.byteLength > 0 ? toBase64Url(response.userHandle) : null;
|
|
1740
|
+
return {
|
|
1741
|
+
credentialId: toBase64Url(credential.rawId),
|
|
1742
|
+
authenticatorData: toBase64Url(authenticatorData.buffer),
|
|
1743
|
+
clientDataJSON: toBase64Url(clientDataJSON.buffer),
|
|
1744
|
+
signature: toBase64Url(signature.buffer),
|
|
1745
|
+
userHandle
|
|
1746
|
+
};
|
|
1747
|
+
}
|
|
1748
|
+
function extractPublicKeyFromAttestationObject(attestationObject) {
|
|
1749
|
+
const decoded = decodeCbor(attestationObject, 0);
|
|
1750
|
+
const topMap = decoded.value;
|
|
1751
|
+
const authData = topMap.get("authData");
|
|
1752
|
+
if (!(authData instanceof Uint8Array)) {
|
|
1753
|
+
throw new PasskeyError(
|
|
1754
|
+
"Invalid attestation object: authData is missing or not a byte string."
|
|
1755
|
+
);
|
|
1756
|
+
}
|
|
1757
|
+
let offset = 0;
|
|
1758
|
+
offset += 32;
|
|
1759
|
+
const flags = authData[offset];
|
|
1760
|
+
offset += 1;
|
|
1761
|
+
offset += 4;
|
|
1762
|
+
const hasAttestedCredentialData = (flags & 64) !== 0;
|
|
1763
|
+
if (!hasAttestedCredentialData) {
|
|
1764
|
+
throw new PasskeyError(
|
|
1765
|
+
"Attestation object does not contain attested credential data. The authenticator did not include a public key."
|
|
1766
|
+
);
|
|
1767
|
+
}
|
|
1768
|
+
offset += 16;
|
|
1769
|
+
const credentialIdLength = authData[offset] << 8 | authData[offset + 1];
|
|
1770
|
+
offset += 2;
|
|
1771
|
+
offset += credentialIdLength;
|
|
1772
|
+
const coseKeyResult = decodeCbor(authData, offset);
|
|
1773
|
+
const coseKeyLength = coseKeyResult.offset - offset;
|
|
1774
|
+
return authData.slice(offset, offset + coseKeyLength);
|
|
1775
|
+
}
|
|
1776
|
+
function decodeCbor(data, offset) {
|
|
1777
|
+
if (offset >= data.length) {
|
|
1778
|
+
throw new PasskeyError("CBOR decode error: unexpected end of data.");
|
|
1779
|
+
}
|
|
1780
|
+
const initialByte = data[offset];
|
|
1781
|
+
const majorType = initialByte >> 5;
|
|
1782
|
+
const additionalInfo = initialByte & 31;
|
|
1783
|
+
offset += 1;
|
|
1784
|
+
let argument;
|
|
1785
|
+
if (additionalInfo < 24) {
|
|
1786
|
+
argument = additionalInfo;
|
|
1787
|
+
} else if (additionalInfo === 24) {
|
|
1788
|
+
argument = data[offset];
|
|
1789
|
+
offset += 1;
|
|
1790
|
+
} else if (additionalInfo === 25) {
|
|
1791
|
+
argument = data[offset] << 8 | data[offset + 1];
|
|
1792
|
+
offset += 2;
|
|
1793
|
+
} else if (additionalInfo === 26) {
|
|
1794
|
+
argument = data[offset] << 24 | data[offset + 1] << 16 | data[offset + 2] << 8 | data[offset + 3];
|
|
1795
|
+
argument = argument >>> 0;
|
|
1796
|
+
offset += 4;
|
|
1797
|
+
} else {
|
|
1798
|
+
throw new PasskeyError(
|
|
1799
|
+
`CBOR decode error: unsupported additional info ${additionalInfo} at byte ${offset - 1}. This CBOR decoder only supports definite-length encodings.`
|
|
1800
|
+
);
|
|
1801
|
+
}
|
|
1802
|
+
switch (majorType) {
|
|
1803
|
+
// Major type 0: Unsigned integer
|
|
1804
|
+
case 0:
|
|
1805
|
+
return { value: argument, offset };
|
|
1806
|
+
// Major type 1: Negative integer (-1 - argument)
|
|
1807
|
+
case 1:
|
|
1808
|
+
return { value: -1 - argument, offset };
|
|
1809
|
+
// Major type 2: Byte string
|
|
1810
|
+
case 2: {
|
|
1811
|
+
const bytes = data.slice(offset, offset + argument);
|
|
1812
|
+
return { value: bytes, offset: offset + argument };
|
|
1813
|
+
}
|
|
1814
|
+
// Major type 3: Text string (UTF-8)
|
|
1815
|
+
case 3: {
|
|
1816
|
+
const textBytes = data.slice(offset, offset + argument);
|
|
1817
|
+
const text = new TextDecoder().decode(textBytes);
|
|
1818
|
+
return { value: text, offset: offset + argument };
|
|
1819
|
+
}
|
|
1820
|
+
// Major type 4: Array
|
|
1821
|
+
case 4: {
|
|
1822
|
+
const arr = [];
|
|
1823
|
+
let currentOffset = offset;
|
|
1824
|
+
for (let i = 0; i < argument; i++) {
|
|
1825
|
+
const item = decodeCbor(data, currentOffset);
|
|
1826
|
+
arr.push(item.value);
|
|
1827
|
+
currentOffset = item.offset;
|
|
1828
|
+
}
|
|
1829
|
+
return { value: arr, offset: currentOffset };
|
|
1830
|
+
}
|
|
1831
|
+
// Major type 5: Map
|
|
1832
|
+
case 5: {
|
|
1833
|
+
const map = /* @__PURE__ */ new Map();
|
|
1834
|
+
let currentOffset = offset;
|
|
1835
|
+
for (let i = 0; i < argument; i++) {
|
|
1836
|
+
const keyResult = decodeCbor(data, currentOffset);
|
|
1837
|
+
const valResult = decodeCbor(data, keyResult.offset);
|
|
1838
|
+
map.set(keyResult.value, valResult.value);
|
|
1839
|
+
currentOffset = valResult.offset;
|
|
1840
|
+
}
|
|
1841
|
+
return { value: map, offset: currentOffset };
|
|
1842
|
+
}
|
|
1843
|
+
default:
|
|
1844
|
+
throw new PasskeyError(
|
|
1845
|
+
`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).`
|
|
1846
|
+
);
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
// src/encryption/key-derivation.ts
|
|
1851
|
+
var import_core8 = require("@korajs/core");
|
|
1852
|
+
var KeyDerivationError = class extends import_core8.KoraError {
|
|
1853
|
+
constructor(message, context) {
|
|
1854
|
+
super(message, "KEY_DERIVATION_ERROR", context);
|
|
1855
|
+
this.name = "KeyDerivationError";
|
|
1856
|
+
}
|
|
1857
|
+
};
|
|
1858
|
+
var SALT_LENGTH = 32;
|
|
1859
|
+
var PBKDF2_ITERATIONS = 6e5;
|
|
1860
|
+
var DERIVED_KEY_LENGTH = 256;
|
|
1861
|
+
function assertCryptoAvailable3() {
|
|
1862
|
+
if (typeof globalThis.crypto === "undefined" || typeof globalThis.crypto.subtle === "undefined") {
|
|
1863
|
+
throw new KeyDerivationError(
|
|
1864
|
+
"Web Crypto API (crypto.subtle) is not available in this environment. Key derivation requires crypto.subtle, which is available in modern browsers and Node.js 20+."
|
|
1865
|
+
);
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
function generateSalt() {
|
|
1869
|
+
if (typeof globalThis.crypto === "undefined") {
|
|
1870
|
+
throw new KeyDerivationError(
|
|
1871
|
+
"Web Crypto API (crypto) is not available. Cannot generate random salt."
|
|
1872
|
+
);
|
|
1873
|
+
}
|
|
1874
|
+
return globalThis.crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
|
|
1875
|
+
}
|
|
1876
|
+
async function deriveEncryptionKey(passphrase, salt) {
|
|
1877
|
+
assertCryptoAvailable3();
|
|
1878
|
+
if (passphrase.length === 0) {
|
|
1879
|
+
throw new KeyDerivationError(
|
|
1880
|
+
"Passphrase must not be empty. Provide a non-empty string for key derivation."
|
|
1881
|
+
);
|
|
1882
|
+
}
|
|
1883
|
+
const usedSalt = salt ?? generateSalt();
|
|
1884
|
+
try {
|
|
1885
|
+
const passphraseBytes = new TextEncoder().encode(passphrase);
|
|
1886
|
+
const baseKey = await globalThis.crypto.subtle.importKey(
|
|
1887
|
+
"raw",
|
|
1888
|
+
passphraseBytes,
|
|
1889
|
+
"PBKDF2",
|
|
1890
|
+
false,
|
|
1891
|
+
["deriveBits", "deriveKey"]
|
|
1892
|
+
);
|
|
1893
|
+
const derivedKey = await globalThis.crypto.subtle.deriveKey(
|
|
1894
|
+
{
|
|
1895
|
+
name: "PBKDF2",
|
|
1896
|
+
salt: usedSalt,
|
|
1897
|
+
iterations: PBKDF2_ITERATIONS,
|
|
1898
|
+
hash: "SHA-256"
|
|
1899
|
+
},
|
|
1900
|
+
baseKey,
|
|
1901
|
+
{ name: "AES-GCM", length: DERIVED_KEY_LENGTH },
|
|
1902
|
+
// extractable: true so the derived key can be exported if needed
|
|
1903
|
+
true,
|
|
1904
|
+
["encrypt", "decrypt"]
|
|
1905
|
+
);
|
|
1906
|
+
return { key: derivedKey, salt: usedSalt };
|
|
1907
|
+
} catch (cause) {
|
|
1908
|
+
if (cause instanceof KeyDerivationError) {
|
|
1909
|
+
throw cause;
|
|
1910
|
+
}
|
|
1911
|
+
throw new KeyDerivationError(
|
|
1912
|
+
"Failed to derive encryption key from passphrase using PBKDF2. Ensure the runtime supports PBKDF2 with SHA-256.",
|
|
1913
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) }
|
|
1914
|
+
);
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
// src/encryption/auto-lock.ts
|
|
1919
|
+
var AutoLockManager = class {
|
|
1920
|
+
_timeout;
|
|
1921
|
+
_onLock;
|
|
1922
|
+
_timerId = null;
|
|
1923
|
+
_isLocked = false;
|
|
1924
|
+
_isRunning = false;
|
|
1925
|
+
constructor(config) {
|
|
1926
|
+
if (config.timeout <= 0) {
|
|
1927
|
+
throw new Error(
|
|
1928
|
+
`AutoLockManager timeout must be a positive number, but received ${config.timeout}.`
|
|
1929
|
+
);
|
|
1930
|
+
}
|
|
1931
|
+
this._timeout = config.timeout;
|
|
1932
|
+
this._onLock = config.onLock;
|
|
1933
|
+
}
|
|
1934
|
+
/**
|
|
1935
|
+
* Whether the manager is currently in the locked state.
|
|
1936
|
+
*
|
|
1937
|
+
* Becomes `true` when the inactivity timeout fires or {@link lock} is called manually.
|
|
1938
|
+
* Returns to `false` only when {@link unlock} is called.
|
|
1939
|
+
*/
|
|
1940
|
+
get isLocked() {
|
|
1941
|
+
return this._isLocked;
|
|
1942
|
+
}
|
|
1943
|
+
/**
|
|
1944
|
+
* Starts monitoring for inactivity.
|
|
1945
|
+
*
|
|
1946
|
+
* Begins the inactivity countdown. If the manager is already running, this is
|
|
1947
|
+
* a no-op to prevent creating multiple timers. Calling `start()` also resets
|
|
1948
|
+
* the locked state if the manager was previously locked.
|
|
1949
|
+
*/
|
|
1950
|
+
start() {
|
|
1951
|
+
if (this._isRunning) {
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
this._isRunning = true;
|
|
1955
|
+
this._isLocked = false;
|
|
1956
|
+
this._startTimer();
|
|
1957
|
+
}
|
|
1958
|
+
/**
|
|
1959
|
+
* Stops monitoring for inactivity.
|
|
1960
|
+
*
|
|
1961
|
+
* Clears the pending inactivity timer. Does not change the lock state: if
|
|
1962
|
+
* the manager was locked, it remains locked. If unlocked, it remains unlocked.
|
|
1963
|
+
* To resume monitoring, call {@link start} again.
|
|
1964
|
+
*/
|
|
1965
|
+
stop() {
|
|
1966
|
+
this._isRunning = false;
|
|
1967
|
+
this._clearTimer();
|
|
1968
|
+
}
|
|
1969
|
+
/**
|
|
1970
|
+
* Reports user activity, resetting the inactivity timer.
|
|
1971
|
+
*
|
|
1972
|
+
* Call this whenever the user interacts with the application (clicks, key presses,
|
|
1973
|
+
* touches, etc.). If the manager is not running or is already locked, this is a no-op.
|
|
1974
|
+
*/
|
|
1975
|
+
reportActivity() {
|
|
1976
|
+
if (!this._isRunning || this._isLocked) {
|
|
1977
|
+
return;
|
|
1978
|
+
}
|
|
1979
|
+
this._clearTimer();
|
|
1980
|
+
this._startTimer();
|
|
1981
|
+
}
|
|
1982
|
+
/**
|
|
1983
|
+
* Manually locks the manager immediately.
|
|
1984
|
+
*
|
|
1985
|
+
* Clears the inactivity timer and transitions to the locked state. The `onLock`
|
|
1986
|
+
* callback is invoked. The manager remains running but locked: call {@link unlock}
|
|
1987
|
+
* to return to the unlocked state, which will restart the inactivity timer.
|
|
1988
|
+
*/
|
|
1989
|
+
lock() {
|
|
1990
|
+
this._clearTimer();
|
|
1991
|
+
if (!this._isLocked) {
|
|
1992
|
+
this._isLocked = true;
|
|
1993
|
+
this._onLock();
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
/**
|
|
1997
|
+
* Unlocks the manager, returning to the unlocked state.
|
|
1998
|
+
*
|
|
1999
|
+
* If the manager is running, the inactivity timer is restarted. If the manager
|
|
2000
|
+
* is not running (was stopped), it simply clears the locked state without
|
|
2001
|
+
* starting a timer.
|
|
2002
|
+
*/
|
|
2003
|
+
unlock() {
|
|
2004
|
+
this._isLocked = false;
|
|
2005
|
+
if (this._isRunning) {
|
|
2006
|
+
this._clearTimer();
|
|
2007
|
+
this._startTimer();
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
/**
|
|
2011
|
+
* Starts the inactivity timer. When it fires, the manager locks.
|
|
2012
|
+
*/
|
|
2013
|
+
_startTimer() {
|
|
2014
|
+
this._timerId = setTimeout(() => {
|
|
2015
|
+
this._timerId = null;
|
|
2016
|
+
this._isLocked = true;
|
|
2017
|
+
this._onLock();
|
|
2018
|
+
}, this._timeout);
|
|
2019
|
+
}
|
|
2020
|
+
/**
|
|
2021
|
+
* Clears the pending inactivity timer, if any.
|
|
2022
|
+
*/
|
|
2023
|
+
_clearTimer() {
|
|
2024
|
+
if (this._timerId !== null) {
|
|
2025
|
+
clearTimeout(this._timerId);
|
|
2026
|
+
this._timerId = null;
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
};
|
|
2030
|
+
|
|
2031
|
+
// src/encryption/operation-encryptor.ts
|
|
2032
|
+
var import_core9 = require("@korajs/core");
|
|
2033
|
+
var ENCRYPTED_MARKER = "__kora_encrypted";
|
|
2034
|
+
var ENCRYPTION_VERSION = 1;
|
|
2035
|
+
var OperationEncryptionError = class extends import_core9.KoraError {
|
|
2036
|
+
constructor(message, context) {
|
|
2037
|
+
super(message, "OPERATION_ENCRYPTION_ERROR", context);
|
|
2038
|
+
this.name = "OperationEncryptionError";
|
|
2039
|
+
}
|
|
2040
|
+
};
|
|
2041
|
+
function toBase64Url3(bytes) {
|
|
2042
|
+
let binary = "";
|
|
2043
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
2044
|
+
binary += String.fromCharCode(bytes[i]);
|
|
2045
|
+
}
|
|
2046
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
2047
|
+
}
|
|
2048
|
+
function fromBase64Url3(str) {
|
|
2049
|
+
let base64 = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
2050
|
+
const paddingNeeded = (4 - base64.length % 4) % 4;
|
|
2051
|
+
base64 += "=".repeat(paddingNeeded);
|
|
2052
|
+
const binary = atob(base64);
|
|
2053
|
+
const bytes = new Uint8Array(binary.length);
|
|
2054
|
+
for (let i = 0; i < binary.length; i++) {
|
|
2055
|
+
bytes[i] = binary.charCodeAt(i);
|
|
2056
|
+
}
|
|
2057
|
+
return bytes;
|
|
2058
|
+
}
|
|
2059
|
+
var OperationEncryptor = class {
|
|
2060
|
+
key;
|
|
2061
|
+
constructor(config) {
|
|
2062
|
+
this.key = config.key;
|
|
2063
|
+
}
|
|
2064
|
+
/**
|
|
2065
|
+
* Encrypt an operation's data fields.
|
|
2066
|
+
*
|
|
2067
|
+
* Returns a new Operation with `data` and `previousData` replaced by
|
|
2068
|
+
* encrypted envelopes. The original operation is not mutated.
|
|
2069
|
+
*
|
|
2070
|
+
* If `data` or `previousData` is null (e.g., delete operations),
|
|
2071
|
+
* that field remains null — there is nothing to encrypt.
|
|
2072
|
+
*
|
|
2073
|
+
* @param operation - The operation to encrypt
|
|
2074
|
+
* @returns A new operation with encrypted data fields
|
|
2075
|
+
* @throws {OperationEncryptionError} If encryption fails
|
|
2076
|
+
*/
|
|
2077
|
+
async encryptOperation(operation) {
|
|
2078
|
+
const [encryptedData, encryptedPreviousData] = await Promise.all([
|
|
2079
|
+
this.encryptField(operation.data, operation.id, "data"),
|
|
2080
|
+
this.encryptField(operation.previousData, operation.id, "previousData")
|
|
2081
|
+
]);
|
|
2082
|
+
return {
|
|
2083
|
+
...operation,
|
|
2084
|
+
data: encryptedData,
|
|
2085
|
+
previousData: encryptedPreviousData
|
|
2086
|
+
};
|
|
2087
|
+
}
|
|
2088
|
+
/**
|
|
2089
|
+
* Decrypt an operation's data fields.
|
|
2090
|
+
*
|
|
2091
|
+
* Returns a new Operation with the original `data` and `previousData`
|
|
2092
|
+
* restored from their encrypted envelopes. The original operation is
|
|
2093
|
+
* not mutated.
|
|
2094
|
+
*
|
|
2095
|
+
* If a field is null or not encrypted (no marker), it passes through unchanged.
|
|
2096
|
+
* This enables mixed plaintext/encrypted operations during migration.
|
|
2097
|
+
*
|
|
2098
|
+
* @param operation - The operation to decrypt
|
|
2099
|
+
* @returns A new operation with decrypted data fields
|
|
2100
|
+
* @throws {OperationEncryptionError} If decryption fails (wrong key, tampered data)
|
|
2101
|
+
*/
|
|
2102
|
+
async decryptOperation(operation) {
|
|
2103
|
+
const [decryptedData, decryptedPreviousData] = await Promise.all([
|
|
2104
|
+
this.decryptField(operation.data, operation.id, "data"),
|
|
2105
|
+
this.decryptField(operation.previousData, operation.id, "previousData")
|
|
2106
|
+
]);
|
|
2107
|
+
return {
|
|
2108
|
+
...operation,
|
|
2109
|
+
data: decryptedData,
|
|
2110
|
+
previousData: decryptedPreviousData
|
|
2111
|
+
};
|
|
2112
|
+
}
|
|
2113
|
+
/**
|
|
2114
|
+
* Check if an operation's data fields are encrypted.
|
|
2115
|
+
*
|
|
2116
|
+
* Returns true if either `data` or `previousData` contains an encrypted
|
|
2117
|
+
* envelope marker. Useful for determining whether decryption is needed
|
|
2118
|
+
* before applying an operation.
|
|
2119
|
+
*
|
|
2120
|
+
* @param operation - The operation to check
|
|
2121
|
+
* @returns true if any data field is encrypted
|
|
2122
|
+
*/
|
|
2123
|
+
isEncrypted(operation) {
|
|
2124
|
+
return isEncryptedEnvelope(operation.data) || isEncryptedEnvelope(operation.previousData);
|
|
2125
|
+
}
|
|
2126
|
+
/**
|
|
2127
|
+
* Encrypt a batch of operations.
|
|
2128
|
+
*
|
|
2129
|
+
* Convenience method for encrypting multiple operations at once.
|
|
2130
|
+
* Operations are encrypted in parallel for performance.
|
|
2131
|
+
*
|
|
2132
|
+
* @param operations - The operations to encrypt
|
|
2133
|
+
* @returns New operations with encrypted data fields
|
|
2134
|
+
*/
|
|
2135
|
+
async encryptBatch(operations) {
|
|
2136
|
+
return Promise.all(operations.map((op) => this.encryptOperation(op)));
|
|
2137
|
+
}
|
|
2138
|
+
/**
|
|
2139
|
+
* Decrypt a batch of operations.
|
|
2140
|
+
*
|
|
2141
|
+
* Convenience method for decrypting multiple operations at once.
|
|
2142
|
+
* Operations are decrypted in parallel for performance.
|
|
2143
|
+
*
|
|
2144
|
+
* @param operations - The operations to decrypt
|
|
2145
|
+
* @returns New operations with decrypted data fields
|
|
2146
|
+
*/
|
|
2147
|
+
async decryptBatch(operations) {
|
|
2148
|
+
return Promise.all(operations.map((op) => this.decryptOperation(op)));
|
|
2149
|
+
}
|
|
2150
|
+
// --- Private helpers ---
|
|
2151
|
+
async encryptField(field, operationId, fieldName) {
|
|
2152
|
+
if (field === null) {
|
|
2153
|
+
return null;
|
|
2154
|
+
}
|
|
2155
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(field));
|
|
2156
|
+
try {
|
|
2157
|
+
const { ciphertext, iv } = await encryptData(this.key, plaintext);
|
|
2158
|
+
const envelope = {
|
|
2159
|
+
[ENCRYPTED_MARKER]: true,
|
|
2160
|
+
ciphertext: toBase64Url3(ciphertext),
|
|
2161
|
+
iv: toBase64Url3(iv),
|
|
2162
|
+
algorithm: "AES-256-GCM",
|
|
2163
|
+
version: ENCRYPTION_VERSION
|
|
2164
|
+
};
|
|
2165
|
+
return envelope;
|
|
2166
|
+
} catch (cause) {
|
|
2167
|
+
if (cause instanceof OperationEncryptionError) {
|
|
2168
|
+
throw cause;
|
|
2169
|
+
}
|
|
2170
|
+
throw new OperationEncryptionError(
|
|
2171
|
+
`Failed to encrypt operation ${fieldName} field.`,
|
|
2172
|
+
{
|
|
2173
|
+
operationId,
|
|
2174
|
+
fieldName,
|
|
2175
|
+
cause: cause instanceof Error ? cause.message : String(cause)
|
|
2176
|
+
}
|
|
2177
|
+
);
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
async decryptField(field, operationId, fieldName) {
|
|
2181
|
+
if (field === null) {
|
|
2182
|
+
return null;
|
|
2183
|
+
}
|
|
2184
|
+
if (!isEncryptedEnvelope(field)) {
|
|
2185
|
+
return field;
|
|
2186
|
+
}
|
|
2187
|
+
const envelope = field;
|
|
2188
|
+
if (envelope.version > ENCRYPTION_VERSION) {
|
|
2189
|
+
throw new OperationEncryptionError(
|
|
2190
|
+
`Encrypted field uses version ${envelope.version}, but this client only supports version ${ENCRYPTION_VERSION}. Update your @korajs/auth package to decrypt this operation.`,
|
|
2191
|
+
{ operationId, fieldName, version: envelope.version }
|
|
2192
|
+
);
|
|
2193
|
+
}
|
|
2194
|
+
try {
|
|
2195
|
+
const ciphertext = fromBase64Url3(envelope.ciphertext);
|
|
2196
|
+
const iv = fromBase64Url3(envelope.iv);
|
|
2197
|
+
const plaintextBytes = await decryptData(this.key, ciphertext, iv);
|
|
2198
|
+
const json = new TextDecoder().decode(plaintextBytes);
|
|
2199
|
+
const parsed = JSON.parse(json);
|
|
2200
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
2201
|
+
throw new OperationEncryptionError(
|
|
2202
|
+
`Decrypted ${fieldName} is not a valid record object.`,
|
|
2203
|
+
{ operationId, fieldName }
|
|
2204
|
+
);
|
|
2205
|
+
}
|
|
2206
|
+
return parsed;
|
|
2207
|
+
} catch (cause) {
|
|
2208
|
+
if (cause instanceof OperationEncryptionError) {
|
|
2209
|
+
throw cause;
|
|
2210
|
+
}
|
|
2211
|
+
throw new OperationEncryptionError(
|
|
2212
|
+
`Failed to decrypt operation ${fieldName} field. This may indicate a wrong encryption key or tampered data.`,
|
|
2213
|
+
{
|
|
2214
|
+
operationId,
|
|
2215
|
+
fieldName,
|
|
2216
|
+
cause: cause instanceof Error ? cause.message : String(cause)
|
|
2217
|
+
}
|
|
2218
|
+
);
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
};
|
|
2222
|
+
function isEncryptedField(field) {
|
|
2223
|
+
return isEncryptedEnvelope(field);
|
|
2224
|
+
}
|
|
2225
|
+
function isEncryptedEnvelope(field) {
|
|
2226
|
+
if (field === null || typeof field !== "object") {
|
|
2227
|
+
return false;
|
|
2228
|
+
}
|
|
2229
|
+
return field[ENCRYPTED_MARKER] === true && typeof field["ciphertext"] === "string" && typeof field["iv"] === "string" && field["algorithm"] === "AES-256-GCM";
|
|
2230
|
+
}
|
|
725
2231
|
// Annotate the CommonJS export names for ESM import in node:
|
|
726
2232
|
0 && (module.exports = {
|
|
727
2233
|
AuthClient,
|
|
728
2234
|
AuthError,
|
|
2235
|
+
AutoLockManager,
|
|
729
2236
|
CryptoUnavailableError,
|
|
730
2237
|
DeviceIdentityError,
|
|
2238
|
+
DeviceKeyStoreError,
|
|
2239
|
+
EncryptedTokenStore,
|
|
2240
|
+
EncryptedTokenStoreError,
|
|
2241
|
+
EncryptionError,
|
|
2242
|
+
InMemoryDeviceKeyStore,
|
|
2243
|
+
IndexedDBDeviceKeyStore,
|
|
2244
|
+
KeyDerivationError,
|
|
2245
|
+
OperationEncryptionError,
|
|
2246
|
+
OperationEncryptor,
|
|
2247
|
+
OrgClient,
|
|
2248
|
+
OrgClientError,
|
|
2249
|
+
PasskeyError,
|
|
2250
|
+
PasskeyUnsupportedError,
|
|
731
2251
|
TokenStore,
|
|
2252
|
+
authenticateWithPasskey,
|
|
732
2253
|
computePublicKeyThumbprint,
|
|
2254
|
+
createDeviceKeyStore,
|
|
2255
|
+
createPasskeyCredential,
|
|
2256
|
+
decryptData,
|
|
2257
|
+
deriveEncryptionKey,
|
|
2258
|
+
encryptData,
|
|
2259
|
+
exportKey,
|
|
733
2260
|
exportPublicKeyJwk,
|
|
734
2261
|
fromBase64Url,
|
|
735
2262
|
generateDeviceKeyPair,
|
|
2263
|
+
generateEncryptionKey,
|
|
2264
|
+
generateSalt,
|
|
2265
|
+
importKey,
|
|
2266
|
+
isEncryptedField,
|
|
2267
|
+
isPasskeySupported,
|
|
2268
|
+
isPlatformAuthenticatorAvailable,
|
|
736
2269
|
signChallenge,
|
|
737
2270
|
toBase64Url,
|
|
738
2271
|
verifyChallenge
|