@korajs/auth 1.0.0-beta.12 → 1.0.0-beta.13

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.
Files changed (101) hide show
  1. package/README.md +52 -47
  2. package/dist/{create-org-session-RsDj9cl4.d.cts → create-org-session-ChFdulEM.d.cts} +211 -17
  3. package/dist/{create-org-session-RsDj9cl4.d.ts → create-org-session-ChFdulEM.d.ts} +211 -17
  4. package/dist/index.cjs +645 -150
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +27 -9
  7. package/dist/index.d.ts +27 -9
  8. package/dist/index.js +644 -150
  9. package/dist/index.js.map +1 -1
  10. package/dist/{operation-encryptor-DRmKNWpF.d.cts → operation-encryptor-DDdlb9bm.d.cts} +16 -0
  11. package/dist/{operation-encryptor-DRmKNWpF.d.ts → operation-encryptor-DDdlb9bm.d.ts} +16 -0
  12. package/dist/react.d.cts +2 -2
  13. package/dist/react.d.ts +2 -2
  14. package/dist/server.cjs +2852 -1675
  15. package/dist/server.cjs.map +1 -1
  16. package/dist/server.d.cts +779 -168
  17. package/dist/server.d.ts +779 -168
  18. package/dist/server.js +2831 -1665
  19. package/dist/server.js.map +1 -1
  20. package/dist/svelte.cjs +2 -2
  21. package/dist/svelte.cjs.map +1 -1
  22. package/dist/svelte.d.cts +2 -2
  23. package/dist/svelte.d.ts +2 -2
  24. package/dist/svelte.js +2 -2
  25. package/dist/svelte.js.map +1 -1
  26. package/dist/vue.d.cts +1 -1
  27. package/dist/vue.d.ts +1 -1
  28. package/package.json +7 -7
  29. package/src/admin/admin-api.ts +327 -0
  30. package/src/admin/audit-log.ts +324 -0
  31. package/src/admin/webhooks.ts +576 -0
  32. package/src/bindings/create-auth-session.ts +184 -0
  33. package/src/bindings/create-org-session.ts +130 -0
  34. package/src/client/auth-client.ts +1592 -0
  35. package/src/client/auth-sync.ts +213 -0
  36. package/src/client/device-session.ts +104 -0
  37. package/src/client/org-client.ts +399 -0
  38. package/src/client/quickstart.ts +108 -0
  39. package/src/client/storage.ts +94 -0
  40. package/src/device/device-identity.ts +330 -0
  41. package/src/device/device-store.ts +379 -0
  42. package/src/encryption/auto-lock.ts +170 -0
  43. package/src/encryption/database-encryption.ts +265 -0
  44. package/src/encryption/key-derivation.ts +149 -0
  45. package/src/encryption/operation-encryptor.ts +361 -0
  46. package/src/index.ts +132 -0
  47. package/src/mfa/totp.ts +826 -0
  48. package/src/org/org-routes.ts +758 -0
  49. package/src/org/org-store.ts +490 -0
  50. package/src/org/org-types.ts +230 -0
  51. package/src/passkey/passkey-client.ts +597 -0
  52. package/src/passkey/passkey-server.ts +779 -0
  53. package/src/postgres/ensure-schema.ts +65 -0
  54. package/src/provider/adapter.ts +246 -0
  55. package/src/provider/built-in/auth-routes.ts +1313 -0
  56. package/src/provider/built-in/email-verification.ts +303 -0
  57. package/src/provider/built-in/password-hash.ts +118 -0
  58. package/src/provider/built-in/password-reset.ts +416 -0
  59. package/src/provider/built-in/postgres-user-store.ts +328 -0
  60. package/src/provider/built-in/quickstart-server.ts +760 -0
  61. package/src/provider/built-in/sqlite-user-store.ts +322 -0
  62. package/src/provider/built-in/sync-scopes.ts +85 -0
  63. package/src/provider/built-in/user-store.ts +465 -0
  64. package/src/provider/external/clerk-adapter.ts +157 -0
  65. package/src/provider/external/external-jwt-provider.ts +491 -0
  66. package/src/provider/external/supabase-adapter.ts +163 -0
  67. package/src/provider/oauth/linked-identity-store.ts +108 -0
  68. package/src/provider/oauth/oauth-flow.ts +550 -0
  69. package/src/provider/oauth/oauth-types.ts +184 -0
  70. package/src/provider/oauth/postgres-oauth-store.ts +296 -0
  71. package/src/provider/oauth/sqlite-oauth-store.ts +272 -0
  72. package/src/rbac/rbac-engine.ts +323 -0
  73. package/src/rbac/rbac-types.ts +210 -0
  74. package/src/rbac/scope-resolver.ts +140 -0
  75. package/src/react/AuthProvider.tsx +97 -0
  76. package/src/react/OrgProvider.tsx +41 -0
  77. package/src/react/auth-context.ts +26 -0
  78. package/src/react/hooks.ts +110 -0
  79. package/src/react/org-hooks.ts +214 -0
  80. package/src/react.ts +26 -0
  81. package/src/server.ts +334 -0
  82. package/src/session/session.ts +401 -0
  83. package/src/svelte/auth-context.ts +50 -0
  84. package/src/svelte/org-context.ts +32 -0
  85. package/src/svelte/org-hooks.ts +201 -0
  86. package/src/svelte/use-auth.ts +115 -0
  87. package/src/svelte.ts +25 -0
  88. package/src/tokens/encrypted-token-store.ts +360 -0
  89. package/src/tokens/jwt.ts +236 -0
  90. package/src/tokens/postgres-token-revocation-store.ts +140 -0
  91. package/src/tokens/sqlite-token-revocation-store.ts +121 -0
  92. package/src/tokens/token-manager.ts +821 -0
  93. package/src/tokens/token-store.ts +192 -0
  94. package/src/types.ts +394 -0
  95. package/src/vue/auth-context.ts +10 -0
  96. package/src/vue/auth-provider-types.ts +5 -0
  97. package/src/vue/auth-provider.ts +76 -0
  98. package/src/vue/org-hooks.ts +193 -0
  99. package/src/vue/org-provider.ts +49 -0
  100. package/src/vue/use-auth.ts +139 -0
  101. package/src/vue.ts +10 -0
package/dist/index.cjs CHANGED
@@ -33,6 +33,7 @@ __export(index_exports, {
33
33
  InMemoryDeviceKeyStore: () => InMemoryDeviceKeyStore,
34
34
  IndexedDBDeviceKeyStore: () => IndexedDBDeviceKeyStore,
35
35
  KeyDerivationError: () => KeyDerivationError,
36
+ MfaRequiredError: () => MfaRequiredError,
36
37
  OperationEncryptionError: () => OperationEncryptionError,
37
38
  OperationEncryptor: () => OperationEncryptor,
38
39
  OrgClient: () => OrgClient,
@@ -80,7 +81,19 @@ var AuthError = class extends import_core.KoraError {
80
81
  this.name = "AuthError";
81
82
  }
82
83
  };
84
+ var MfaRequiredError = class extends AuthError {
85
+ constructor(mfaToken) {
86
+ super("A second factor is required to finish signing in.", "AUTH_MFA_REQUIRED");
87
+ this.mfaToken = mfaToken;
88
+ this.name = "MfaRequiredError";
89
+ }
90
+ mfaToken;
91
+ };
83
92
  var EXPIRY_BUFFER_SECONDS = 30;
93
+ var DEFAULT_REQUEST_TIMEOUT_MS = 2e4;
94
+ var DEFAULT_BACKOFF_BASE_MS = 2e3;
95
+ var DEFAULT_BACKOFF_MAX_MS = 5 * 6e4;
96
+ var CLOCK_ROLLBACK_TOLERANCE_MS = 5 * 6e4;
84
97
  function decodeJwtPayload(token) {
85
98
  const parts = token.split(".");
86
99
  if (parts.length !== 3) {
@@ -89,25 +102,28 @@ function decodeJwtPayload(token) {
89
102
  try {
90
103
  const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
91
104
  const json = atob(base64);
92
- return JSON.parse(json);
105
+ const parsed = JSON.parse(json);
106
+ return isRecord(parsed) ? parsed : null;
93
107
  } catch {
94
108
  return null;
95
109
  }
96
110
  }
97
- function isTokenExpired(token) {
111
+ function isTokenExpired(token, bufferSeconds = EXPIRY_BUFFER_SECONDS) {
98
112
  const payload = decodeJwtPayload(token);
99
113
  if (!payload || typeof payload.exp !== "number") {
100
114
  return true;
101
115
  }
102
116
  const nowSeconds = Math.floor(Date.now() / 1e3);
103
- return payload.exp <= nowSeconds + EXPIRY_BUFFER_SECONDS;
117
+ return payload.exp <= nowSeconds + bufferSeconds;
104
118
  }
105
- function getUserIdFromToken(token) {
119
+ function tokenIssuedAtMs(token) {
106
120
  const payload = decodeJwtPayload(token);
107
- if (!payload || typeof payload.sub !== "string") {
108
- return null;
109
- }
110
- return payload.sub;
121
+ if (!payload) return null;
122
+ if (typeof payload.iatMs === "number") return payload.iatMs;
123
+ return typeof payload.iat === "number" ? payload.iat * 1e3 : null;
124
+ }
125
+ function isRecord(value) {
126
+ return typeof value === "object" && value !== null && !Array.isArray(value);
111
127
  }
112
128
  function getDefaultFetch() {
113
129
  if (typeof globalThis.fetch !== "function") {
@@ -139,6 +155,46 @@ function redirectCurrentWindow(url) {
139
155
  }
140
156
  globalThis.window.location.assign(url);
141
157
  }
158
+ var OAUTH_BINDING_PREFIX = "kora_oauth_binding:";
159
+ function getSessionStorage() {
160
+ try {
161
+ const storage = globalThis.sessionStorage;
162
+ return storage && typeof storage.getItem === "function" ? storage : null;
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
167
+ var DEFINITIVE_REFRESH_CODES = /* @__PURE__ */ new Set(["REFRESH_TOKEN_INVALID", "invalid_grant"]);
168
+ function isDefinitiveRejection(response) {
169
+ if (!isRecord(response.json)) return false;
170
+ const code = typeof response.json.code === "string" ? response.json.code : void 0;
171
+ const error = typeof response.json.error === "string" ? response.json.error : void 0;
172
+ if (response.status === 401) return code !== void 0 || error !== void 0;
173
+ if (response.status === 400) {
174
+ return code !== void 0 && DEFINITIVE_REFRESH_CODES.has(code) || error !== void 0 && DEFINITIVE_REFRESH_CODES.has(error);
175
+ }
176
+ return false;
177
+ }
178
+ function parseRetryAfter(value) {
179
+ if (!value) return void 0;
180
+ const seconds = Number(value);
181
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
182
+ const date = Date.parse(value);
183
+ return Number.isNaN(date) ? void 0 : Math.max(0, date - Date.now());
184
+ }
185
+ function readTokenPair(json) {
186
+ if (!isRecord(json)) return null;
187
+ const data = json.data !== void 0 ? json.data : json;
188
+ if (!isRecord(data)) return null;
189
+ return typeof data.accessToken === "string" && typeof data.refreshToken === "string" ? { accessToken: data.accessToken, refreshToken: data.refreshToken } : null;
190
+ }
191
+ var inProcessLocks = /* @__PURE__ */ new WeakMap();
192
+ function getWebLocks() {
193
+ const nav = globalThis.navigator;
194
+ const locks = nav?.locks;
195
+ if (typeof locks !== "object" || locks === null) return null;
196
+ return typeof locks.request === "function" ? locks : null;
197
+ }
142
198
  function createTokenStorage(prefix) {
143
199
  let useLocalStorage = false;
144
200
  try {
@@ -195,10 +251,22 @@ var AuthClient = class {
195
251
  fetchFn;
196
252
  deviceIdentity;
197
253
  listeners = /* @__PURE__ */ new Set();
254
+ sessionListeners = /* @__PURE__ */ new Set();
255
+ requestTimeoutMs;
256
+ maxOfflineGraceMs;
257
+ backoffBaseMs;
258
+ backoffMaxMs;
259
+ lockName;
198
260
  _state = "loading";
199
261
  _user = null;
200
262
  _refreshPromise = null;
201
263
  _initialized = false;
264
+ sessionStatus = "fresh";
265
+ lastServerContactAt = 0;
266
+ failureCount = 0;
267
+ nextAttemptAt = 0;
268
+ retryTimer = null;
269
+ detachEnvironment;
202
270
  /**
203
271
  * Creates a new AuthClient.
204
272
  *
@@ -210,6 +278,12 @@ var AuthClient = class {
210
278
  this.storage = config.storage ?? createTokenStorage(prefix);
211
279
  this.fetchFn = config.fetch ?? getDefaultFetch();
212
280
  this.deviceIdentity = config.deviceIdentity;
281
+ this.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
282
+ this.maxOfflineGraceMs = config.maxOfflineGraceMs;
283
+ this.backoffBaseMs = config.refreshBackoff?.baseDelayMs ?? DEFAULT_BACKOFF_BASE_MS;
284
+ this.backoffMaxMs = config.refreshBackoff?.maxDelayMs ?? DEFAULT_BACKOFF_MAX_MS;
285
+ this.lockName = `kora-auth-refresh:${prefix}`;
286
+ this.detachEnvironment = this.attachEnvironmentListeners();
213
287
  }
214
288
  // -----------------------------------------------------------------------
215
289
  // Public getters
@@ -222,10 +296,54 @@ var AuthClient = class {
222
296
  get currentUser() {
223
297
  return this._user;
224
298
  }
225
- /** Whether the user is currently authenticated. */
299
+ /** Whether the user is currently authenticated (fresh or offline). */
226
300
  get isAuthenticated() {
227
301
  return this._state === "authenticated";
228
302
  }
303
+ /**
304
+ * The stored session identity and its freshness, or null when signed out.
305
+ * Unlike {@link getAccessToken}, this is available offline: the identity is
306
+ * decoupled from whether a fresh access token can be minted right now.
307
+ */
308
+ get session() {
309
+ if (this._state !== "authenticated" || !this._user) return null;
310
+ return {
311
+ userId: this._user.id,
312
+ deviceId: this.cachedDeviceId,
313
+ status: this.sessionStatus,
314
+ lastServerContactAt: this.lastServerContactAt
315
+ };
316
+ }
317
+ cachedDeviceId = null;
318
+ /**
319
+ * Read the stored session identity (user id and device id) from storage,
320
+ * without any network request. Returns null when no usable session is stored.
321
+ */
322
+ async getStoredIdentity() {
323
+ const claims = await this.getStoredClaims();
324
+ if (!claims || typeof claims.sub !== "string" || claims.sub.length === 0) return null;
325
+ return {
326
+ userId: claims.sub,
327
+ deviceId: typeof claims.dev === "string" && claims.dev.length > 0 ? claims.dev : null
328
+ };
329
+ }
330
+ /**
331
+ * Decoded (unverified) claims of the stored credentials, preferring the access
332
+ * token even when it has expired. For client-side hints only (local database
333
+ * name, sync node id, handshake scope narrowing); the server re-derives
334
+ * everything it authorizes from a verified token.
335
+ */
336
+ async getStoredClaims() {
337
+ const access = await this.storage.getAccessToken();
338
+ const refresh = await this.storage.getRefreshToken();
339
+ if (!refresh) return null;
340
+ for (const token of [access, refresh]) {
341
+ if (!token) continue;
342
+ const claims = decodeJwtPayload(token);
343
+ if (claims && typeof claims.sub === "string" && claims.sub.length > 0) return claims;
344
+ }
345
+ return null;
346
+ }
229
347
  // -----------------------------------------------------------------------
230
348
  // Initialization
231
349
  // -----------------------------------------------------------------------
@@ -234,6 +352,8 @@ var AuthClient = class {
234
352
  *
235
353
  * Loads tokens from storage, validates the access token, and attempts a
236
354
  * refresh if the access token is expired but a refresh token is available.
355
+ * When the auth server cannot be reached, the stored session is restored as
356
+ * authenticated-offline instead of being discarded.
237
357
  * Safe to call multiple times -- subsequent calls are no-ops once initialized.
238
358
  */
239
359
  async initialize() {
@@ -247,20 +367,21 @@ var AuthClient = class {
247
367
  this.setState("unauthenticated", null);
248
368
  return;
249
369
  }
370
+ this.noteIssuedCredential(refreshToken);
250
371
  if (!isTokenExpired(accessToken)) {
251
372
  await this.restoreSession(accessToken);
252
373
  return;
253
374
  }
254
- try {
255
- const newAccessToken = await this.refreshAccessToken(refreshToken);
256
- if (newAccessToken) {
257
- await this.restoreSession(newAccessToken);
258
- return;
259
- }
260
- } catch {
375
+ const outcome = await this.refresh();
376
+ if (outcome.kind === "ok") {
377
+ await this.restoreSession(outcome.accessToken);
378
+ return;
261
379
  }
262
- await this.storage.clear();
263
- this.setState("unauthenticated", null);
380
+ if (outcome.kind === "rejected") {
381
+ this.setState("unauthenticated", null);
382
+ return;
383
+ }
384
+ await this.enterOfflineSession();
264
385
  }
265
386
  // -----------------------------------------------------------------------
266
387
  // Sign up / Sign in / Sign out
@@ -278,11 +399,7 @@ var AuthClient = class {
278
399
  method: "POST",
279
400
  body
280
401
  });
281
- const tokens = "tokens" in response ? response.tokens : response;
282
- await this.storage.setTokens(tokens.accessToken, tokens.refreshToken);
283
- const user = await this.fetchUserProfile(tokens.accessToken);
284
- this.setState("authenticated", user);
285
- return user;
402
+ return this.completeSignIn(response);
286
403
  }
287
404
  /**
288
405
  * Sign in with email and password.
@@ -297,11 +414,22 @@ var AuthClient = class {
297
414
  method: "POST",
298
415
  body
299
416
  });
300
- const tokens = "tokens" in response ? response.tokens : response;
301
- await this.storage.setTokens(tokens.accessToken, tokens.refreshToken);
302
- const user = await this.fetchUserProfile(tokens.accessToken);
303
- this.setState("authenticated", user);
304
- return user;
417
+ return this.completeSignIn(response);
418
+ }
419
+ /**
420
+ * Finish a sign-in that required a second factor.
421
+ *
422
+ * @param mfaToken - From the {@link MfaRequiredError} thrown by sign-in
423
+ * @param proof - A current TOTP code, or a recovery code
424
+ * @returns The authenticated AuthUser
425
+ * @throws {AuthError} If the code or the MFA session is invalid
426
+ */
427
+ async verifyMfa(mfaToken, proof) {
428
+ const response = await this.request("/auth/mfa/verify", {
429
+ method: "POST",
430
+ body: { mfaToken, ...proof }
431
+ });
432
+ return this.completeSignIn(response);
305
433
  }
306
434
  /**
307
435
  * Create an OAuth authorization URL and optionally redirect the current window.
@@ -322,14 +450,19 @@ var AuthClient = class {
322
450
  */
323
451
  async completeOAuthSignIn(provider, params) {
324
452
  const body = await this.withDeviceIdentity(params);
453
+ const binding = params.binding ?? this.takeOAuthBinding(params.state);
325
454
  const response = await this.request(
326
455
  `/auth/oauth/${encodeURIComponent(provider)}/callback`,
327
456
  {
328
457
  method: "POST",
329
- body: { ...body }
458
+ body: { ...body, ...binding ? { binding } : {} }
330
459
  }
331
460
  );
461
+ if ("mfaRequired" in response) {
462
+ throw new MfaRequiredError(response.mfaToken);
463
+ }
332
464
  await this.storage.setTokens(response.tokens.accessToken, response.tokens.refreshToken);
465
+ this.markFresh(response.tokens.refreshToken);
333
466
  const user = normalizeAuthUser(response.user);
334
467
  this.setState("authenticated", user);
335
468
  return user;
@@ -337,19 +470,27 @@ var AuthClient = class {
337
470
  /**
338
471
  * Create an OAuth authorization URL for linking another provider to the current user.
339
472
  */
340
- async getOAuthAuthorizationUrl(provider, options = {}) {
341
- return this.createOAuthAuthorization(provider, { ...options, redirect: false });
473
+ async getOAuthAuthorizationUrl(provider, _options = {}) {
474
+ const token = await this.requireAccessToken();
475
+ const result = await this.request(
476
+ `/auth/oauth/${encodeURIComponent(provider)}/link/start`,
477
+ { method: "POST", body: {}, token }
478
+ );
479
+ this.rememberOAuthBinding(result);
480
+ return result;
342
481
  }
343
482
  /**
344
483
  * Link an OAuth provider to the current authenticated user.
345
484
  */
346
485
  async linkOAuth(provider, params) {
347
486
  const token = await this.requireAccessToken();
487
+ const binding = params.binding ?? this.takeOAuthBinding(params.state);
348
488
  return this.request(`/auth/oauth/${encodeURIComponent(provider)}/link`, {
349
489
  method: "POST",
350
490
  body: {
351
491
  code: params.code,
352
- state: params.state
492
+ state: params.state,
493
+ ...binding ? { binding } : {}
353
494
  },
354
495
  token
355
496
  });
@@ -386,6 +527,9 @@ var AuthClient = class {
386
527
  const refreshToken = await this.storage.getRefreshToken();
387
528
  await this.storage.clear();
388
529
  this._refreshPromise = null;
530
+ this.resetBackoff();
531
+ this.sessionStatus = "fresh";
532
+ this.cachedDeviceId = null;
389
533
  this.setState("unauthenticated", null);
390
534
  if (accessToken) {
391
535
  try {
@@ -404,24 +548,46 @@ var AuthClient = class {
404
548
  /**
405
549
  * Get a valid access token, automatically refreshing if expired.
406
550
  *
407
- * @returns A valid access token string, or null if the user is not
408
- * authenticated and refresh is not possible
551
+ * Returns null when no fresh token can be obtained right now. That does NOT
552
+ * mean the user is signed out: check {@link state} / {@link session}. A
553
+ * transient failure keeps the session (authenticated-offline) and later calls
554
+ * retry with backoff.
555
+ *
556
+ * @returns A valid access token string, or null if none is available now
409
557
  */
410
558
  async getAccessToken() {
411
559
  const accessToken = await this.storage.getAccessToken();
412
560
  if (accessToken && !isTokenExpired(accessToken)) {
561
+ if (this._state === "authenticated" && this.sessionStatus !== "fresh") {
562
+ this.markFresh(await this.storage.getRefreshToken());
563
+ }
413
564
  return accessToken;
414
565
  }
415
566
  const refreshToken = await this.storage.getRefreshToken();
416
567
  if (!refreshToken) {
417
568
  return null;
418
569
  }
419
- try {
420
- const newAccessToken = await this.refreshAccessToken(refreshToken);
421
- return newAccessToken;
422
- } catch {
570
+ const outcome = await this.refresh();
571
+ return outcome.kind === "ok" ? outcome.accessToken : null;
572
+ }
573
+ /**
574
+ * Refresh the session now, even when the cached access token has not expired
575
+ * locally. Used after the sync server ended a session with `AUTH_EXPIRED` or
576
+ * `AUTH_REVOKED`: the server's clock or a revocation says the cached token is
577
+ * no longer good. Concurrent calls (and other tabs) share one refresh.
578
+ *
579
+ * A transient failure keeps the session (authenticated-offline) and returns
580
+ * null; only a definitive server rejection signs the user out.
581
+ *
582
+ * @returns The refreshed access token, or null if none could be obtained now
583
+ */
584
+ async refreshAccessToken() {
585
+ const refreshToken = await this.storage.getRefreshToken();
586
+ if (!refreshToken) {
423
587
  return null;
424
588
  }
589
+ const outcome = await this.refresh();
590
+ return outcome.kind === "ok" ? outcome.accessToken : null;
425
591
  }
426
592
  /**
427
593
  * Get a valid token for the sync engine handshake.
@@ -432,6 +598,23 @@ var AuthClient = class {
432
598
  async getSyncToken() {
433
599
  return this.getAccessToken();
434
600
  }
601
+ /**
602
+ * Retry immediately: clears the refresh backoff and attempts a refresh if the
603
+ * session is offline. Call it when connectivity is known to be back (for
604
+ * example when a sync transport opens). Also wired to the browser `online`
605
+ * and `visibilitychange` events automatically.
606
+ */
607
+ async retryNow() {
608
+ this.resetBackoff();
609
+ if (this._state === "authenticated" && this.sessionStatus !== "fresh") {
610
+ await this.getAccessToken();
611
+ }
612
+ }
613
+ /** Remove environment listeners and timers (for tests and teardown). */
614
+ destroy() {
615
+ this.detachEnvironment();
616
+ this.clearRetryTimer();
617
+ }
435
618
  // -----------------------------------------------------------------------
436
619
  // State change subscriptions
437
620
  // -----------------------------------------------------------------------
@@ -458,6 +641,20 @@ var AuthClient = class {
458
641
  this.listeners.delete(callback);
459
642
  };
460
643
  }
644
+ /**
645
+ * Subscribe to session freshness changes (fresh, authenticated-offline,
646
+ * locked). Fires in addition to {@link onAuthChange}, including when the
647
+ * state stays 'authenticated' but connectivity to the auth server changes.
648
+ *
649
+ * @param callback - Called with the current session (null when signed out)
650
+ * @returns An unsubscribe function
651
+ */
652
+ onSessionChange(callback) {
653
+ this.sessionListeners.add(callback);
654
+ return () => {
655
+ this.sessionListeners.delete(callback);
656
+ };
657
+ }
461
658
  // -----------------------------------------------------------------------
462
659
  // Internal helpers
463
660
  // -----------------------------------------------------------------------
@@ -475,30 +672,115 @@ var AuthClient = class {
475
672
  } catch {
476
673
  }
477
674
  }
675
+ this.notifySession();
478
676
  }
479
677
  }
678
+ setSessionStatus(status) {
679
+ if (this.sessionStatus === status) return;
680
+ this.sessionStatus = status;
681
+ this.notifySession();
682
+ }
683
+ notifySession() {
684
+ const session = this.session;
685
+ for (const listener of this.sessionListeners) {
686
+ try {
687
+ listener(session);
688
+ } catch {
689
+ }
690
+ }
691
+ }
692
+ async completeSignIn(response) {
693
+ if ("mfaRequired" in response) {
694
+ throw new MfaRequiredError(response.mfaToken);
695
+ }
696
+ const tokens = "tokens" in response ? response.tokens : response;
697
+ await this.storage.setTokens(tokens.accessToken, tokens.refreshToken);
698
+ this.markFresh(tokens.refreshToken);
699
+ const user = "user" in response && response.user ? normalizeAuthUser(response.user) : await this.fetchUserProfile(tokens.accessToken);
700
+ this.setState("authenticated", user);
701
+ return user;
702
+ }
480
703
  /**
481
704
  * Restore a session from a valid access token by fetching the user profile.
482
- * Falls back to extracting the user ID from the token payload if the
483
- * /auth/me request fails (offline scenario).
705
+ * A definitive 401 from `/auth/me` ends the session (NEW-AUTH-4); any other
706
+ * failure restores it as authenticated-offline from the stored identity.
484
707
  */
485
708
  async restoreSession(accessToken) {
709
+ let response;
486
710
  try {
487
- const user = await this.fetchUserProfile(accessToken);
488
- this.setState("authenticated", user);
711
+ response = await this.rawRequest("/auth/me", { method: "GET", token: accessToken });
489
712
  } catch {
490
- const userId = getUserIdFromToken(accessToken);
491
- if (userId) {
492
- this.setState("authenticated", {
493
- id: userId,
494
- email: "",
495
- name: null
496
- });
497
- } else {
498
- await this.storage.clear();
499
- this.setState("unauthenticated", null);
713
+ await this.enterOfflineSession();
714
+ return;
715
+ }
716
+ if (response.ok && isRecord(response.json)) {
717
+ const profile = response.json.data !== void 0 ? response.json.data : response.json;
718
+ if (isRecord(profile) && typeof profile.id === "string") {
719
+ this.markFresh(null);
720
+ this.setState("authenticated", normalizeAuthUser(profile));
721
+ return;
500
722
  }
501
723
  }
724
+ if (response.status === 401 && isDefinitiveRejection(response)) {
725
+ await this.endSession();
726
+ return;
727
+ }
728
+ await this.enterOfflineSession();
729
+ }
730
+ /**
731
+ * Restore the session from stored credentials while the auth server is
732
+ * unreachable. Signs out only when the stored refresh token is unusable.
733
+ */
734
+ async enterOfflineSession() {
735
+ const refreshToken = await this.storage.getRefreshToken();
736
+ const identity = await this.getStoredIdentity();
737
+ if (!refreshToken || !identity || isTokenExpired(refreshToken, 0)) {
738
+ await this.endSession();
739
+ return;
740
+ }
741
+ this.cachedDeviceId = identity.deviceId;
742
+ this.noteIssuedCredential(refreshToken);
743
+ const user = this._user && this._user.id === identity.userId ? this._user : { id: identity.userId, email: "", name: null };
744
+ this.sessionStatus = this.offlineStatus();
745
+ this.setState("authenticated", user);
746
+ this.notifySession();
747
+ }
748
+ /** Offline, or locked when the grace period ran out or the clock went backwards. */
749
+ offlineStatus() {
750
+ const now = Date.now();
751
+ if (this.lastServerContactAt > 0 && now + CLOCK_ROLLBACK_TOLERANCE_MS < this.lastServerContactAt) {
752
+ return "locked";
753
+ }
754
+ if (this.maxOfflineGraceMs !== void 0 && this.lastServerContactAt > 0 && now - this.lastServerContactAt > this.maxOfflineGraceMs) {
755
+ return "locked";
756
+ }
757
+ return "offline";
758
+ }
759
+ async endSession() {
760
+ await this.storage.clear();
761
+ this.resetBackoff();
762
+ this.sessionStatus = "fresh";
763
+ this.cachedDeviceId = null;
764
+ this.setState("unauthenticated", null);
765
+ }
766
+ /** The refresh token's issue time is the server's clock at the last rotation. */
767
+ noteIssuedCredential(refreshToken) {
768
+ if (!refreshToken) return;
769
+ const issued = tokenIssuedAtMs(refreshToken);
770
+ if (issued !== null) this.lastServerContactAt = Math.max(this.lastServerContactAt, issued);
771
+ const claims = decodeJwtPayload(refreshToken);
772
+ if (claims && typeof claims.dev === "string") this.cachedDeviceId = claims.dev;
773
+ }
774
+ markFresh(refreshToken) {
775
+ this.resetBackoff();
776
+ this.lastServerContactAt = Math.max(this.lastServerContactAt, Date.now());
777
+ this.noteIssuedCredential(refreshToken);
778
+ this.setSessionStatus("fresh");
779
+ }
780
+ markOffline() {
781
+ if (this._state === "authenticated") {
782
+ this.setSessionStatus(this.offlineStatus());
783
+ }
502
784
  }
503
785
  /**
504
786
  * Fetch the current user profile from the server.
@@ -512,19 +794,9 @@ var AuthClient = class {
512
794
  }
513
795
  async createOAuthAuthorization(provider, options) {
514
796
  const params = new URLSearchParams();
515
- const withIdentity = await this.withDeviceIdentity({
516
- deviceId: options.deviceId,
517
- devicePublicKey: options.devicePublicKey
518
- });
519
797
  if (options.returnTo) {
520
798
  params.set("returnTo", options.returnTo);
521
799
  }
522
- if (withIdentity.deviceId) {
523
- params.set("deviceId", withIdentity.deviceId);
524
- }
525
- if (withIdentity.devicePublicKey) {
526
- params.set("devicePublicKey", withIdentity.devicePublicKey);
527
- }
528
800
  if (options.metadata) {
529
801
  for (const [key, value] of Object.entries(options.metadata)) {
530
802
  if (value !== void 0 && value !== null) {
@@ -533,12 +805,38 @@ var AuthClient = class {
533
805
  }
534
806
  }
535
807
  const query = params.toString();
536
- return this.request(
808
+ const result = await this.request(
537
809
  `/auth/oauth/${encodeURIComponent(provider)}${query ? `?${query}` : ""}`,
538
810
  {
539
811
  method: "GET"
540
812
  }
541
813
  );
814
+ this.rememberOAuthBinding(result);
815
+ return result;
816
+ }
817
+ /** Bindings of flows this client started, by state (survives the redirect on web). */
818
+ oauthBindings = /* @__PURE__ */ new Map();
819
+ rememberOAuthBinding(result) {
820
+ if (!result.binding || !result.state) return;
821
+ this.oauthBindings.set(result.state, result.binding);
822
+ const session = getSessionStorage();
823
+ try {
824
+ session?.setItem(`${OAUTH_BINDING_PREFIX}${result.state}`, result.binding);
825
+ } catch {
826
+ }
827
+ }
828
+ takeOAuthBinding(state) {
829
+ const inMemory = this.oauthBindings.get(state);
830
+ this.oauthBindings.delete(state);
831
+ const session = getSessionStorage();
832
+ let stored = null;
833
+ try {
834
+ stored = session?.getItem(`${OAUTH_BINDING_PREFIX}${state}`) ?? null;
835
+ session?.removeItem(`${OAUTH_BINDING_PREFIX}${state}`);
836
+ } catch {
837
+ stored = null;
838
+ }
839
+ return inMemory ?? stored ?? void 0;
542
840
  }
543
841
  async withDeviceIdentity(params) {
544
842
  if (!this.deviceIdentity || params.deviceId && params.devicePublicKey) {
@@ -552,54 +850,187 @@ var AuthClient = class {
552
850
  };
553
851
  }
554
852
  /**
555
- * Refresh the access token using a refresh token.
556
- * De-duplicates concurrent refresh calls so only one network request is made.
853
+ * Refresh the session's tokens. De-duplicates concurrent calls in this
854
+ * client, serializes refreshes across tabs, and applies backoff after
855
+ * transient failures.
557
856
  */
558
- async refreshAccessToken(refreshToken) {
857
+ refresh() {
559
858
  if (this._refreshPromise) {
560
859
  return this._refreshPromise;
561
860
  }
562
- this._refreshPromise = this.performRefresh(refreshToken);
563
- try {
564
- const result = await this._refreshPromise;
565
- return result;
566
- } finally {
567
- this._refreshPromise = null;
568
- }
861
+ const promise = this.refreshOnce().finally(() => {
862
+ if (this._refreshPromise === promise) this._refreshPromise = null;
863
+ });
864
+ this._refreshPromise = promise;
865
+ return promise;
569
866
  }
570
- async requireAccessToken() {
571
- const token = await this.getAccessToken();
572
- if (!token) {
573
- throw new AuthError("You must be signed in to perform this action.", "AUTH_REQUIRED");
867
+ async refreshOnce() {
868
+ const refreshToken = await this.storage.getRefreshToken();
869
+ if (!refreshToken) return { kind: "rejected" };
870
+ if (isTokenExpired(refreshToken, 0)) {
871
+ await this.endSession();
872
+ return { kind: "rejected" };
574
873
  }
575
- return token;
874
+ if (Date.now() < this.nextAttemptAt) {
875
+ this.markOffline();
876
+ return { kind: "transient" };
877
+ }
878
+ let outcome;
879
+ try {
880
+ outcome = await this.withRefreshLock(async () => {
881
+ const current = await this.storage.getRefreshToken();
882
+ if (!current) return { kind: "rejected" };
883
+ const currentAccess = await this.storage.getAccessToken();
884
+ if (current !== refreshToken && currentAccess && !isTokenExpired(currentAccess)) {
885
+ return { kind: "ok", accessToken: currentAccess };
886
+ }
887
+ return this.performRefresh(current);
888
+ });
889
+ } catch {
890
+ outcome = { kind: "transient" };
891
+ }
892
+ if (outcome.kind === "ok") {
893
+ this.markFresh(await this.storage.getRefreshToken());
894
+ } else if (outcome.kind === "rejected") {
895
+ this.resetBackoff();
896
+ this.cachedDeviceId = null;
897
+ this.sessionStatus = "fresh";
898
+ this.setState("unauthenticated", null);
899
+ } else {
900
+ this.registerFailure(outcome.retryAfterMs);
901
+ this.markOffline();
902
+ }
903
+ return outcome;
576
904
  }
577
905
  /**
578
- * Execute the token refresh network request.
906
+ * Execute the token refresh network request and classify the answer.
579
907
  */
580
908
  async performRefresh(refreshToken) {
909
+ let response;
581
910
  try {
582
- const response = await this.request("/auth/refresh", {
911
+ response = await this.rawRequest("/auth/refresh", {
583
912
  method: "POST",
584
913
  body: { refreshToken }
585
914
  });
586
- await this.storage.setTokens(response.accessToken, response.refreshToken);
587
- return response.accessToken;
588
915
  } catch {
589
- await this.storage.clear();
590
- this.setState("unauthenticated", null);
591
- return null;
916
+ return { kind: "transient" };
917
+ }
918
+ if (response.ok) {
919
+ const tokens = readTokenPair(response.json);
920
+ if (!tokens) {
921
+ return { kind: "transient" };
922
+ }
923
+ await this.storage.setTokens(tokens.accessToken, tokens.refreshToken);
924
+ return { kind: "ok", accessToken: tokens.accessToken };
925
+ }
926
+ if (isDefinitiveRejection(response)) {
927
+ if (await this.storage.getRefreshToken() === refreshToken) {
928
+ await this.storage.clear();
929
+ return { kind: "rejected" };
930
+ }
931
+ const adopted = await this.storage.getAccessToken();
932
+ return adopted && !isTokenExpired(adopted) ? { kind: "ok", accessToken: adopted } : { kind: "transient" };
933
+ }
934
+ return { kind: "transient", retryAfterMs: response.retryAfterMs };
935
+ }
936
+ async withRefreshLock(fn) {
937
+ const locks = getWebLocks();
938
+ if (locks) {
939
+ const controller = typeof AbortController === "function" ? new AbortController() : null;
940
+ const timer = controller ? setTimeout(() => controller.abort(), this.requestTimeoutMs * 2) : null;
941
+ try {
942
+ return await locks.request(
943
+ this.lockName,
944
+ controller ? { signal: controller.signal } : {},
945
+ async () => {
946
+ if (timer) clearTimeout(timer);
947
+ return fn();
948
+ }
949
+ );
950
+ } finally {
951
+ if (timer) clearTimeout(timer);
952
+ }
953
+ }
954
+ const key = this.storage;
955
+ const previous = inProcessLocks.get(key) ?? Promise.resolve();
956
+ const run = previous.then(fn, fn);
957
+ inProcessLocks.set(
958
+ key,
959
+ run.then(
960
+ () => void 0,
961
+ () => void 0
962
+ )
963
+ );
964
+ return run;
965
+ }
966
+ /**
967
+ * Schedule the next allowed refresh attempt. The first retry after a failure
968
+ * is immediate (it recovers a rotation response lost on the wire); after
969
+ * that, exponential backoff with equal jitter, never earlier than Retry-After.
970
+ */
971
+ registerFailure(retryAfterMs) {
972
+ this.failureCount++;
973
+ let delay = 0;
974
+ if (this.failureCount > 1) {
975
+ const ceiling = Math.min(this.backoffBaseMs * 2 ** (this.failureCount - 2), this.backoffMaxMs);
976
+ delay = ceiling / 2 + Math.random() * (ceiling / 2);
977
+ }
978
+ if (retryAfterMs !== void 0) delay = Math.max(delay, retryAfterMs);
979
+ this.nextAttemptAt = Date.now() + delay;
980
+ this.scheduleRetry(Math.max(delay, this.backoffBaseMs));
981
+ }
982
+ resetBackoff() {
983
+ this.failureCount = 0;
984
+ this.nextAttemptAt = 0;
985
+ this.clearRetryTimer();
986
+ }
987
+ /** Background retry so an offline session recovers without the app calling in. */
988
+ scheduleRetry(delayMs) {
989
+ this.clearRetryTimer();
990
+ if (typeof setTimeout !== "function") return;
991
+ const timer = setTimeout(() => {
992
+ this.retryTimer = null;
993
+ if (this._state === "authenticated" && this.sessionStatus !== "fresh") {
994
+ void this.getAccessToken().catch(() => void 0);
995
+ }
996
+ }, delayMs);
997
+ timer.unref?.();
998
+ this.retryTimer = timer;
999
+ }
1000
+ clearRetryTimer() {
1001
+ if (this.retryTimer !== null) {
1002
+ clearTimeout(this.retryTimer);
1003
+ this.retryTimer = null;
592
1004
  }
593
1005
  }
1006
+ attachEnvironmentListeners() {
1007
+ const target = globalThis.window;
1008
+ if (!target || typeof target.addEventListener !== "function") return () => {
1009
+ };
1010
+ const wake = () => {
1011
+ const doc = globalThis.document;
1012
+ if (doc?.visibilityState === "hidden") return;
1013
+ void this.retryNow().catch(() => void 0);
1014
+ };
1015
+ target.addEventListener("online", wake);
1016
+ target.addEventListener("visibilitychange", wake);
1017
+ return () => {
1018
+ target.removeEventListener?.("online", wake);
1019
+ target.removeEventListener?.("visibilitychange", wake);
1020
+ };
1021
+ }
1022
+ async requireAccessToken() {
1023
+ const token = await this.getAccessToken();
1024
+ if (!token) {
1025
+ throw new AuthError("You must be signed in to perform this action.", "AUTH_REQUIRED");
1026
+ }
1027
+ return token;
1028
+ }
594
1029
  /**
595
- * Make an HTTP request to the auth server.
596
- *
597
- * @param path - URL path relative to serverUrl (e.g. '/auth/signin')
598
- * @param options - Request options
599
- * @returns Parsed JSON response body
600
- * @throws {AuthError} On network failure or non-2xx response
1030
+ * Send one request with a timeout and return status plus parsed JSON (if any).
1031
+ * Throws only for transport failures (network, abort, timeout).
601
1032
  */
602
- async request(path, options) {
1033
+ async rawRequest(path, options) {
603
1034
  const url = `${this.serverUrl}${path}`;
604
1035
  const headers = {};
605
1036
  if (options.body) {
@@ -608,14 +1039,61 @@ var AuthClient = class {
608
1039
  if (options.token) {
609
1040
  headers.Authorization = `Bearer ${options.token}`;
610
1041
  }
1042
+ const controller = typeof AbortController === "function" ? new AbortController() : null;
1043
+ let timer = null;
1044
+ const timeout = new Promise((_, reject) => {
1045
+ timer = setTimeout(() => {
1046
+ controller?.abort();
1047
+ reject(
1048
+ new AuthError(
1049
+ `Request to ${path} timed out after ${this.requestTimeoutMs}ms.`,
1050
+ "AUTH_TIMEOUT",
1051
+ { path }
1052
+ )
1053
+ );
1054
+ }, this.requestTimeoutMs);
1055
+ });
1056
+ try {
1057
+ const response = await Promise.race([
1058
+ this.fetchFn(url, {
1059
+ method: options.method,
1060
+ headers,
1061
+ body: options.body ? JSON.stringify(options.body) : void 0,
1062
+ ...controller ? { signal: controller.signal } : {}
1063
+ }),
1064
+ timeout
1065
+ ]);
1066
+ let json;
1067
+ try {
1068
+ json = await Promise.race([response.json(), timeout]);
1069
+ } catch {
1070
+ json = void 0;
1071
+ }
1072
+ const headerGetter = response.headers;
1073
+ return {
1074
+ status: response.status,
1075
+ ok: response.ok,
1076
+ json,
1077
+ retryAfterMs: parseRetryAfter(headerGetter?.get?.("Retry-After"))
1078
+ };
1079
+ } finally {
1080
+ if (timer !== null) clearTimeout(timer);
1081
+ }
1082
+ }
1083
+ /**
1084
+ * Make an HTTP request to the auth server.
1085
+ *
1086
+ * @param path - URL path relative to serverUrl (e.g. '/auth/signin')
1087
+ * @param options - Request options
1088
+ * @returns Parsed JSON response body
1089
+ * @throws {AuthError} On network failure, timeout, or non-2xx response
1090
+ */
1091
+ async request(path, options) {
611
1092
  let response;
612
1093
  try {
613
- response = await this.fetchFn(url, {
614
- method: options.method,
615
- headers,
616
- body: options.body ? JSON.stringify(options.body) : void 0
617
- });
1094
+ response = await this.rawRequest(path, options);
618
1095
  } catch (cause) {
1096
+ if (cause instanceof AuthError) throw cause;
619
1097
  throw new AuthError(
620
1098
  `Network request to ${path} failed. The auth server at ${this.serverUrl} may be unreachable. Check your network connection and serverUrl configuration.`,
621
1099
  "AUTH_NETWORK_ERROR",
@@ -625,26 +1103,33 @@ var AuthClient = class {
625
1103
  if (!response.ok) {
626
1104
  let errorMessage = `Auth server returned HTTP ${response.status}`;
627
1105
  let serverError;
628
- try {
629
- const body = await response.json();
630
- if (typeof body.error === "string") {
631
- errorMessage = body.error;
1106
+ let serverCode;
1107
+ if (isRecord(response.json)) {
1108
+ if (typeof response.json.error === "string") {
1109
+ errorMessage = response.json.error;
632
1110
  serverError = errorMessage;
633
- } else if (typeof body.message === "string") {
634
- errorMessage = body.message;
1111
+ } else if (typeof response.json.message === "string") {
1112
+ errorMessage = response.json.message;
635
1113
  serverError = errorMessage;
636
1114
  }
637
- } catch {
1115
+ if (typeof response.json.code === "string") serverCode = response.json.code;
638
1116
  }
639
1117
  throw new AuthError(errorMessage, "AUTH_SERVER_ERROR", {
640
1118
  path,
641
1119
  status: response.status,
642
- serverError
1120
+ serverError,
1121
+ serverCode
643
1122
  });
644
1123
  }
645
- const json = await response.json();
646
- const data = json.data !== void 0 ? json.data : json;
647
- return data;
1124
+ if (!isRecord(response.json) && !Array.isArray(response.json)) {
1125
+ throw new AuthError(
1126
+ `Auth server returned a non-JSON response for ${path}. A captive portal or proxy may be intercepting requests.`,
1127
+ "AUTH_INVALID_RESPONSE",
1128
+ { path, status: response.status }
1129
+ );
1130
+ }
1131
+ const json = response.json;
1132
+ return json.data !== void 0 ? json.data : json;
648
1133
  }
649
1134
  };
650
1135
 
@@ -1143,7 +1628,10 @@ function createKoraAuth(options) {
1143
1628
  storageKey: options.storageKey,
1144
1629
  storage,
1145
1630
  fetch: options.fetch,
1146
- deviceIdentity
1631
+ deviceIdentity,
1632
+ requestTimeoutMs: options.requestTimeoutMs,
1633
+ maxOfflineGraceMs: options.maxOfflineGraceMs,
1634
+ refreshBackoff: options.refreshBackoff
1147
1635
  });
1148
1636
  }
1149
1637
  function tryCreateDefaultTokenStorage(storageKey) {
@@ -1202,38 +1690,49 @@ function decodeJwtPayload2(token) {
1202
1690
  return null;
1203
1691
  }
1204
1692
  }
1205
- function readDeviceIdFromClaims(claims) {
1206
- const dev = claims.dev;
1207
- return typeof dev === "string" && dev.length > 0 ? dev : void 0;
1693
+ function readString(claims, key) {
1694
+ const value = claims?.[key];
1695
+ return typeof value === "string" && value.length > 0 ? value : void 0;
1208
1696
  }
1209
1697
  function createKoraAuthSync(options) {
1210
1698
  const { authClient, schema, scopeFromClaims, anonymous = "suspend" } = options;
1699
+ const resolveClaims = async () => {
1700
+ const token = await authClient.getAccessToken();
1701
+ if (token) return { claims: decodeJwtPayload2(token), token };
1702
+ if (authClient.state === "unauthenticated" || !authClient.getStoredClaims) {
1703
+ return { claims: null, token: null };
1704
+ }
1705
+ return { claims: await authClient.getStoredClaims(), token: null };
1706
+ };
1211
1707
  const binding = {
1212
- auth: async () => {
1213
- const token = await authClient.getAccessToken();
1708
+ auth: async (request) => {
1709
+ const token = request?.forceRefresh && authClient.refreshAccessToken ? await authClient.refreshAccessToken() : await authClient.getAccessToken();
1214
1710
  return { token: token ?? "" };
1215
1711
  }
1216
1712
  };
1217
1713
  binding.resolveSyncState = async () => {
1218
1714
  if (authClient.state === "loading") return { state: "loading" };
1219
- const token = await authClient.getAccessToken();
1220
- if (!token) {
1221
- return anonymous === "allow" ? { state: "anonymous", mayConnectAnonymously: true } : { state: "signed-out", mayConnectAnonymously: false };
1222
- }
1223
- const claims = decodeJwtPayload2(token);
1224
- const userId = typeof claims?.sub === "string" ? claims.sub : "";
1715
+ const { claims, token } = await resolveClaims();
1716
+ const userId = readString(claims, "sub");
1225
1717
  if (!userId) {
1226
1718
  return anonymous === "allow" ? { state: "anonymous", mayConnectAnonymously: true } : { state: "signed-out", mayConnectAnonymously: false };
1227
1719
  }
1228
- return { state: "authenticated", userId, token };
1720
+ const deviceId = readString(claims, "dev");
1721
+ if (token) {
1722
+ return { state: "authenticated", userId, token, ...deviceId ? { deviceId } : {} };
1723
+ }
1724
+ return {
1725
+ state: "authenticated",
1726
+ userId,
1727
+ token: null,
1728
+ offline: true,
1729
+ locked: authClient.session?.status === "locked",
1730
+ ...deviceId ? { deviceId } : {}
1731
+ };
1229
1732
  };
1230
1733
  if (schema) {
1231
1734
  binding.resolveScopeMap = async () => {
1232
- const token = await authClient.getAccessToken();
1233
- if (!token) {
1234
- return void 0;
1235
- }
1236
- const claims = decodeJwtPayload2(token);
1735
+ const { claims } = await resolveClaims();
1237
1736
  if (!claims) {
1238
1737
  return void 0;
1239
1738
  }
@@ -1242,30 +1741,24 @@ function createKoraAuthSync(options) {
1242
1741
  };
1243
1742
  }
1244
1743
  binding.resolveNodeId = async () => {
1245
- const token = await authClient.getAccessToken();
1246
- if (!token) {
1247
- return void 0;
1248
- }
1249
- const claims = decodeJwtPayload2(token);
1250
- if (!claims) {
1251
- return void 0;
1252
- }
1253
- return readDeviceIdFromClaims(claims);
1744
+ const { claims } = await resolveClaims();
1745
+ return readString(claims, "dev");
1254
1746
  };
1255
1747
  binding.resolveUserId = async () => {
1256
- const token = await authClient.getAccessToken();
1257
- if (!token) {
1258
- return void 0;
1259
- }
1260
- const claims = decodeJwtPayload2(token);
1261
- if (!claims || typeof claims.sub !== "string" || claims.sub.length === 0) {
1262
- return void 0;
1263
- }
1264
- return claims.sub;
1748
+ const { claims } = await resolveClaims();
1749
+ return readString(claims, "sub");
1265
1750
  };
1266
- if (authClient.onAuthChange) {
1267
- binding.subscribe = (listener) => authClient.onAuthChange?.(() => listener()) ?? (() => {
1268
- });
1751
+ if (authClient.onAuthChange || authClient.onSessionChange) {
1752
+ binding.subscribe = (listener) => {
1753
+ const offAuth = authClient.onAuthChange?.(() => listener()) ?? (() => {
1754
+ });
1755
+ const offSession = authClient.onSessionChange?.(() => listener()) ?? (() => {
1756
+ });
1757
+ return () => {
1758
+ offAuth();
1759
+ offSession();
1760
+ };
1761
+ };
1269
1762
  }
1270
1763
  return binding;
1271
1764
  }
@@ -1610,10 +2103,11 @@ var OrgClient = class {
1610
2103
  await this.request(`/orgs/${orgId}/invitations/${invitationId}`, { method: "DELETE" });
1611
2104
  }
1612
2105
  /**
1613
- * List pending invitations for the current user's email.
2106
+ * List pending invitations addressed to the signed-in user's verified email.
2107
+ * The server resolves the email from the session; tokens are never returned.
1614
2108
  */
1615
- async listMyInvitations(email) {
1616
- return this.request(`/invitations?email=${encodeURIComponent(email)}`, {
2109
+ async listMyInvitations() {
2110
+ return this.request("/invitations", {
1617
2111
  method: "GET"
1618
2112
  });
1619
2113
  }
@@ -2800,6 +3294,7 @@ function isEncryptedEnvelope(field) {
2800
3294
  InMemoryDeviceKeyStore,
2801
3295
  IndexedDBDeviceKeyStore,
2802
3296
  KeyDerivationError,
3297
+ MfaRequiredError,
2803
3298
  OperationEncryptionError,
2804
3299
  OperationEncryptor,
2805
3300
  OrgClient,