@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/dist/index.js CHANGED
@@ -1,14 +1,29 @@
1
1
  import {
2
2
  CryptoUnavailableError,
3
3
  DeviceIdentityError,
4
+ EncryptionError,
5
+ OperationEncryptionError,
6
+ OperationEncryptor,
7
+ PasskeyError,
8
+ PasskeyUnsupportedError,
9
+ authenticateWithPasskey,
4
10
  computePublicKeyThumbprint,
11
+ createPasskeyCredential,
12
+ decryptData,
13
+ encryptData,
14
+ exportKey,
5
15
  exportPublicKeyJwk,
6
16
  fromBase64Url,
7
17
  generateDeviceKeyPair,
18
+ generateEncryptionKey,
19
+ importKey,
20
+ isEncryptedField,
21
+ isPasskeySupported,
22
+ isPlatformAuthenticatorAvailable,
8
23
  signChallenge,
9
24
  toBase64Url,
10
25
  verifyChallenge
11
- } from "./chunk-L554ZDPY.js";
26
+ } from "./chunk-FSU4SK32.js";
12
27
 
13
28
  // src/client/auth-client.ts
14
29
  import { KoraError } from "@korajs/core";
@@ -104,6 +119,7 @@ var AuthClient = class {
104
119
  _state = "loading";
105
120
  _user = null;
106
121
  _refreshPromise = null;
122
+ _initialized = false;
107
123
  /**
108
124
  * Creates a new AuthClient.
109
125
  *
@@ -140,6 +156,10 @@ var AuthClient = class {
140
156
  * Safe to call multiple times -- subsequent calls are no-ops once initialized.
141
157
  */
142
158
  async initialize() {
159
+ if (this._initialized) {
160
+ return;
161
+ }
162
+ this._initialized = true;
143
163
  const accessToken = this.storage.getAccessToken();
144
164
  const refreshToken = this.storage.getRefreshToken();
145
165
  if (!accessToken || !refreshToken) {
@@ -201,13 +221,26 @@ var AuthClient = class {
201
221
  /**
202
222
  * Sign out the current user.
203
223
  *
204
- * Clears local tokens and state. Does not make a network request to the
205
- * server -- tokens are simply discarded locally.
224
+ * Clears local tokens and attempts to revoke the refresh token on the server
225
+ * (best-effort succeeds even if the server is unreachable). This ensures that
226
+ * stolen refresh tokens cannot be used after the user explicitly signs out.
206
227
  */
207
228
  async signOut() {
229
+ const accessToken = this.storage.getAccessToken();
230
+ const refreshToken = this.storage.getRefreshToken();
208
231
  this.storage.clear();
209
232
  this._refreshPromise = null;
210
233
  this.setState("unauthenticated", null);
234
+ if (accessToken) {
235
+ try {
236
+ await this.request("/auth/signout", {
237
+ method: "POST",
238
+ body: { refreshToken: refreshToken ?? void 0 },
239
+ token: accessToken
240
+ });
241
+ } catch {
242
+ }
243
+ }
211
244
  }
212
245
  // -----------------------------------------------------------------------
213
246
  // Token access
@@ -414,6 +447,492 @@ var AuthClient = class {
414
447
  }
415
448
  };
416
449
 
450
+ // src/client/org-client.ts
451
+ import { KoraError as KoraError2 } from "@korajs/core";
452
+ var OrgClientError = class extends KoraError2 {
453
+ constructor(message, code, context) {
454
+ super(message, code, context);
455
+ this.name = "OrgClientError";
456
+ }
457
+ };
458
+ var OrgClient = class {
459
+ serverUrl;
460
+ getAccessToken;
461
+ listeners = /* @__PURE__ */ new Set();
462
+ _activeOrgId = null;
463
+ _activeOrg = null;
464
+ _activeRole = null;
465
+ constructor(config) {
466
+ this.serverUrl = config.serverUrl.replace(/\/+$/, "");
467
+ this.getAccessToken = config.getAccessToken;
468
+ }
469
+ // --- Getters ---
470
+ /** Currently active organization ID */
471
+ get activeOrgId() {
472
+ return this._activeOrgId;
473
+ }
474
+ /** Currently active organization */
475
+ get activeOrg() {
476
+ return this._activeOrg;
477
+ }
478
+ /** Current user's role in the active organization */
479
+ get activeRole() {
480
+ return this._activeRole;
481
+ }
482
+ // --- Organization Operations ---
483
+ /**
484
+ * Create a new organization.
485
+ */
486
+ async createOrg(params) {
487
+ return this.request("/orgs", {
488
+ method: "POST",
489
+ body: params
490
+ });
491
+ }
492
+ /**
493
+ * List all organizations the current user belongs to.
494
+ */
495
+ async listOrgs() {
496
+ return this.request("/orgs", { method: "GET" });
497
+ }
498
+ /**
499
+ * Get an organization by ID.
500
+ */
501
+ async getOrg(orgId) {
502
+ return this.request(`/orgs/${orgId}`, { method: "GET" });
503
+ }
504
+ /**
505
+ * Update an organization.
506
+ */
507
+ async updateOrg(orgId, params) {
508
+ const result = await this.request(`/orgs/${orgId}`, {
509
+ method: "PATCH",
510
+ body: params
511
+ });
512
+ if (this._activeOrgId === orgId) {
513
+ this._activeOrg = result;
514
+ }
515
+ return result;
516
+ }
517
+ /**
518
+ * Delete an organization.
519
+ */
520
+ async deleteOrg(orgId) {
521
+ await this.request(`/orgs/${orgId}`, { method: "DELETE" });
522
+ if (this._activeOrgId === orgId) {
523
+ this._activeOrgId = null;
524
+ this._activeOrg = null;
525
+ this._activeRole = null;
526
+ this.notifyListeners();
527
+ }
528
+ }
529
+ // --- Org Switching ---
530
+ /**
531
+ * Switch the active organization context.
532
+ * Fetches the org details and the user's membership/role.
533
+ */
534
+ async switchOrg(orgId) {
535
+ const org = await this.request(`/orgs/${orgId}`, { method: "GET" });
536
+ const membership = await this.request(`/orgs/${orgId}/membership`, {
537
+ method: "GET"
538
+ });
539
+ this._activeOrgId = orgId;
540
+ this._activeOrg = org;
541
+ this._activeRole = membership.role;
542
+ this.notifyListeners();
543
+ }
544
+ /**
545
+ * Clear the active organization (no org selected).
546
+ */
547
+ clearActiveOrg() {
548
+ this._activeOrgId = null;
549
+ this._activeOrg = null;
550
+ this._activeRole = null;
551
+ this.notifyListeners();
552
+ }
553
+ // --- Member Management ---
554
+ /**
555
+ * List members of an organization.
556
+ */
557
+ async listMembers(orgId) {
558
+ return this.request(`/orgs/${orgId}/members`, { method: "GET" });
559
+ }
560
+ /**
561
+ * Remove a member from an organization.
562
+ */
563
+ async removeMember(orgId, userId) {
564
+ await this.request(`/orgs/${orgId}/members/${userId}`, { method: "DELETE" });
565
+ }
566
+ /**
567
+ * Update a member's role.
568
+ */
569
+ async updateMemberRole(orgId, userId, role) {
570
+ return this.request(`/orgs/${orgId}/members/${userId}/role`, {
571
+ method: "PATCH",
572
+ body: { role }
573
+ });
574
+ }
575
+ /**
576
+ * Transfer ownership to another member.
577
+ */
578
+ async transferOwnership(orgId, newOwnerId) {
579
+ await this.request(`/orgs/${orgId}/transfer`, {
580
+ method: "POST",
581
+ body: { newOwnerId }
582
+ });
583
+ }
584
+ /**
585
+ * Leave an organization (remove yourself).
586
+ */
587
+ async leaveOrg(orgId) {
588
+ await this.request(`/orgs/${orgId}/leave`, { method: "POST" });
589
+ if (this._activeOrgId === orgId) {
590
+ this._activeOrgId = null;
591
+ this._activeOrg = null;
592
+ this._activeRole = null;
593
+ this.notifyListeners();
594
+ }
595
+ }
596
+ // --- Invitations ---
597
+ /**
598
+ * Invite a user to an organization by email.
599
+ */
600
+ async inviteMember(orgId, params) {
601
+ return this.request(`/orgs/${orgId}/invitations`, {
602
+ method: "POST",
603
+ body: params
604
+ });
605
+ }
606
+ /**
607
+ * Accept an invitation by token.
608
+ */
609
+ async acceptInvitation(token) {
610
+ return this.request("/invitations/accept", {
611
+ method: "POST",
612
+ body: { token }
613
+ });
614
+ }
615
+ /**
616
+ * List pending invitations for an organization.
617
+ */
618
+ async listInvitations(orgId) {
619
+ return this.request(`/orgs/${orgId}/invitations`, { method: "GET" });
620
+ }
621
+ /**
622
+ * Revoke a pending invitation.
623
+ */
624
+ async revokeInvitation(orgId, invitationId) {
625
+ await this.request(`/orgs/${orgId}/invitations/${invitationId}`, { method: "DELETE" });
626
+ }
627
+ /**
628
+ * List pending invitations for the current user's email.
629
+ */
630
+ async listMyInvitations(email) {
631
+ return this.request(
632
+ `/invitations?email=${encodeURIComponent(email)}`,
633
+ { method: "GET" }
634
+ );
635
+ }
636
+ // --- Subscriptions ---
637
+ /**
638
+ * Subscribe to active org changes.
639
+ * @returns Unsubscribe function
640
+ */
641
+ onOrgChange(callback) {
642
+ this.listeners.add(callback);
643
+ return () => {
644
+ this.listeners.delete(callback);
645
+ };
646
+ }
647
+ // --- Internal ---
648
+ notifyListeners() {
649
+ for (const listener of this.listeners) {
650
+ try {
651
+ listener(this._activeOrgId);
652
+ } catch {
653
+ }
654
+ }
655
+ }
656
+ async request(path, options) {
657
+ const token = await this.getAccessToken();
658
+ if (!token) {
659
+ throw new OrgClientError(
660
+ "Not authenticated. Sign in before performing organization operations.",
661
+ "ORG_NOT_AUTHENTICATED"
662
+ );
663
+ }
664
+ const url = `${this.serverUrl}${path}`;
665
+ const headers = {
666
+ Authorization: `Bearer ${token}`
667
+ };
668
+ if (options.body) {
669
+ headers["Content-Type"] = "application/json";
670
+ }
671
+ let response;
672
+ try {
673
+ response = await fetch(url, {
674
+ method: options.method,
675
+ headers,
676
+ body: options.body ? JSON.stringify(options.body) : void 0
677
+ });
678
+ } catch (cause) {
679
+ throw new OrgClientError(
680
+ `Network request to ${path} failed.`,
681
+ "ORG_NETWORK_ERROR",
682
+ { path, cause: cause instanceof Error ? cause.message : String(cause) }
683
+ );
684
+ }
685
+ if (!response.ok) {
686
+ let errorMessage = `Server returned HTTP ${response.status}`;
687
+ try {
688
+ const body = await response.json();
689
+ if (typeof body["error"] === "string") {
690
+ errorMessage = body["error"];
691
+ }
692
+ } catch {
693
+ }
694
+ throw new OrgClientError(errorMessage, "ORG_SERVER_ERROR", {
695
+ path,
696
+ status: response.status
697
+ });
698
+ }
699
+ const text = await response.text();
700
+ if (text.length === 0) return void 0;
701
+ try {
702
+ const body = JSON.parse(text);
703
+ if (body && typeof body === "object" && "data" in body) {
704
+ return body.data;
705
+ }
706
+ return body;
707
+ } catch {
708
+ return void 0;
709
+ }
710
+ }
711
+ };
712
+
713
+ // src/device/device-store.ts
714
+ import { KoraError as KoraError3 } from "@korajs/core";
715
+ var DeviceKeyStoreError = class extends KoraError3 {
716
+ constructor(message, context) {
717
+ super(message, "DEVICE_KEY_STORE_ERROR", context);
718
+ this.name = "DeviceKeyStoreError";
719
+ }
720
+ };
721
+ var IDB_DATABASE_NAME = "kora_device_keys";
722
+ var IDB_STORE_NAME = "keypairs";
723
+ var IDB_VERSION = 1;
724
+ var IndexedDBDeviceKeyStore = class {
725
+ dbPromise = null;
726
+ /**
727
+ * Opens (or creates) the IndexedDB database.
728
+ *
729
+ * The database connection is lazily initialized on first use and
730
+ * reused for subsequent operations. If the database does not exist,
731
+ * it is created with the `keypairs` object store.
732
+ */
733
+ openDatabase() {
734
+ if (this.dbPromise !== null) {
735
+ return this.dbPromise;
736
+ }
737
+ this.dbPromise = new Promise((resolve, reject) => {
738
+ let request;
739
+ try {
740
+ request = globalThis.indexedDB.open(IDB_DATABASE_NAME, IDB_VERSION);
741
+ } catch (cause) {
742
+ this.dbPromise = null;
743
+ reject(
744
+ new DeviceKeyStoreError(
745
+ "Failed to open IndexedDB database for device key storage. IndexedDB may be unavailable or access may be denied.",
746
+ { cause: cause instanceof Error ? cause.message : String(cause) }
747
+ )
748
+ );
749
+ return;
750
+ }
751
+ request.onupgradeneeded = () => {
752
+ const db = request.result;
753
+ if (!db.objectStoreNames.contains(IDB_STORE_NAME)) {
754
+ db.createObjectStore(IDB_STORE_NAME);
755
+ }
756
+ };
757
+ request.onsuccess = () => {
758
+ resolve(request.result);
759
+ };
760
+ request.onerror = () => {
761
+ this.dbPromise = null;
762
+ reject(
763
+ new DeviceKeyStoreError(
764
+ "Failed to open IndexedDB database for device key storage.",
765
+ { error: request.error?.message }
766
+ )
767
+ );
768
+ };
769
+ request.onblocked = () => {
770
+ this.dbPromise = null;
771
+ reject(
772
+ new DeviceKeyStoreError(
773
+ "IndexedDB database open was blocked. Another tab may have an older version of the database open. Close other tabs and try again."
774
+ )
775
+ );
776
+ };
777
+ });
778
+ return this.dbPromise;
779
+ }
780
+ /** @inheritdoc */
781
+ async saveKeyPair(deviceId, keyPair) {
782
+ const db = await this.openDatabase();
783
+ return new Promise((resolve, reject) => {
784
+ try {
785
+ const tx = db.transaction(IDB_STORE_NAME, "readwrite");
786
+ const store = tx.objectStore(IDB_STORE_NAME);
787
+ store.put(keyPair, deviceId);
788
+ tx.oncomplete = () => {
789
+ resolve();
790
+ };
791
+ tx.onerror = () => {
792
+ reject(
793
+ new DeviceKeyStoreError(
794
+ `Failed to save key pair for device "${deviceId}".`,
795
+ { deviceId, error: tx.error?.message }
796
+ )
797
+ );
798
+ };
799
+ } catch (cause) {
800
+ reject(
801
+ new DeviceKeyStoreError(
802
+ `Failed to save key pair for device "${deviceId}".`,
803
+ {
804
+ deviceId,
805
+ cause: cause instanceof Error ? cause.message : String(cause)
806
+ }
807
+ )
808
+ );
809
+ }
810
+ });
811
+ }
812
+ /** @inheritdoc */
813
+ async loadKeyPair(deviceId) {
814
+ const db = await this.openDatabase();
815
+ return new Promise((resolve, reject) => {
816
+ try {
817
+ const tx = db.transaction(IDB_STORE_NAME, "readonly");
818
+ const store = tx.objectStore(IDB_STORE_NAME);
819
+ const request = store.get(deviceId);
820
+ request.onsuccess = () => {
821
+ const result = request.result;
822
+ resolve(result ?? null);
823
+ };
824
+ request.onerror = () => {
825
+ reject(
826
+ new DeviceKeyStoreError(
827
+ `Failed to load key pair for device "${deviceId}".`,
828
+ { deviceId, error: request.error?.message }
829
+ )
830
+ );
831
+ };
832
+ } catch (cause) {
833
+ reject(
834
+ new DeviceKeyStoreError(
835
+ `Failed to load key pair for device "${deviceId}".`,
836
+ {
837
+ deviceId,
838
+ cause: cause instanceof Error ? cause.message : String(cause)
839
+ }
840
+ )
841
+ );
842
+ }
843
+ });
844
+ }
845
+ /** @inheritdoc */
846
+ async deleteKeyPair(deviceId) {
847
+ const db = await this.openDatabase();
848
+ return new Promise((resolve, reject) => {
849
+ try {
850
+ const tx = db.transaction(IDB_STORE_NAME, "readwrite");
851
+ const store = tx.objectStore(IDB_STORE_NAME);
852
+ store.delete(deviceId);
853
+ tx.oncomplete = () => {
854
+ resolve();
855
+ };
856
+ tx.onerror = () => {
857
+ reject(
858
+ new DeviceKeyStoreError(
859
+ `Failed to delete key pair for device "${deviceId}".`,
860
+ { deviceId, error: tx.error?.message }
861
+ )
862
+ );
863
+ };
864
+ } catch (cause) {
865
+ reject(
866
+ new DeviceKeyStoreError(
867
+ `Failed to delete key pair for device "${deviceId}".`,
868
+ {
869
+ deviceId,
870
+ cause: cause instanceof Error ? cause.message : String(cause)
871
+ }
872
+ )
873
+ );
874
+ }
875
+ });
876
+ }
877
+ /** @inheritdoc */
878
+ async hasKeyPair(deviceId) {
879
+ const db = await this.openDatabase();
880
+ return new Promise((resolve, reject) => {
881
+ try {
882
+ const tx = db.transaction(IDB_STORE_NAME, "readonly");
883
+ const store = tx.objectStore(IDB_STORE_NAME);
884
+ const request = store.count(deviceId);
885
+ request.onsuccess = () => {
886
+ resolve(request.result > 0);
887
+ };
888
+ request.onerror = () => {
889
+ reject(
890
+ new DeviceKeyStoreError(
891
+ `Failed to check if key pair exists for device "${deviceId}".`,
892
+ { deviceId, error: request.error?.message }
893
+ )
894
+ );
895
+ };
896
+ } catch (cause) {
897
+ reject(
898
+ new DeviceKeyStoreError(
899
+ `Failed to check if key pair exists for device "${deviceId}".`,
900
+ {
901
+ deviceId,
902
+ cause: cause instanceof Error ? cause.message : String(cause)
903
+ }
904
+ )
905
+ );
906
+ }
907
+ });
908
+ }
909
+ };
910
+ var InMemoryDeviceKeyStore = class {
911
+ store = /* @__PURE__ */ new Map();
912
+ /** @inheritdoc */
913
+ async saveKeyPair(deviceId, keyPair) {
914
+ this.store.set(deviceId, keyPair);
915
+ }
916
+ /** @inheritdoc */
917
+ async loadKeyPair(deviceId) {
918
+ return this.store.get(deviceId) ?? null;
919
+ }
920
+ /** @inheritdoc */
921
+ async deleteKeyPair(deviceId) {
922
+ this.store.delete(deviceId);
923
+ }
924
+ /** @inheritdoc */
925
+ async hasKeyPair(deviceId) {
926
+ return this.store.has(deviceId);
927
+ }
928
+ };
929
+ function createDeviceKeyStore() {
930
+ if (typeof globalThis.indexedDB !== "undefined") {
931
+ return new IndexedDBDeviceKeyStore();
932
+ }
933
+ return new InMemoryDeviceKeyStore();
934
+ }
935
+
417
936
  // src/tokens/token-store.ts
418
937
  var DEFAULT_STORAGE_KEY = "kora_auth_tokens";
419
938
  var MemoryStorage = class {
@@ -535,16 +1054,420 @@ var TokenStore = class {
535
1054
  return tokens?.refreshToken ?? null;
536
1055
  }
537
1056
  };
1057
+
1058
+ // src/tokens/encrypted-token-store.ts
1059
+ import { KoraError as KoraError4 } from "@korajs/core";
1060
+ var EncryptedTokenStoreError = class extends KoraError4 {
1061
+ constructor(message, context) {
1062
+ super(message, "ENCRYPTED_TOKEN_STORE_ERROR", context);
1063
+ this.name = "EncryptedTokenStoreError";
1064
+ }
1065
+ };
1066
+ function toBase64Url2(bytes) {
1067
+ let binary = "";
1068
+ for (let i = 0; i < bytes.length; i++) {
1069
+ binary += String.fromCharCode(bytes[i]);
1070
+ }
1071
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1072
+ }
1073
+ function fromBase64Url2(str) {
1074
+ let base64 = str.replace(/-/g, "+").replace(/_/g, "/");
1075
+ const paddingNeeded = (4 - base64.length % 4) % 4;
1076
+ base64 += "=".repeat(paddingNeeded);
1077
+ const binary = atob(base64);
1078
+ const bytes = new Uint8Array(binary.length);
1079
+ for (let i = 0; i < binary.length; i++) {
1080
+ bytes[i] = binary.charCodeAt(i);
1081
+ }
1082
+ return bytes;
1083
+ }
1084
+ var MemoryStorage2 = class {
1085
+ store = /* @__PURE__ */ new Map();
1086
+ getItem(key) {
1087
+ return this.store.get(key) ?? null;
1088
+ }
1089
+ setItem(key, value) {
1090
+ this.store.set(key, value);
1091
+ }
1092
+ removeItem(key) {
1093
+ this.store.delete(key);
1094
+ }
1095
+ };
1096
+ function tryGetLocalStorage2() {
1097
+ try {
1098
+ if (typeof globalThis !== "undefined" && "localStorage" in globalThis) {
1099
+ const storage = globalThis.localStorage;
1100
+ const testKey = "__kora_encrypted_storage_test__";
1101
+ storage.setItem(testKey, "1");
1102
+ storage.removeItem(testKey);
1103
+ return storage;
1104
+ }
1105
+ } catch {
1106
+ }
1107
+ return null;
1108
+ }
1109
+ var DEFAULT_STORAGE_KEY2 = "kora_auth_encrypted";
1110
+ var EncryptedTokenStore = class {
1111
+ storageKey;
1112
+ key;
1113
+ storage;
1114
+ /**
1115
+ * Creates a new EncryptedTokenStore instance.
1116
+ *
1117
+ * @param config - Configuration including the encryption key and optional storage key
1118
+ */
1119
+ constructor(config) {
1120
+ this.storageKey = config.storageKey ?? DEFAULT_STORAGE_KEY2;
1121
+ this.key = config.key;
1122
+ this.storage = tryGetLocalStorage2() ?? new MemoryStorage2();
1123
+ }
1124
+ /**
1125
+ * Encrypt and save tokens to persistent storage.
1126
+ *
1127
+ * Serializes the tokens as JSON, encrypts with AES-256-GCM using a fresh
1128
+ * random IV, then stores the result as a JSON object containing the
1129
+ * base64url-encoded IV and ciphertext.
1130
+ *
1131
+ * Overwrites any previously stored tokens.
1132
+ *
1133
+ * @param tokens - The token set to encrypt and store
1134
+ * @throws {EncryptedTokenStoreError} If encryption fails
1135
+ */
1136
+ async saveTokens(tokens) {
1137
+ const serialized = {
1138
+ accessToken: tokens.accessToken,
1139
+ refreshToken: tokens.refreshToken
1140
+ };
1141
+ if (tokens.deviceCredential !== void 0) {
1142
+ serialized.deviceCredential = tokens.deviceCredential;
1143
+ }
1144
+ const plaintext = new TextEncoder().encode(JSON.stringify(serialized));
1145
+ try {
1146
+ const { ciphertext, iv } = await encryptData(this.key, plaintext);
1147
+ const payload = {
1148
+ iv: toBase64Url2(iv),
1149
+ data: toBase64Url2(ciphertext)
1150
+ };
1151
+ this.storage.setItem(this.storageKey, JSON.stringify(payload));
1152
+ } catch (cause) {
1153
+ if (cause instanceof EncryptedTokenStoreError) {
1154
+ throw cause;
1155
+ }
1156
+ throw new EncryptedTokenStoreError(
1157
+ "Failed to encrypt and save auth tokens. Ensure the encryption key is a valid AES-256-GCM CryptoKey.",
1158
+ { cause: cause instanceof Error ? cause.message : String(cause) }
1159
+ );
1160
+ }
1161
+ }
1162
+ /**
1163
+ * Load and decrypt tokens from storage.
1164
+ *
1165
+ * Reads the encrypted payload from localStorage, decodes the base64url IV
1166
+ * and ciphertext, decrypts with AES-256-GCM, and parses the resulting JSON.
1167
+ *
1168
+ * Returns null (without throwing) if:
1169
+ * - No tokens have been saved
1170
+ * - The stored data is corrupted or not valid JSON
1171
+ * - Decryption fails (wrong key, tampered ciphertext, or wrong IV)
1172
+ * - The decrypted data does not contain valid token fields
1173
+ *
1174
+ * This fail-silent design prevents decryption errors from crashing the
1175
+ * application. The caller should treat null as "no valid tokens available"
1176
+ * and initiate a re-authentication flow.
1177
+ *
1178
+ * @returns The decrypted {@link AuthTokens}, or null if unavailable or decryption fails
1179
+ */
1180
+ async loadTokens() {
1181
+ const raw = this.storage.getItem(this.storageKey);
1182
+ if (raw === null) {
1183
+ return null;
1184
+ }
1185
+ try {
1186
+ const parsed = JSON.parse(raw);
1187
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1188
+ return null;
1189
+ }
1190
+ const record = parsed;
1191
+ if (typeof record["iv"] !== "string" || typeof record["data"] !== "string") {
1192
+ return null;
1193
+ }
1194
+ const iv = fromBase64Url2(record["iv"]);
1195
+ const ciphertext = fromBase64Url2(record["data"]);
1196
+ const plaintextBytes = await decryptData(this.key, ciphertext, iv);
1197
+ const json = new TextDecoder().decode(plaintextBytes);
1198
+ const tokenData = JSON.parse(json);
1199
+ if (typeof tokenData !== "object" || tokenData === null || Array.isArray(tokenData)) {
1200
+ return null;
1201
+ }
1202
+ const tokenRecord = tokenData;
1203
+ if (typeof tokenRecord["accessToken"] !== "string" || typeof tokenRecord["refreshToken"] !== "string") {
1204
+ return null;
1205
+ }
1206
+ const tokens = {
1207
+ accessToken: tokenRecord["accessToken"],
1208
+ refreshToken: tokenRecord["refreshToken"]
1209
+ };
1210
+ if (typeof tokenRecord["deviceCredential"] === "string") {
1211
+ tokens.deviceCredential = tokenRecord["deviceCredential"];
1212
+ }
1213
+ return tokens;
1214
+ } catch {
1215
+ return null;
1216
+ }
1217
+ }
1218
+ /**
1219
+ * Clear all stored encrypted tokens.
1220
+ *
1221
+ * Removes the encrypted payload from storage. This is a synchronous
1222
+ * operation since it only removes the localStorage entry.
1223
+ *
1224
+ * Call this on logout to ensure no encrypted credential material
1225
+ * remains in persistent storage.
1226
+ */
1227
+ clearTokens() {
1228
+ this.storage.removeItem(this.storageKey);
1229
+ }
1230
+ /**
1231
+ * Get the current access token by decrypting stored tokens.
1232
+ *
1233
+ * Returns the raw token string without validating expiration.
1234
+ * The caller is responsible for checking whether the token is
1235
+ * still valid and initiating a refresh if needed.
1236
+ *
1237
+ * @returns The decrypted access token string, or null if no valid tokens are stored
1238
+ */
1239
+ async getAccessToken() {
1240
+ const tokens = await this.loadTokens();
1241
+ return tokens?.accessToken ?? null;
1242
+ }
1243
+ /**
1244
+ * Get the current refresh token by decrypting stored tokens.
1245
+ *
1246
+ * @returns The decrypted refresh token string, or null if no valid tokens are stored
1247
+ */
1248
+ async getRefreshToken() {
1249
+ const tokens = await this.loadTokens();
1250
+ return tokens?.refreshToken ?? null;
1251
+ }
1252
+ };
1253
+
1254
+ // src/encryption/key-derivation.ts
1255
+ import { KoraError as KoraError5 } from "@korajs/core";
1256
+ var KeyDerivationError = class extends KoraError5 {
1257
+ constructor(message, context) {
1258
+ super(message, "KEY_DERIVATION_ERROR", context);
1259
+ this.name = "KeyDerivationError";
1260
+ }
1261
+ };
1262
+ var SALT_LENGTH = 32;
1263
+ var PBKDF2_ITERATIONS = 6e5;
1264
+ var DERIVED_KEY_LENGTH = 256;
1265
+ function assertCryptoAvailable() {
1266
+ if (typeof globalThis.crypto === "undefined" || typeof globalThis.crypto.subtle === "undefined") {
1267
+ throw new KeyDerivationError(
1268
+ "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+."
1269
+ );
1270
+ }
1271
+ }
1272
+ function generateSalt() {
1273
+ if (typeof globalThis.crypto === "undefined") {
1274
+ throw new KeyDerivationError(
1275
+ "Web Crypto API (crypto) is not available. Cannot generate random salt."
1276
+ );
1277
+ }
1278
+ return globalThis.crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
1279
+ }
1280
+ async function deriveEncryptionKey(passphrase, salt) {
1281
+ assertCryptoAvailable();
1282
+ if (passphrase.length === 0) {
1283
+ throw new KeyDerivationError(
1284
+ "Passphrase must not be empty. Provide a non-empty string for key derivation."
1285
+ );
1286
+ }
1287
+ const usedSalt = salt ?? generateSalt();
1288
+ try {
1289
+ const passphraseBytes = new TextEncoder().encode(passphrase);
1290
+ const baseKey = await globalThis.crypto.subtle.importKey(
1291
+ "raw",
1292
+ passphraseBytes,
1293
+ "PBKDF2",
1294
+ false,
1295
+ ["deriveBits", "deriveKey"]
1296
+ );
1297
+ const derivedKey = await globalThis.crypto.subtle.deriveKey(
1298
+ {
1299
+ name: "PBKDF2",
1300
+ salt: usedSalt,
1301
+ iterations: PBKDF2_ITERATIONS,
1302
+ hash: "SHA-256"
1303
+ },
1304
+ baseKey,
1305
+ { name: "AES-GCM", length: DERIVED_KEY_LENGTH },
1306
+ // extractable: true so the derived key can be exported if needed
1307
+ true,
1308
+ ["encrypt", "decrypt"]
1309
+ );
1310
+ return { key: derivedKey, salt: usedSalt };
1311
+ } catch (cause) {
1312
+ if (cause instanceof KeyDerivationError) {
1313
+ throw cause;
1314
+ }
1315
+ throw new KeyDerivationError(
1316
+ "Failed to derive encryption key from passphrase using PBKDF2. Ensure the runtime supports PBKDF2 with SHA-256.",
1317
+ { cause: cause instanceof Error ? cause.message : String(cause) }
1318
+ );
1319
+ }
1320
+ }
1321
+
1322
+ // src/encryption/auto-lock.ts
1323
+ var AutoLockManager = class {
1324
+ _timeout;
1325
+ _onLock;
1326
+ _timerId = null;
1327
+ _isLocked = false;
1328
+ _isRunning = false;
1329
+ constructor(config) {
1330
+ if (config.timeout <= 0) {
1331
+ throw new Error(
1332
+ `AutoLockManager timeout must be a positive number, but received ${config.timeout}.`
1333
+ );
1334
+ }
1335
+ this._timeout = config.timeout;
1336
+ this._onLock = config.onLock;
1337
+ }
1338
+ /**
1339
+ * Whether the manager is currently in the locked state.
1340
+ *
1341
+ * Becomes `true` when the inactivity timeout fires or {@link lock} is called manually.
1342
+ * Returns to `false` only when {@link unlock} is called.
1343
+ */
1344
+ get isLocked() {
1345
+ return this._isLocked;
1346
+ }
1347
+ /**
1348
+ * Starts monitoring for inactivity.
1349
+ *
1350
+ * Begins the inactivity countdown. If the manager is already running, this is
1351
+ * a no-op to prevent creating multiple timers. Calling `start()` also resets
1352
+ * the locked state if the manager was previously locked.
1353
+ */
1354
+ start() {
1355
+ if (this._isRunning) {
1356
+ return;
1357
+ }
1358
+ this._isRunning = true;
1359
+ this._isLocked = false;
1360
+ this._startTimer();
1361
+ }
1362
+ /**
1363
+ * Stops monitoring for inactivity.
1364
+ *
1365
+ * Clears the pending inactivity timer. Does not change the lock state: if
1366
+ * the manager was locked, it remains locked. If unlocked, it remains unlocked.
1367
+ * To resume monitoring, call {@link start} again.
1368
+ */
1369
+ stop() {
1370
+ this._isRunning = false;
1371
+ this._clearTimer();
1372
+ }
1373
+ /**
1374
+ * Reports user activity, resetting the inactivity timer.
1375
+ *
1376
+ * Call this whenever the user interacts with the application (clicks, key presses,
1377
+ * touches, etc.). If the manager is not running or is already locked, this is a no-op.
1378
+ */
1379
+ reportActivity() {
1380
+ if (!this._isRunning || this._isLocked) {
1381
+ return;
1382
+ }
1383
+ this._clearTimer();
1384
+ this._startTimer();
1385
+ }
1386
+ /**
1387
+ * Manually locks the manager immediately.
1388
+ *
1389
+ * Clears the inactivity timer and transitions to the locked state. The `onLock`
1390
+ * callback is invoked. The manager remains running but locked: call {@link unlock}
1391
+ * to return to the unlocked state, which will restart the inactivity timer.
1392
+ */
1393
+ lock() {
1394
+ this._clearTimer();
1395
+ if (!this._isLocked) {
1396
+ this._isLocked = true;
1397
+ this._onLock();
1398
+ }
1399
+ }
1400
+ /**
1401
+ * Unlocks the manager, returning to the unlocked state.
1402
+ *
1403
+ * If the manager is running, the inactivity timer is restarted. If the manager
1404
+ * is not running (was stopped), it simply clears the locked state without
1405
+ * starting a timer.
1406
+ */
1407
+ unlock() {
1408
+ this._isLocked = false;
1409
+ if (this._isRunning) {
1410
+ this._clearTimer();
1411
+ this._startTimer();
1412
+ }
1413
+ }
1414
+ /**
1415
+ * Starts the inactivity timer. When it fires, the manager locks.
1416
+ */
1417
+ _startTimer() {
1418
+ this._timerId = setTimeout(() => {
1419
+ this._timerId = null;
1420
+ this._isLocked = true;
1421
+ this._onLock();
1422
+ }, this._timeout);
1423
+ }
1424
+ /**
1425
+ * Clears the pending inactivity timer, if any.
1426
+ */
1427
+ _clearTimer() {
1428
+ if (this._timerId !== null) {
1429
+ clearTimeout(this._timerId);
1430
+ this._timerId = null;
1431
+ }
1432
+ }
1433
+ };
538
1434
  export {
539
1435
  AuthClient,
540
1436
  AuthError,
1437
+ AutoLockManager,
541
1438
  CryptoUnavailableError,
542
1439
  DeviceIdentityError,
1440
+ DeviceKeyStoreError,
1441
+ EncryptedTokenStore,
1442
+ EncryptedTokenStoreError,
1443
+ EncryptionError,
1444
+ InMemoryDeviceKeyStore,
1445
+ IndexedDBDeviceKeyStore,
1446
+ KeyDerivationError,
1447
+ OperationEncryptionError,
1448
+ OperationEncryptor,
1449
+ OrgClient,
1450
+ OrgClientError,
1451
+ PasskeyError,
1452
+ PasskeyUnsupportedError,
543
1453
  TokenStore,
1454
+ authenticateWithPasskey,
544
1455
  computePublicKeyThumbprint,
1456
+ createDeviceKeyStore,
1457
+ createPasskeyCredential,
1458
+ decryptData,
1459
+ deriveEncryptionKey,
1460
+ encryptData,
1461
+ exportKey,
545
1462
  exportPublicKeyJwk,
546
1463
  fromBase64Url,
547
1464
  generateDeviceKeyPair,
1465
+ generateEncryptionKey,
1466
+ generateSalt,
1467
+ importKey,
1468
+ isEncryptedField,
1469
+ isPasskeySupported,
1470
+ isPlatformAuthenticatorAvailable,
548
1471
  signChallenge,
549
1472
  toBase64Url,
550
1473
  verifyChallenge