@korajs/auth 0.1.0 → 0.3.1

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