@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.js
CHANGED
|
@@ -38,7 +38,19 @@ var AuthError = class extends KoraError {
|
|
|
38
38
|
this.name = "AuthError";
|
|
39
39
|
}
|
|
40
40
|
};
|
|
41
|
+
var MfaRequiredError = class extends AuthError {
|
|
42
|
+
constructor(mfaToken) {
|
|
43
|
+
super("A second factor is required to finish signing in.", "AUTH_MFA_REQUIRED");
|
|
44
|
+
this.mfaToken = mfaToken;
|
|
45
|
+
this.name = "MfaRequiredError";
|
|
46
|
+
}
|
|
47
|
+
mfaToken;
|
|
48
|
+
};
|
|
41
49
|
var EXPIRY_BUFFER_SECONDS = 30;
|
|
50
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 2e4;
|
|
51
|
+
var DEFAULT_BACKOFF_BASE_MS = 2e3;
|
|
52
|
+
var DEFAULT_BACKOFF_MAX_MS = 5 * 6e4;
|
|
53
|
+
var CLOCK_ROLLBACK_TOLERANCE_MS = 5 * 6e4;
|
|
42
54
|
function decodeJwtPayload(token) {
|
|
43
55
|
const parts = token.split(".");
|
|
44
56
|
if (parts.length !== 3) {
|
|
@@ -47,25 +59,28 @@ function decodeJwtPayload(token) {
|
|
|
47
59
|
try {
|
|
48
60
|
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
49
61
|
const json = atob(base64);
|
|
50
|
-
|
|
62
|
+
const parsed = JSON.parse(json);
|
|
63
|
+
return isRecord(parsed) ? parsed : null;
|
|
51
64
|
} catch {
|
|
52
65
|
return null;
|
|
53
66
|
}
|
|
54
67
|
}
|
|
55
|
-
function isTokenExpired(token) {
|
|
68
|
+
function isTokenExpired(token, bufferSeconds = EXPIRY_BUFFER_SECONDS) {
|
|
56
69
|
const payload = decodeJwtPayload(token);
|
|
57
70
|
if (!payload || typeof payload.exp !== "number") {
|
|
58
71
|
return true;
|
|
59
72
|
}
|
|
60
73
|
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
61
|
-
return payload.exp <= nowSeconds +
|
|
74
|
+
return payload.exp <= nowSeconds + bufferSeconds;
|
|
62
75
|
}
|
|
63
|
-
function
|
|
76
|
+
function tokenIssuedAtMs(token) {
|
|
64
77
|
const payload = decodeJwtPayload(token);
|
|
65
|
-
if (!payload
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
78
|
+
if (!payload) return null;
|
|
79
|
+
if (typeof payload.iatMs === "number") return payload.iatMs;
|
|
80
|
+
return typeof payload.iat === "number" ? payload.iat * 1e3 : null;
|
|
81
|
+
}
|
|
82
|
+
function isRecord(value) {
|
|
83
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
69
84
|
}
|
|
70
85
|
function getDefaultFetch() {
|
|
71
86
|
if (typeof globalThis.fetch !== "function") {
|
|
@@ -97,6 +112,46 @@ function redirectCurrentWindow(url) {
|
|
|
97
112
|
}
|
|
98
113
|
globalThis.window.location.assign(url);
|
|
99
114
|
}
|
|
115
|
+
var OAUTH_BINDING_PREFIX = "kora_oauth_binding:";
|
|
116
|
+
function getSessionStorage() {
|
|
117
|
+
try {
|
|
118
|
+
const storage = globalThis.sessionStorage;
|
|
119
|
+
return storage && typeof storage.getItem === "function" ? storage : null;
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
var DEFINITIVE_REFRESH_CODES = /* @__PURE__ */ new Set(["REFRESH_TOKEN_INVALID", "invalid_grant"]);
|
|
125
|
+
function isDefinitiveRejection(response) {
|
|
126
|
+
if (!isRecord(response.json)) return false;
|
|
127
|
+
const code = typeof response.json.code === "string" ? response.json.code : void 0;
|
|
128
|
+
const error = typeof response.json.error === "string" ? response.json.error : void 0;
|
|
129
|
+
if (response.status === 401) return code !== void 0 || error !== void 0;
|
|
130
|
+
if (response.status === 400) {
|
|
131
|
+
return code !== void 0 && DEFINITIVE_REFRESH_CODES.has(code) || error !== void 0 && DEFINITIVE_REFRESH_CODES.has(error);
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
function parseRetryAfter(value) {
|
|
136
|
+
if (!value) return void 0;
|
|
137
|
+
const seconds = Number(value);
|
|
138
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
|
|
139
|
+
const date = Date.parse(value);
|
|
140
|
+
return Number.isNaN(date) ? void 0 : Math.max(0, date - Date.now());
|
|
141
|
+
}
|
|
142
|
+
function readTokenPair(json) {
|
|
143
|
+
if (!isRecord(json)) return null;
|
|
144
|
+
const data = json.data !== void 0 ? json.data : json;
|
|
145
|
+
if (!isRecord(data)) return null;
|
|
146
|
+
return typeof data.accessToken === "string" && typeof data.refreshToken === "string" ? { accessToken: data.accessToken, refreshToken: data.refreshToken } : null;
|
|
147
|
+
}
|
|
148
|
+
var inProcessLocks = /* @__PURE__ */ new WeakMap();
|
|
149
|
+
function getWebLocks() {
|
|
150
|
+
const nav = globalThis.navigator;
|
|
151
|
+
const locks = nav?.locks;
|
|
152
|
+
if (typeof locks !== "object" || locks === null) return null;
|
|
153
|
+
return typeof locks.request === "function" ? locks : null;
|
|
154
|
+
}
|
|
100
155
|
function createTokenStorage(prefix) {
|
|
101
156
|
let useLocalStorage = false;
|
|
102
157
|
try {
|
|
@@ -153,10 +208,22 @@ var AuthClient = class {
|
|
|
153
208
|
fetchFn;
|
|
154
209
|
deviceIdentity;
|
|
155
210
|
listeners = /* @__PURE__ */ new Set();
|
|
211
|
+
sessionListeners = /* @__PURE__ */ new Set();
|
|
212
|
+
requestTimeoutMs;
|
|
213
|
+
maxOfflineGraceMs;
|
|
214
|
+
backoffBaseMs;
|
|
215
|
+
backoffMaxMs;
|
|
216
|
+
lockName;
|
|
156
217
|
_state = "loading";
|
|
157
218
|
_user = null;
|
|
158
219
|
_refreshPromise = null;
|
|
159
220
|
_initialized = false;
|
|
221
|
+
sessionStatus = "fresh";
|
|
222
|
+
lastServerContactAt = 0;
|
|
223
|
+
failureCount = 0;
|
|
224
|
+
nextAttemptAt = 0;
|
|
225
|
+
retryTimer = null;
|
|
226
|
+
detachEnvironment;
|
|
160
227
|
/**
|
|
161
228
|
* Creates a new AuthClient.
|
|
162
229
|
*
|
|
@@ -168,6 +235,12 @@ var AuthClient = class {
|
|
|
168
235
|
this.storage = config.storage ?? createTokenStorage(prefix);
|
|
169
236
|
this.fetchFn = config.fetch ?? getDefaultFetch();
|
|
170
237
|
this.deviceIdentity = config.deviceIdentity;
|
|
238
|
+
this.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
239
|
+
this.maxOfflineGraceMs = config.maxOfflineGraceMs;
|
|
240
|
+
this.backoffBaseMs = config.refreshBackoff?.baseDelayMs ?? DEFAULT_BACKOFF_BASE_MS;
|
|
241
|
+
this.backoffMaxMs = config.refreshBackoff?.maxDelayMs ?? DEFAULT_BACKOFF_MAX_MS;
|
|
242
|
+
this.lockName = `kora-auth-refresh:${prefix}`;
|
|
243
|
+
this.detachEnvironment = this.attachEnvironmentListeners();
|
|
171
244
|
}
|
|
172
245
|
// -----------------------------------------------------------------------
|
|
173
246
|
// Public getters
|
|
@@ -180,10 +253,54 @@ var AuthClient = class {
|
|
|
180
253
|
get currentUser() {
|
|
181
254
|
return this._user;
|
|
182
255
|
}
|
|
183
|
-
/** Whether the user is currently authenticated. */
|
|
256
|
+
/** Whether the user is currently authenticated (fresh or offline). */
|
|
184
257
|
get isAuthenticated() {
|
|
185
258
|
return this._state === "authenticated";
|
|
186
259
|
}
|
|
260
|
+
/**
|
|
261
|
+
* The stored session identity and its freshness, or null when signed out.
|
|
262
|
+
* Unlike {@link getAccessToken}, this is available offline: the identity is
|
|
263
|
+
* decoupled from whether a fresh access token can be minted right now.
|
|
264
|
+
*/
|
|
265
|
+
get session() {
|
|
266
|
+
if (this._state !== "authenticated" || !this._user) return null;
|
|
267
|
+
return {
|
|
268
|
+
userId: this._user.id,
|
|
269
|
+
deviceId: this.cachedDeviceId,
|
|
270
|
+
status: this.sessionStatus,
|
|
271
|
+
lastServerContactAt: this.lastServerContactAt
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
cachedDeviceId = null;
|
|
275
|
+
/**
|
|
276
|
+
* Read the stored session identity (user id and device id) from storage,
|
|
277
|
+
* without any network request. Returns null when no usable session is stored.
|
|
278
|
+
*/
|
|
279
|
+
async getStoredIdentity() {
|
|
280
|
+
const claims = await this.getStoredClaims();
|
|
281
|
+
if (!claims || typeof claims.sub !== "string" || claims.sub.length === 0) return null;
|
|
282
|
+
return {
|
|
283
|
+
userId: claims.sub,
|
|
284
|
+
deviceId: typeof claims.dev === "string" && claims.dev.length > 0 ? claims.dev : null
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Decoded (unverified) claims of the stored credentials, preferring the access
|
|
289
|
+
* token even when it has expired. For client-side hints only (local database
|
|
290
|
+
* name, sync node id, handshake scope narrowing); the server re-derives
|
|
291
|
+
* everything it authorizes from a verified token.
|
|
292
|
+
*/
|
|
293
|
+
async getStoredClaims() {
|
|
294
|
+
const access = await this.storage.getAccessToken();
|
|
295
|
+
const refresh = await this.storage.getRefreshToken();
|
|
296
|
+
if (!refresh) return null;
|
|
297
|
+
for (const token of [access, refresh]) {
|
|
298
|
+
if (!token) continue;
|
|
299
|
+
const claims = decodeJwtPayload(token);
|
|
300
|
+
if (claims && typeof claims.sub === "string" && claims.sub.length > 0) return claims;
|
|
301
|
+
}
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
187
304
|
// -----------------------------------------------------------------------
|
|
188
305
|
// Initialization
|
|
189
306
|
// -----------------------------------------------------------------------
|
|
@@ -192,6 +309,8 @@ var AuthClient = class {
|
|
|
192
309
|
*
|
|
193
310
|
* Loads tokens from storage, validates the access token, and attempts a
|
|
194
311
|
* refresh if the access token is expired but a refresh token is available.
|
|
312
|
+
* When the auth server cannot be reached, the stored session is restored as
|
|
313
|
+
* authenticated-offline instead of being discarded.
|
|
195
314
|
* Safe to call multiple times -- subsequent calls are no-ops once initialized.
|
|
196
315
|
*/
|
|
197
316
|
async initialize() {
|
|
@@ -205,20 +324,21 @@ var AuthClient = class {
|
|
|
205
324
|
this.setState("unauthenticated", null);
|
|
206
325
|
return;
|
|
207
326
|
}
|
|
327
|
+
this.noteIssuedCredential(refreshToken);
|
|
208
328
|
if (!isTokenExpired(accessToken)) {
|
|
209
329
|
await this.restoreSession(accessToken);
|
|
210
330
|
return;
|
|
211
331
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
} catch {
|
|
332
|
+
const outcome = await this.refresh();
|
|
333
|
+
if (outcome.kind === "ok") {
|
|
334
|
+
await this.restoreSession(outcome.accessToken);
|
|
335
|
+
return;
|
|
219
336
|
}
|
|
220
|
-
|
|
221
|
-
|
|
337
|
+
if (outcome.kind === "rejected") {
|
|
338
|
+
this.setState("unauthenticated", null);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
await this.enterOfflineSession();
|
|
222
342
|
}
|
|
223
343
|
// -----------------------------------------------------------------------
|
|
224
344
|
// Sign up / Sign in / Sign out
|
|
@@ -236,11 +356,7 @@ var AuthClient = class {
|
|
|
236
356
|
method: "POST",
|
|
237
357
|
body
|
|
238
358
|
});
|
|
239
|
-
|
|
240
|
-
await this.storage.setTokens(tokens.accessToken, tokens.refreshToken);
|
|
241
|
-
const user = await this.fetchUserProfile(tokens.accessToken);
|
|
242
|
-
this.setState("authenticated", user);
|
|
243
|
-
return user;
|
|
359
|
+
return this.completeSignIn(response);
|
|
244
360
|
}
|
|
245
361
|
/**
|
|
246
362
|
* Sign in with email and password.
|
|
@@ -255,11 +371,22 @@ var AuthClient = class {
|
|
|
255
371
|
method: "POST",
|
|
256
372
|
body
|
|
257
373
|
});
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
374
|
+
return this.completeSignIn(response);
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Finish a sign-in that required a second factor.
|
|
378
|
+
*
|
|
379
|
+
* @param mfaToken - From the {@link MfaRequiredError} thrown by sign-in
|
|
380
|
+
* @param proof - A current TOTP code, or a recovery code
|
|
381
|
+
* @returns The authenticated AuthUser
|
|
382
|
+
* @throws {AuthError} If the code or the MFA session is invalid
|
|
383
|
+
*/
|
|
384
|
+
async verifyMfa(mfaToken, proof) {
|
|
385
|
+
const response = await this.request("/auth/mfa/verify", {
|
|
386
|
+
method: "POST",
|
|
387
|
+
body: { mfaToken, ...proof }
|
|
388
|
+
});
|
|
389
|
+
return this.completeSignIn(response);
|
|
263
390
|
}
|
|
264
391
|
/**
|
|
265
392
|
* Create an OAuth authorization URL and optionally redirect the current window.
|
|
@@ -280,14 +407,19 @@ var AuthClient = class {
|
|
|
280
407
|
*/
|
|
281
408
|
async completeOAuthSignIn(provider, params) {
|
|
282
409
|
const body = await this.withDeviceIdentity(params);
|
|
410
|
+
const binding = params.binding ?? this.takeOAuthBinding(params.state);
|
|
283
411
|
const response = await this.request(
|
|
284
412
|
`/auth/oauth/${encodeURIComponent(provider)}/callback`,
|
|
285
413
|
{
|
|
286
414
|
method: "POST",
|
|
287
|
-
body: { ...body }
|
|
415
|
+
body: { ...body, ...binding ? { binding } : {} }
|
|
288
416
|
}
|
|
289
417
|
);
|
|
418
|
+
if ("mfaRequired" in response) {
|
|
419
|
+
throw new MfaRequiredError(response.mfaToken);
|
|
420
|
+
}
|
|
290
421
|
await this.storage.setTokens(response.tokens.accessToken, response.tokens.refreshToken);
|
|
422
|
+
this.markFresh(response.tokens.refreshToken);
|
|
291
423
|
const user = normalizeAuthUser(response.user);
|
|
292
424
|
this.setState("authenticated", user);
|
|
293
425
|
return user;
|
|
@@ -295,19 +427,27 @@ var AuthClient = class {
|
|
|
295
427
|
/**
|
|
296
428
|
* Create an OAuth authorization URL for linking another provider to the current user.
|
|
297
429
|
*/
|
|
298
|
-
async getOAuthAuthorizationUrl(provider,
|
|
299
|
-
|
|
430
|
+
async getOAuthAuthorizationUrl(provider, _options = {}) {
|
|
431
|
+
const token = await this.requireAccessToken();
|
|
432
|
+
const result = await this.request(
|
|
433
|
+
`/auth/oauth/${encodeURIComponent(provider)}/link/start`,
|
|
434
|
+
{ method: "POST", body: {}, token }
|
|
435
|
+
);
|
|
436
|
+
this.rememberOAuthBinding(result);
|
|
437
|
+
return result;
|
|
300
438
|
}
|
|
301
439
|
/**
|
|
302
440
|
* Link an OAuth provider to the current authenticated user.
|
|
303
441
|
*/
|
|
304
442
|
async linkOAuth(provider, params) {
|
|
305
443
|
const token = await this.requireAccessToken();
|
|
444
|
+
const binding = params.binding ?? this.takeOAuthBinding(params.state);
|
|
306
445
|
return this.request(`/auth/oauth/${encodeURIComponent(provider)}/link`, {
|
|
307
446
|
method: "POST",
|
|
308
447
|
body: {
|
|
309
448
|
code: params.code,
|
|
310
|
-
state: params.state
|
|
449
|
+
state: params.state,
|
|
450
|
+
...binding ? { binding } : {}
|
|
311
451
|
},
|
|
312
452
|
token
|
|
313
453
|
});
|
|
@@ -344,6 +484,9 @@ var AuthClient = class {
|
|
|
344
484
|
const refreshToken = await this.storage.getRefreshToken();
|
|
345
485
|
await this.storage.clear();
|
|
346
486
|
this._refreshPromise = null;
|
|
487
|
+
this.resetBackoff();
|
|
488
|
+
this.sessionStatus = "fresh";
|
|
489
|
+
this.cachedDeviceId = null;
|
|
347
490
|
this.setState("unauthenticated", null);
|
|
348
491
|
if (accessToken) {
|
|
349
492
|
try {
|
|
@@ -362,24 +505,46 @@ var AuthClient = class {
|
|
|
362
505
|
/**
|
|
363
506
|
* Get a valid access token, automatically refreshing if expired.
|
|
364
507
|
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
508
|
+
* Returns null when no fresh token can be obtained right now. That does NOT
|
|
509
|
+
* mean the user is signed out: check {@link state} / {@link session}. A
|
|
510
|
+
* transient failure keeps the session (authenticated-offline) and later calls
|
|
511
|
+
* retry with backoff.
|
|
512
|
+
*
|
|
513
|
+
* @returns A valid access token string, or null if none is available now
|
|
367
514
|
*/
|
|
368
515
|
async getAccessToken() {
|
|
369
516
|
const accessToken = await this.storage.getAccessToken();
|
|
370
517
|
if (accessToken && !isTokenExpired(accessToken)) {
|
|
518
|
+
if (this._state === "authenticated" && this.sessionStatus !== "fresh") {
|
|
519
|
+
this.markFresh(await this.storage.getRefreshToken());
|
|
520
|
+
}
|
|
371
521
|
return accessToken;
|
|
372
522
|
}
|
|
373
523
|
const refreshToken = await this.storage.getRefreshToken();
|
|
374
524
|
if (!refreshToken) {
|
|
375
525
|
return null;
|
|
376
526
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
527
|
+
const outcome = await this.refresh();
|
|
528
|
+
return outcome.kind === "ok" ? outcome.accessToken : null;
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Refresh the session now, even when the cached access token has not expired
|
|
532
|
+
* locally. Used after the sync server ended a session with `AUTH_EXPIRED` or
|
|
533
|
+
* `AUTH_REVOKED`: the server's clock or a revocation says the cached token is
|
|
534
|
+
* no longer good. Concurrent calls (and other tabs) share one refresh.
|
|
535
|
+
*
|
|
536
|
+
* A transient failure keeps the session (authenticated-offline) and returns
|
|
537
|
+
* null; only a definitive server rejection signs the user out.
|
|
538
|
+
*
|
|
539
|
+
* @returns The refreshed access token, or null if none could be obtained now
|
|
540
|
+
*/
|
|
541
|
+
async refreshAccessToken() {
|
|
542
|
+
const refreshToken = await this.storage.getRefreshToken();
|
|
543
|
+
if (!refreshToken) {
|
|
381
544
|
return null;
|
|
382
545
|
}
|
|
546
|
+
const outcome = await this.refresh();
|
|
547
|
+
return outcome.kind === "ok" ? outcome.accessToken : null;
|
|
383
548
|
}
|
|
384
549
|
/**
|
|
385
550
|
* Get a valid token for the sync engine handshake.
|
|
@@ -390,6 +555,23 @@ var AuthClient = class {
|
|
|
390
555
|
async getSyncToken() {
|
|
391
556
|
return this.getAccessToken();
|
|
392
557
|
}
|
|
558
|
+
/**
|
|
559
|
+
* Retry immediately: clears the refresh backoff and attempts a refresh if the
|
|
560
|
+
* session is offline. Call it when connectivity is known to be back (for
|
|
561
|
+
* example when a sync transport opens). Also wired to the browser `online`
|
|
562
|
+
* and `visibilitychange` events automatically.
|
|
563
|
+
*/
|
|
564
|
+
async retryNow() {
|
|
565
|
+
this.resetBackoff();
|
|
566
|
+
if (this._state === "authenticated" && this.sessionStatus !== "fresh") {
|
|
567
|
+
await this.getAccessToken();
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
/** Remove environment listeners and timers (for tests and teardown). */
|
|
571
|
+
destroy() {
|
|
572
|
+
this.detachEnvironment();
|
|
573
|
+
this.clearRetryTimer();
|
|
574
|
+
}
|
|
393
575
|
// -----------------------------------------------------------------------
|
|
394
576
|
// State change subscriptions
|
|
395
577
|
// -----------------------------------------------------------------------
|
|
@@ -416,6 +598,20 @@ var AuthClient = class {
|
|
|
416
598
|
this.listeners.delete(callback);
|
|
417
599
|
};
|
|
418
600
|
}
|
|
601
|
+
/**
|
|
602
|
+
* Subscribe to session freshness changes (fresh, authenticated-offline,
|
|
603
|
+
* locked). Fires in addition to {@link onAuthChange}, including when the
|
|
604
|
+
* state stays 'authenticated' but connectivity to the auth server changes.
|
|
605
|
+
*
|
|
606
|
+
* @param callback - Called with the current session (null when signed out)
|
|
607
|
+
* @returns An unsubscribe function
|
|
608
|
+
*/
|
|
609
|
+
onSessionChange(callback) {
|
|
610
|
+
this.sessionListeners.add(callback);
|
|
611
|
+
return () => {
|
|
612
|
+
this.sessionListeners.delete(callback);
|
|
613
|
+
};
|
|
614
|
+
}
|
|
419
615
|
// -----------------------------------------------------------------------
|
|
420
616
|
// Internal helpers
|
|
421
617
|
// -----------------------------------------------------------------------
|
|
@@ -433,30 +629,115 @@ var AuthClient = class {
|
|
|
433
629
|
} catch {
|
|
434
630
|
}
|
|
435
631
|
}
|
|
632
|
+
this.notifySession();
|
|
436
633
|
}
|
|
437
634
|
}
|
|
635
|
+
setSessionStatus(status) {
|
|
636
|
+
if (this.sessionStatus === status) return;
|
|
637
|
+
this.sessionStatus = status;
|
|
638
|
+
this.notifySession();
|
|
639
|
+
}
|
|
640
|
+
notifySession() {
|
|
641
|
+
const session = this.session;
|
|
642
|
+
for (const listener of this.sessionListeners) {
|
|
643
|
+
try {
|
|
644
|
+
listener(session);
|
|
645
|
+
} catch {
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
async completeSignIn(response) {
|
|
650
|
+
if ("mfaRequired" in response) {
|
|
651
|
+
throw new MfaRequiredError(response.mfaToken);
|
|
652
|
+
}
|
|
653
|
+
const tokens = "tokens" in response ? response.tokens : response;
|
|
654
|
+
await this.storage.setTokens(tokens.accessToken, tokens.refreshToken);
|
|
655
|
+
this.markFresh(tokens.refreshToken);
|
|
656
|
+
const user = "user" in response && response.user ? normalizeAuthUser(response.user) : await this.fetchUserProfile(tokens.accessToken);
|
|
657
|
+
this.setState("authenticated", user);
|
|
658
|
+
return user;
|
|
659
|
+
}
|
|
438
660
|
/**
|
|
439
661
|
* Restore a session from a valid access token by fetching the user profile.
|
|
440
|
-
*
|
|
441
|
-
*
|
|
662
|
+
* A definitive 401 from `/auth/me` ends the session (NEW-AUTH-4); any other
|
|
663
|
+
* failure restores it as authenticated-offline from the stored identity.
|
|
442
664
|
*/
|
|
443
665
|
async restoreSession(accessToken) {
|
|
666
|
+
let response;
|
|
444
667
|
try {
|
|
445
|
-
|
|
446
|
-
this.setState("authenticated", user);
|
|
668
|
+
response = await this.rawRequest("/auth/me", { method: "GET", token: accessToken });
|
|
447
669
|
} catch {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
this.setState("unauthenticated", null);
|
|
670
|
+
await this.enterOfflineSession();
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
if (response.ok && isRecord(response.json)) {
|
|
674
|
+
const profile = response.json.data !== void 0 ? response.json.data : response.json;
|
|
675
|
+
if (isRecord(profile) && typeof profile.id === "string") {
|
|
676
|
+
this.markFresh(null);
|
|
677
|
+
this.setState("authenticated", normalizeAuthUser(profile));
|
|
678
|
+
return;
|
|
458
679
|
}
|
|
459
680
|
}
|
|
681
|
+
if (response.status === 401 && isDefinitiveRejection(response)) {
|
|
682
|
+
await this.endSession();
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
await this.enterOfflineSession();
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Restore the session from stored credentials while the auth server is
|
|
689
|
+
* unreachable. Signs out only when the stored refresh token is unusable.
|
|
690
|
+
*/
|
|
691
|
+
async enterOfflineSession() {
|
|
692
|
+
const refreshToken = await this.storage.getRefreshToken();
|
|
693
|
+
const identity = await this.getStoredIdentity();
|
|
694
|
+
if (!refreshToken || !identity || isTokenExpired(refreshToken, 0)) {
|
|
695
|
+
await this.endSession();
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
this.cachedDeviceId = identity.deviceId;
|
|
699
|
+
this.noteIssuedCredential(refreshToken);
|
|
700
|
+
const user = this._user && this._user.id === identity.userId ? this._user : { id: identity.userId, email: "", name: null };
|
|
701
|
+
this.sessionStatus = this.offlineStatus();
|
|
702
|
+
this.setState("authenticated", user);
|
|
703
|
+
this.notifySession();
|
|
704
|
+
}
|
|
705
|
+
/** Offline, or locked when the grace period ran out or the clock went backwards. */
|
|
706
|
+
offlineStatus() {
|
|
707
|
+
const now = Date.now();
|
|
708
|
+
if (this.lastServerContactAt > 0 && now + CLOCK_ROLLBACK_TOLERANCE_MS < this.lastServerContactAt) {
|
|
709
|
+
return "locked";
|
|
710
|
+
}
|
|
711
|
+
if (this.maxOfflineGraceMs !== void 0 && this.lastServerContactAt > 0 && now - this.lastServerContactAt > this.maxOfflineGraceMs) {
|
|
712
|
+
return "locked";
|
|
713
|
+
}
|
|
714
|
+
return "offline";
|
|
715
|
+
}
|
|
716
|
+
async endSession() {
|
|
717
|
+
await this.storage.clear();
|
|
718
|
+
this.resetBackoff();
|
|
719
|
+
this.sessionStatus = "fresh";
|
|
720
|
+
this.cachedDeviceId = null;
|
|
721
|
+
this.setState("unauthenticated", null);
|
|
722
|
+
}
|
|
723
|
+
/** The refresh token's issue time is the server's clock at the last rotation. */
|
|
724
|
+
noteIssuedCredential(refreshToken) {
|
|
725
|
+
if (!refreshToken) return;
|
|
726
|
+
const issued = tokenIssuedAtMs(refreshToken);
|
|
727
|
+
if (issued !== null) this.lastServerContactAt = Math.max(this.lastServerContactAt, issued);
|
|
728
|
+
const claims = decodeJwtPayload(refreshToken);
|
|
729
|
+
if (claims && typeof claims.dev === "string") this.cachedDeviceId = claims.dev;
|
|
730
|
+
}
|
|
731
|
+
markFresh(refreshToken) {
|
|
732
|
+
this.resetBackoff();
|
|
733
|
+
this.lastServerContactAt = Math.max(this.lastServerContactAt, Date.now());
|
|
734
|
+
this.noteIssuedCredential(refreshToken);
|
|
735
|
+
this.setSessionStatus("fresh");
|
|
736
|
+
}
|
|
737
|
+
markOffline() {
|
|
738
|
+
if (this._state === "authenticated") {
|
|
739
|
+
this.setSessionStatus(this.offlineStatus());
|
|
740
|
+
}
|
|
460
741
|
}
|
|
461
742
|
/**
|
|
462
743
|
* Fetch the current user profile from the server.
|
|
@@ -470,19 +751,9 @@ var AuthClient = class {
|
|
|
470
751
|
}
|
|
471
752
|
async createOAuthAuthorization(provider, options) {
|
|
472
753
|
const params = new URLSearchParams();
|
|
473
|
-
const withIdentity = await this.withDeviceIdentity({
|
|
474
|
-
deviceId: options.deviceId,
|
|
475
|
-
devicePublicKey: options.devicePublicKey
|
|
476
|
-
});
|
|
477
754
|
if (options.returnTo) {
|
|
478
755
|
params.set("returnTo", options.returnTo);
|
|
479
756
|
}
|
|
480
|
-
if (withIdentity.deviceId) {
|
|
481
|
-
params.set("deviceId", withIdentity.deviceId);
|
|
482
|
-
}
|
|
483
|
-
if (withIdentity.devicePublicKey) {
|
|
484
|
-
params.set("devicePublicKey", withIdentity.devicePublicKey);
|
|
485
|
-
}
|
|
486
757
|
if (options.metadata) {
|
|
487
758
|
for (const [key, value] of Object.entries(options.metadata)) {
|
|
488
759
|
if (value !== void 0 && value !== null) {
|
|
@@ -491,12 +762,38 @@ var AuthClient = class {
|
|
|
491
762
|
}
|
|
492
763
|
}
|
|
493
764
|
const query = params.toString();
|
|
494
|
-
|
|
765
|
+
const result = await this.request(
|
|
495
766
|
`/auth/oauth/${encodeURIComponent(provider)}${query ? `?${query}` : ""}`,
|
|
496
767
|
{
|
|
497
768
|
method: "GET"
|
|
498
769
|
}
|
|
499
770
|
);
|
|
771
|
+
this.rememberOAuthBinding(result);
|
|
772
|
+
return result;
|
|
773
|
+
}
|
|
774
|
+
/** Bindings of flows this client started, by state (survives the redirect on web). */
|
|
775
|
+
oauthBindings = /* @__PURE__ */ new Map();
|
|
776
|
+
rememberOAuthBinding(result) {
|
|
777
|
+
if (!result.binding || !result.state) return;
|
|
778
|
+
this.oauthBindings.set(result.state, result.binding);
|
|
779
|
+
const session = getSessionStorage();
|
|
780
|
+
try {
|
|
781
|
+
session?.setItem(`${OAUTH_BINDING_PREFIX}${result.state}`, result.binding);
|
|
782
|
+
} catch {
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
takeOAuthBinding(state) {
|
|
786
|
+
const inMemory = this.oauthBindings.get(state);
|
|
787
|
+
this.oauthBindings.delete(state);
|
|
788
|
+
const session = getSessionStorage();
|
|
789
|
+
let stored = null;
|
|
790
|
+
try {
|
|
791
|
+
stored = session?.getItem(`${OAUTH_BINDING_PREFIX}${state}`) ?? null;
|
|
792
|
+
session?.removeItem(`${OAUTH_BINDING_PREFIX}${state}`);
|
|
793
|
+
} catch {
|
|
794
|
+
stored = null;
|
|
795
|
+
}
|
|
796
|
+
return inMemory ?? stored ?? void 0;
|
|
500
797
|
}
|
|
501
798
|
async withDeviceIdentity(params) {
|
|
502
799
|
if (!this.deviceIdentity || params.deviceId && params.devicePublicKey) {
|
|
@@ -510,54 +807,187 @@ var AuthClient = class {
|
|
|
510
807
|
};
|
|
511
808
|
}
|
|
512
809
|
/**
|
|
513
|
-
* Refresh the
|
|
514
|
-
*
|
|
810
|
+
* Refresh the session's tokens. De-duplicates concurrent calls in this
|
|
811
|
+
* client, serializes refreshes across tabs, and applies backoff after
|
|
812
|
+
* transient failures.
|
|
515
813
|
*/
|
|
516
|
-
|
|
814
|
+
refresh() {
|
|
517
815
|
if (this._refreshPromise) {
|
|
518
816
|
return this._refreshPromise;
|
|
519
817
|
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
this._refreshPromise = null;
|
|
526
|
-
}
|
|
818
|
+
const promise = this.refreshOnce().finally(() => {
|
|
819
|
+
if (this._refreshPromise === promise) this._refreshPromise = null;
|
|
820
|
+
});
|
|
821
|
+
this._refreshPromise = promise;
|
|
822
|
+
return promise;
|
|
527
823
|
}
|
|
528
|
-
async
|
|
529
|
-
const
|
|
530
|
-
if (!
|
|
531
|
-
|
|
824
|
+
async refreshOnce() {
|
|
825
|
+
const refreshToken = await this.storage.getRefreshToken();
|
|
826
|
+
if (!refreshToken) return { kind: "rejected" };
|
|
827
|
+
if (isTokenExpired(refreshToken, 0)) {
|
|
828
|
+
await this.endSession();
|
|
829
|
+
return { kind: "rejected" };
|
|
532
830
|
}
|
|
533
|
-
|
|
831
|
+
if (Date.now() < this.nextAttemptAt) {
|
|
832
|
+
this.markOffline();
|
|
833
|
+
return { kind: "transient" };
|
|
834
|
+
}
|
|
835
|
+
let outcome;
|
|
836
|
+
try {
|
|
837
|
+
outcome = await this.withRefreshLock(async () => {
|
|
838
|
+
const current = await this.storage.getRefreshToken();
|
|
839
|
+
if (!current) return { kind: "rejected" };
|
|
840
|
+
const currentAccess = await this.storage.getAccessToken();
|
|
841
|
+
if (current !== refreshToken && currentAccess && !isTokenExpired(currentAccess)) {
|
|
842
|
+
return { kind: "ok", accessToken: currentAccess };
|
|
843
|
+
}
|
|
844
|
+
return this.performRefresh(current);
|
|
845
|
+
});
|
|
846
|
+
} catch {
|
|
847
|
+
outcome = { kind: "transient" };
|
|
848
|
+
}
|
|
849
|
+
if (outcome.kind === "ok") {
|
|
850
|
+
this.markFresh(await this.storage.getRefreshToken());
|
|
851
|
+
} else if (outcome.kind === "rejected") {
|
|
852
|
+
this.resetBackoff();
|
|
853
|
+
this.cachedDeviceId = null;
|
|
854
|
+
this.sessionStatus = "fresh";
|
|
855
|
+
this.setState("unauthenticated", null);
|
|
856
|
+
} else {
|
|
857
|
+
this.registerFailure(outcome.retryAfterMs);
|
|
858
|
+
this.markOffline();
|
|
859
|
+
}
|
|
860
|
+
return outcome;
|
|
534
861
|
}
|
|
535
862
|
/**
|
|
536
|
-
* Execute the token refresh network request.
|
|
863
|
+
* Execute the token refresh network request and classify the answer.
|
|
537
864
|
*/
|
|
538
865
|
async performRefresh(refreshToken) {
|
|
866
|
+
let response;
|
|
539
867
|
try {
|
|
540
|
-
|
|
868
|
+
response = await this.rawRequest("/auth/refresh", {
|
|
541
869
|
method: "POST",
|
|
542
870
|
body: { refreshToken }
|
|
543
871
|
});
|
|
544
|
-
await this.storage.setTokens(response.accessToken, response.refreshToken);
|
|
545
|
-
return response.accessToken;
|
|
546
872
|
} catch {
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
873
|
+
return { kind: "transient" };
|
|
874
|
+
}
|
|
875
|
+
if (response.ok) {
|
|
876
|
+
const tokens = readTokenPair(response.json);
|
|
877
|
+
if (!tokens) {
|
|
878
|
+
return { kind: "transient" };
|
|
879
|
+
}
|
|
880
|
+
await this.storage.setTokens(tokens.accessToken, tokens.refreshToken);
|
|
881
|
+
return { kind: "ok", accessToken: tokens.accessToken };
|
|
882
|
+
}
|
|
883
|
+
if (isDefinitiveRejection(response)) {
|
|
884
|
+
if (await this.storage.getRefreshToken() === refreshToken) {
|
|
885
|
+
await this.storage.clear();
|
|
886
|
+
return { kind: "rejected" };
|
|
887
|
+
}
|
|
888
|
+
const adopted = await this.storage.getAccessToken();
|
|
889
|
+
return adopted && !isTokenExpired(adopted) ? { kind: "ok", accessToken: adopted } : { kind: "transient" };
|
|
550
890
|
}
|
|
891
|
+
return { kind: "transient", retryAfterMs: response.retryAfterMs };
|
|
892
|
+
}
|
|
893
|
+
async withRefreshLock(fn) {
|
|
894
|
+
const locks = getWebLocks();
|
|
895
|
+
if (locks) {
|
|
896
|
+
const controller = typeof AbortController === "function" ? new AbortController() : null;
|
|
897
|
+
const timer = controller ? setTimeout(() => controller.abort(), this.requestTimeoutMs * 2) : null;
|
|
898
|
+
try {
|
|
899
|
+
return await locks.request(
|
|
900
|
+
this.lockName,
|
|
901
|
+
controller ? { signal: controller.signal } : {},
|
|
902
|
+
async () => {
|
|
903
|
+
if (timer) clearTimeout(timer);
|
|
904
|
+
return fn();
|
|
905
|
+
}
|
|
906
|
+
);
|
|
907
|
+
} finally {
|
|
908
|
+
if (timer) clearTimeout(timer);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
const key = this.storage;
|
|
912
|
+
const previous = inProcessLocks.get(key) ?? Promise.resolve();
|
|
913
|
+
const run = previous.then(fn, fn);
|
|
914
|
+
inProcessLocks.set(
|
|
915
|
+
key,
|
|
916
|
+
run.then(
|
|
917
|
+
() => void 0,
|
|
918
|
+
() => void 0
|
|
919
|
+
)
|
|
920
|
+
);
|
|
921
|
+
return run;
|
|
551
922
|
}
|
|
552
923
|
/**
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
* @param options - Request options
|
|
557
|
-
* @returns Parsed JSON response body
|
|
558
|
-
* @throws {AuthError} On network failure or non-2xx response
|
|
924
|
+
* Schedule the next allowed refresh attempt. The first retry after a failure
|
|
925
|
+
* is immediate (it recovers a rotation response lost on the wire); after
|
|
926
|
+
* that, exponential backoff with equal jitter, never earlier than Retry-After.
|
|
559
927
|
*/
|
|
560
|
-
|
|
928
|
+
registerFailure(retryAfterMs) {
|
|
929
|
+
this.failureCount++;
|
|
930
|
+
let delay = 0;
|
|
931
|
+
if (this.failureCount > 1) {
|
|
932
|
+
const ceiling = Math.min(this.backoffBaseMs * 2 ** (this.failureCount - 2), this.backoffMaxMs);
|
|
933
|
+
delay = ceiling / 2 + Math.random() * (ceiling / 2);
|
|
934
|
+
}
|
|
935
|
+
if (retryAfterMs !== void 0) delay = Math.max(delay, retryAfterMs);
|
|
936
|
+
this.nextAttemptAt = Date.now() + delay;
|
|
937
|
+
this.scheduleRetry(Math.max(delay, this.backoffBaseMs));
|
|
938
|
+
}
|
|
939
|
+
resetBackoff() {
|
|
940
|
+
this.failureCount = 0;
|
|
941
|
+
this.nextAttemptAt = 0;
|
|
942
|
+
this.clearRetryTimer();
|
|
943
|
+
}
|
|
944
|
+
/** Background retry so an offline session recovers without the app calling in. */
|
|
945
|
+
scheduleRetry(delayMs) {
|
|
946
|
+
this.clearRetryTimer();
|
|
947
|
+
if (typeof setTimeout !== "function") return;
|
|
948
|
+
const timer = setTimeout(() => {
|
|
949
|
+
this.retryTimer = null;
|
|
950
|
+
if (this._state === "authenticated" && this.sessionStatus !== "fresh") {
|
|
951
|
+
void this.getAccessToken().catch(() => void 0);
|
|
952
|
+
}
|
|
953
|
+
}, delayMs);
|
|
954
|
+
timer.unref?.();
|
|
955
|
+
this.retryTimer = timer;
|
|
956
|
+
}
|
|
957
|
+
clearRetryTimer() {
|
|
958
|
+
if (this.retryTimer !== null) {
|
|
959
|
+
clearTimeout(this.retryTimer);
|
|
960
|
+
this.retryTimer = null;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
attachEnvironmentListeners() {
|
|
964
|
+
const target = globalThis.window;
|
|
965
|
+
if (!target || typeof target.addEventListener !== "function") return () => {
|
|
966
|
+
};
|
|
967
|
+
const wake = () => {
|
|
968
|
+
const doc = globalThis.document;
|
|
969
|
+
if (doc?.visibilityState === "hidden") return;
|
|
970
|
+
void this.retryNow().catch(() => void 0);
|
|
971
|
+
};
|
|
972
|
+
target.addEventListener("online", wake);
|
|
973
|
+
target.addEventListener("visibilitychange", wake);
|
|
974
|
+
return () => {
|
|
975
|
+
target.removeEventListener?.("online", wake);
|
|
976
|
+
target.removeEventListener?.("visibilitychange", wake);
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
async requireAccessToken() {
|
|
980
|
+
const token = await this.getAccessToken();
|
|
981
|
+
if (!token) {
|
|
982
|
+
throw new AuthError("You must be signed in to perform this action.", "AUTH_REQUIRED");
|
|
983
|
+
}
|
|
984
|
+
return token;
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Send one request with a timeout and return status plus parsed JSON (if any).
|
|
988
|
+
* Throws only for transport failures (network, abort, timeout).
|
|
989
|
+
*/
|
|
990
|
+
async rawRequest(path, options) {
|
|
561
991
|
const url = `${this.serverUrl}${path}`;
|
|
562
992
|
const headers = {};
|
|
563
993
|
if (options.body) {
|
|
@@ -566,14 +996,61 @@ var AuthClient = class {
|
|
|
566
996
|
if (options.token) {
|
|
567
997
|
headers.Authorization = `Bearer ${options.token}`;
|
|
568
998
|
}
|
|
999
|
+
const controller = typeof AbortController === "function" ? new AbortController() : null;
|
|
1000
|
+
let timer = null;
|
|
1001
|
+
const timeout = new Promise((_, reject) => {
|
|
1002
|
+
timer = setTimeout(() => {
|
|
1003
|
+
controller?.abort();
|
|
1004
|
+
reject(
|
|
1005
|
+
new AuthError(
|
|
1006
|
+
`Request to ${path} timed out after ${this.requestTimeoutMs}ms.`,
|
|
1007
|
+
"AUTH_TIMEOUT",
|
|
1008
|
+
{ path }
|
|
1009
|
+
)
|
|
1010
|
+
);
|
|
1011
|
+
}, this.requestTimeoutMs);
|
|
1012
|
+
});
|
|
1013
|
+
try {
|
|
1014
|
+
const response = await Promise.race([
|
|
1015
|
+
this.fetchFn(url, {
|
|
1016
|
+
method: options.method,
|
|
1017
|
+
headers,
|
|
1018
|
+
body: options.body ? JSON.stringify(options.body) : void 0,
|
|
1019
|
+
...controller ? { signal: controller.signal } : {}
|
|
1020
|
+
}),
|
|
1021
|
+
timeout
|
|
1022
|
+
]);
|
|
1023
|
+
let json;
|
|
1024
|
+
try {
|
|
1025
|
+
json = await Promise.race([response.json(), timeout]);
|
|
1026
|
+
} catch {
|
|
1027
|
+
json = void 0;
|
|
1028
|
+
}
|
|
1029
|
+
const headerGetter = response.headers;
|
|
1030
|
+
return {
|
|
1031
|
+
status: response.status,
|
|
1032
|
+
ok: response.ok,
|
|
1033
|
+
json,
|
|
1034
|
+
retryAfterMs: parseRetryAfter(headerGetter?.get?.("Retry-After"))
|
|
1035
|
+
};
|
|
1036
|
+
} finally {
|
|
1037
|
+
if (timer !== null) clearTimeout(timer);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Make an HTTP request to the auth server.
|
|
1042
|
+
*
|
|
1043
|
+
* @param path - URL path relative to serverUrl (e.g. '/auth/signin')
|
|
1044
|
+
* @param options - Request options
|
|
1045
|
+
* @returns Parsed JSON response body
|
|
1046
|
+
* @throws {AuthError} On network failure, timeout, or non-2xx response
|
|
1047
|
+
*/
|
|
1048
|
+
async request(path, options) {
|
|
569
1049
|
let response;
|
|
570
1050
|
try {
|
|
571
|
-
response = await this.
|
|
572
|
-
method: options.method,
|
|
573
|
-
headers,
|
|
574
|
-
body: options.body ? JSON.stringify(options.body) : void 0
|
|
575
|
-
});
|
|
1051
|
+
response = await this.rawRequest(path, options);
|
|
576
1052
|
} catch (cause) {
|
|
1053
|
+
if (cause instanceof AuthError) throw cause;
|
|
577
1054
|
throw new AuthError(
|
|
578
1055
|
`Network request to ${path} failed. The auth server at ${this.serverUrl} may be unreachable. Check your network connection and serverUrl configuration.`,
|
|
579
1056
|
"AUTH_NETWORK_ERROR",
|
|
@@ -583,26 +1060,33 @@ var AuthClient = class {
|
|
|
583
1060
|
if (!response.ok) {
|
|
584
1061
|
let errorMessage = `Auth server returned HTTP ${response.status}`;
|
|
585
1062
|
let serverError;
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
if (typeof
|
|
589
|
-
errorMessage =
|
|
1063
|
+
let serverCode;
|
|
1064
|
+
if (isRecord(response.json)) {
|
|
1065
|
+
if (typeof response.json.error === "string") {
|
|
1066
|
+
errorMessage = response.json.error;
|
|
590
1067
|
serverError = errorMessage;
|
|
591
|
-
} else if (typeof
|
|
592
|
-
errorMessage =
|
|
1068
|
+
} else if (typeof response.json.message === "string") {
|
|
1069
|
+
errorMessage = response.json.message;
|
|
593
1070
|
serverError = errorMessage;
|
|
594
1071
|
}
|
|
595
|
-
|
|
1072
|
+
if (typeof response.json.code === "string") serverCode = response.json.code;
|
|
596
1073
|
}
|
|
597
1074
|
throw new AuthError(errorMessage, "AUTH_SERVER_ERROR", {
|
|
598
1075
|
path,
|
|
599
1076
|
status: response.status,
|
|
600
|
-
serverError
|
|
1077
|
+
serverError,
|
|
1078
|
+
serverCode
|
|
601
1079
|
});
|
|
602
1080
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
1081
|
+
if (!isRecord(response.json) && !Array.isArray(response.json)) {
|
|
1082
|
+
throw new AuthError(
|
|
1083
|
+
`Auth server returned a non-JSON response for ${path}. A captive portal or proxy may be intercepting requests.`,
|
|
1084
|
+
"AUTH_INVALID_RESPONSE",
|
|
1085
|
+
{ path, status: response.status }
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
const json = response.json;
|
|
1089
|
+
return json.data !== void 0 ? json.data : json;
|
|
606
1090
|
}
|
|
607
1091
|
};
|
|
608
1092
|
|
|
@@ -940,7 +1424,10 @@ function createKoraAuth(options) {
|
|
|
940
1424
|
storageKey: options.storageKey,
|
|
941
1425
|
storage,
|
|
942
1426
|
fetch: options.fetch,
|
|
943
|
-
deviceIdentity
|
|
1427
|
+
deviceIdentity,
|
|
1428
|
+
requestTimeoutMs: options.requestTimeoutMs,
|
|
1429
|
+
maxOfflineGraceMs: options.maxOfflineGraceMs,
|
|
1430
|
+
refreshBackoff: options.refreshBackoff
|
|
944
1431
|
});
|
|
945
1432
|
}
|
|
946
1433
|
function tryCreateDefaultTokenStorage(storageKey) {
|
|
@@ -1002,38 +1489,49 @@ function decodeJwtPayload2(token) {
|
|
|
1002
1489
|
return null;
|
|
1003
1490
|
}
|
|
1004
1491
|
}
|
|
1005
|
-
function
|
|
1006
|
-
const
|
|
1007
|
-
return typeof
|
|
1492
|
+
function readString(claims, key) {
|
|
1493
|
+
const value = claims?.[key];
|
|
1494
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
1008
1495
|
}
|
|
1009
1496
|
function createKoraAuthSync(options) {
|
|
1010
1497
|
const { authClient, schema, scopeFromClaims, anonymous = "suspend" } = options;
|
|
1498
|
+
const resolveClaims = async () => {
|
|
1499
|
+
const token = await authClient.getAccessToken();
|
|
1500
|
+
if (token) return { claims: decodeJwtPayload2(token), token };
|
|
1501
|
+
if (authClient.state === "unauthenticated" || !authClient.getStoredClaims) {
|
|
1502
|
+
return { claims: null, token: null };
|
|
1503
|
+
}
|
|
1504
|
+
return { claims: await authClient.getStoredClaims(), token: null };
|
|
1505
|
+
};
|
|
1011
1506
|
const binding = {
|
|
1012
|
-
auth: async () => {
|
|
1013
|
-
const token = await authClient.getAccessToken();
|
|
1507
|
+
auth: async (request) => {
|
|
1508
|
+
const token = request?.forceRefresh && authClient.refreshAccessToken ? await authClient.refreshAccessToken() : await authClient.getAccessToken();
|
|
1014
1509
|
return { token: token ?? "" };
|
|
1015
1510
|
}
|
|
1016
1511
|
};
|
|
1017
1512
|
binding.resolveSyncState = async () => {
|
|
1018
1513
|
if (authClient.state === "loading") return { state: "loading" };
|
|
1019
|
-
const token = await
|
|
1020
|
-
|
|
1021
|
-
return anonymous === "allow" ? { state: "anonymous", mayConnectAnonymously: true } : { state: "signed-out", mayConnectAnonymously: false };
|
|
1022
|
-
}
|
|
1023
|
-
const claims = decodeJwtPayload2(token);
|
|
1024
|
-
const userId = typeof claims?.sub === "string" ? claims.sub : "";
|
|
1514
|
+
const { claims, token } = await resolveClaims();
|
|
1515
|
+
const userId = readString(claims, "sub");
|
|
1025
1516
|
if (!userId) {
|
|
1026
1517
|
return anonymous === "allow" ? { state: "anonymous", mayConnectAnonymously: true } : { state: "signed-out", mayConnectAnonymously: false };
|
|
1027
1518
|
}
|
|
1028
|
-
|
|
1519
|
+
const deviceId = readString(claims, "dev");
|
|
1520
|
+
if (token) {
|
|
1521
|
+
return { state: "authenticated", userId, token, ...deviceId ? { deviceId } : {} };
|
|
1522
|
+
}
|
|
1523
|
+
return {
|
|
1524
|
+
state: "authenticated",
|
|
1525
|
+
userId,
|
|
1526
|
+
token: null,
|
|
1527
|
+
offline: true,
|
|
1528
|
+
locked: authClient.session?.status === "locked",
|
|
1529
|
+
...deviceId ? { deviceId } : {}
|
|
1530
|
+
};
|
|
1029
1531
|
};
|
|
1030
1532
|
if (schema) {
|
|
1031
1533
|
binding.resolveScopeMap = async () => {
|
|
1032
|
-
const
|
|
1033
|
-
if (!token) {
|
|
1034
|
-
return void 0;
|
|
1035
|
-
}
|
|
1036
|
-
const claims = decodeJwtPayload2(token);
|
|
1534
|
+
const { claims } = await resolveClaims();
|
|
1037
1535
|
if (!claims) {
|
|
1038
1536
|
return void 0;
|
|
1039
1537
|
}
|
|
@@ -1042,30 +1540,24 @@ function createKoraAuthSync(options) {
|
|
|
1042
1540
|
};
|
|
1043
1541
|
}
|
|
1044
1542
|
binding.resolveNodeId = async () => {
|
|
1045
|
-
const
|
|
1046
|
-
|
|
1047
|
-
return void 0;
|
|
1048
|
-
}
|
|
1049
|
-
const claims = decodeJwtPayload2(token);
|
|
1050
|
-
if (!claims) {
|
|
1051
|
-
return void 0;
|
|
1052
|
-
}
|
|
1053
|
-
return readDeviceIdFromClaims(claims);
|
|
1543
|
+
const { claims } = await resolveClaims();
|
|
1544
|
+
return readString(claims, "dev");
|
|
1054
1545
|
};
|
|
1055
1546
|
binding.resolveUserId = async () => {
|
|
1056
|
-
const
|
|
1057
|
-
|
|
1058
|
-
return void 0;
|
|
1059
|
-
}
|
|
1060
|
-
const claims = decodeJwtPayload2(token);
|
|
1061
|
-
if (!claims || typeof claims.sub !== "string" || claims.sub.length === 0) {
|
|
1062
|
-
return void 0;
|
|
1063
|
-
}
|
|
1064
|
-
return claims.sub;
|
|
1547
|
+
const { claims } = await resolveClaims();
|
|
1548
|
+
return readString(claims, "sub");
|
|
1065
1549
|
};
|
|
1066
|
-
if (authClient.onAuthChange) {
|
|
1067
|
-
binding.subscribe = (listener) =>
|
|
1068
|
-
|
|
1550
|
+
if (authClient.onAuthChange || authClient.onSessionChange) {
|
|
1551
|
+
binding.subscribe = (listener) => {
|
|
1552
|
+
const offAuth = authClient.onAuthChange?.(() => listener()) ?? (() => {
|
|
1553
|
+
});
|
|
1554
|
+
const offSession = authClient.onSessionChange?.(() => listener()) ?? (() => {
|
|
1555
|
+
});
|
|
1556
|
+
return () => {
|
|
1557
|
+
offAuth();
|
|
1558
|
+
offSession();
|
|
1559
|
+
};
|
|
1560
|
+
};
|
|
1069
1561
|
}
|
|
1070
1562
|
return binding;
|
|
1071
1563
|
}
|
|
@@ -1248,10 +1740,11 @@ var OrgClient = class {
|
|
|
1248
1740
|
await this.request(`/orgs/${orgId}/invitations/${invitationId}`, { method: "DELETE" });
|
|
1249
1741
|
}
|
|
1250
1742
|
/**
|
|
1251
|
-
* List pending invitations
|
|
1743
|
+
* List pending invitations addressed to the signed-in user's verified email.
|
|
1744
|
+
* The server resolves the email from the session; tokens are never returned.
|
|
1252
1745
|
*/
|
|
1253
|
-
async listMyInvitations(
|
|
1254
|
-
return this.request(
|
|
1746
|
+
async listMyInvitations() {
|
|
1747
|
+
return this.request("/invitations", {
|
|
1255
1748
|
method: "GET"
|
|
1256
1749
|
});
|
|
1257
1750
|
}
|
|
@@ -1843,6 +2336,7 @@ export {
|
|
|
1843
2336
|
InMemoryDeviceKeyStore,
|
|
1844
2337
|
IndexedDBDeviceKeyStore,
|
|
1845
2338
|
KeyDerivationError,
|
|
2339
|
+
MfaRequiredError,
|
|
1846
2340
|
OperationEncryptionError,
|
|
1847
2341
|
OperationEncryptor,
|
|
1848
2342
|
OrgClient,
|