@korajs/auth 1.0.0-beta.12 → 1.0.0-beta.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -47
- package/dist/{create-org-session-RsDj9cl4.d.cts → create-org-session-ChFdulEM.d.cts} +211 -17
- package/dist/{create-org-session-RsDj9cl4.d.ts → create-org-session-ChFdulEM.d.ts} +211 -17
- package/dist/index.cjs +645 -150
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -9
- package/dist/index.d.ts +27 -9
- package/dist/index.js +644 -150
- package/dist/index.js.map +1 -1
- package/dist/{operation-encryptor-DRmKNWpF.d.cts → operation-encryptor-DDdlb9bm.d.cts} +16 -0
- package/dist/{operation-encryptor-DRmKNWpF.d.ts → operation-encryptor-DDdlb9bm.d.ts} +16 -0
- package/dist/react.d.cts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/server.cjs +2880 -1667
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +810 -169
- package/dist/server.d.ts +810 -169
- package/dist/server.js +2848 -1646
- package/dist/server.js.map +1 -1
- package/dist/svelte.cjs +2 -2
- package/dist/svelte.cjs.map +1 -1
- package/dist/svelte.d.cts +2 -2
- package/dist/svelte.d.ts +2 -2
- package/dist/svelte.js +2 -2
- package/dist/svelte.js.map +1 -1
- package/dist/vue.d.cts +1 -1
- package/dist/vue.d.ts +1 -1
- package/package.json +7 -7
- package/src/admin/admin-api.ts +327 -0
- package/src/admin/audit-log.ts +324 -0
- package/src/admin/webhooks.ts +576 -0
- package/src/bindings/create-auth-session.ts +184 -0
- package/src/bindings/create-org-session.ts +130 -0
- package/src/client/auth-client.ts +1592 -0
- package/src/client/auth-sync.ts +213 -0
- package/src/client/device-session.ts +104 -0
- package/src/client/org-client.ts +399 -0
- package/src/client/quickstart.ts +108 -0
- package/src/client/storage.ts +94 -0
- package/src/device/device-identity.ts +330 -0
- package/src/device/device-store.ts +379 -0
- package/src/encryption/auto-lock.ts +170 -0
- package/src/encryption/database-encryption.ts +265 -0
- package/src/encryption/key-derivation.ts +149 -0
- package/src/encryption/operation-encryptor.ts +361 -0
- package/src/index.ts +132 -0
- package/src/mfa/totp.ts +826 -0
- package/src/org/org-routes.ts +758 -0
- package/src/org/org-store.ts +490 -0
- package/src/org/org-types.ts +230 -0
- package/src/passkey/passkey-client.ts +597 -0
- package/src/passkey/passkey-server.ts +779 -0
- package/src/postgres/ensure-schema.ts +65 -0
- package/src/provider/adapter.ts +246 -0
- package/src/provider/built-in/auth-routes.ts +1313 -0
- package/src/provider/built-in/email-verification.ts +303 -0
- package/src/provider/built-in/password-hash.ts +118 -0
- package/src/provider/built-in/password-reset.ts +416 -0
- package/src/provider/built-in/postgres-user-store.ts +365 -0
- package/src/provider/built-in/quickstart-server.ts +760 -0
- package/src/provider/built-in/sqlite-user-store.ts +335 -0
- package/src/provider/built-in/sync-scopes.ts +85 -0
- package/src/provider/built-in/user-store.ts +465 -0
- package/src/provider/external/clerk-adapter.ts +157 -0
- package/src/provider/external/external-jwt-provider.ts +491 -0
- package/src/provider/external/supabase-adapter.ts +163 -0
- package/src/provider/oauth/linked-identity-store.ts +108 -0
- package/src/provider/oauth/oauth-flow.ts +550 -0
- package/src/provider/oauth/oauth-types.ts +184 -0
- package/src/provider/oauth/postgres-oauth-store.ts +296 -0
- package/src/provider/oauth/sqlite-oauth-store.ts +285 -0
- package/src/rbac/rbac-engine.ts +323 -0
- package/src/rbac/rbac-types.ts +210 -0
- package/src/rbac/scope-resolver.ts +140 -0
- package/src/react/AuthProvider.tsx +97 -0
- package/src/react/OrgProvider.tsx +41 -0
- package/src/react/auth-context.ts +26 -0
- package/src/react/hooks.ts +110 -0
- package/src/react/org-hooks.ts +214 -0
- package/src/react.ts +26 -0
- package/src/server.ts +338 -0
- package/src/session/session.ts +401 -0
- package/src/svelte/auth-context.ts +50 -0
- package/src/svelte/org-context.ts +32 -0
- package/src/svelte/org-hooks.ts +201 -0
- package/src/svelte/use-auth.ts +115 -0
- package/src/svelte.ts +25 -0
- package/src/tokens/encrypted-token-store.ts +360 -0
- package/src/tokens/jwt.ts +236 -0
- package/src/tokens/postgres-token-revocation-store.ts +140 -0
- package/src/tokens/sqlite-token-revocation-store.ts +121 -0
- package/src/tokens/token-manager.ts +821 -0
- package/src/tokens/token-store.ts +192 -0
- package/src/types.ts +394 -0
- package/src/vue/auth-context.ts +10 -0
- package/src/vue/auth-provider-types.ts +5 -0
- package/src/vue/auth-provider.ts +76 -0
- package/src/vue/org-hooks.ts +193 -0
- package/src/vue/org-provider.ts +49 -0
- package/src/vue/use-auth.ts +139 -0
- 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
|
-
|
|
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 +
|
|
117
|
+
return payload.exp <= nowSeconds + bufferSeconds;
|
|
104
118
|
}
|
|
105
|
-
function
|
|
119
|
+
function tokenIssuedAtMs(token) {
|
|
106
120
|
const payload = decodeJwtPayload(token);
|
|
107
|
-
if (!payload
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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,
|
|
341
|
-
|
|
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
|
-
*
|
|
408
|
-
*
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
*
|
|
483
|
-
*
|
|
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
|
-
|
|
488
|
-
this.setState("authenticated", user);
|
|
711
|
+
response = await this.rawRequest("/auth/me", { method: "GET", token: accessToken });
|
|
489
712
|
} catch {
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
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
|
|
556
|
-
*
|
|
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
|
-
|
|
857
|
+
refresh() {
|
|
559
858
|
if (this._refreshPromise) {
|
|
560
859
|
return this._refreshPromise;
|
|
561
860
|
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
|
571
|
-
const
|
|
572
|
-
if (!
|
|
573
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
*
|
|
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
|
|
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.
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
if (typeof
|
|
631
|
-
errorMessage =
|
|
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
|
|
634
|
-
errorMessage =
|
|
1111
|
+
} else if (typeof response.json.message === "string") {
|
|
1112
|
+
errorMessage = response.json.message;
|
|
635
1113
|
serverError = errorMessage;
|
|
636
1114
|
}
|
|
637
|
-
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
|
1206
|
-
const
|
|
1207
|
-
return typeof
|
|
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
|
|
1220
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1246
|
-
|
|
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
|
|
1257
|
-
|
|
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) =>
|
|
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
|
|
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(
|
|
1616
|
-
return this.request(
|
|
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,
|