@oxy.so/core 1.9.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +112 -163
- package/dist/cjs/OxyServices.base.js +4 -2
- package/dist/cjs/boot/sessionColdBoot.js +3 -0
- package/dist/cjs/mixins/OxyServices.user.js +2 -5
- package/dist/cjs/server/cors.js +0 -1
- package/dist/cjs/session/refresh.js +71 -7
- package/dist/cjs/utils/errorUtils.js +1 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +112 -163
- package/dist/esm/OxyServices.base.js +4 -2
- package/dist/esm/boot/sessionColdBoot.js +3 -0
- package/dist/esm/mixins/OxyServices.user.js +2 -5
- package/dist/esm/server/cors.js +0 -1
- package/dist/esm/session/refresh.js +70 -6
- package/dist/esm/utils/errorUtils.js +1 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +59 -7
- package/dist/types/OxyServices.base.d.ts +3 -1
- package/dist/types/mixins/OxyServices.civic.d.ts +11 -1
- package/dist/types/mixins/OxyServices.user.d.ts +1 -4
- package/dist/types/session/refresh.d.ts +21 -0
- package/dist/types/utils/errorUtils.d.ts +1 -1
- package/package.json +1 -1
- package/src/HttpService.ts +116 -187
- package/src/OxyServices.base.ts +4 -2
- package/src/__tests__/httpServiceFormEncoded.test.ts +2 -5
- package/src/__tests__/{httpServiceCsrf.test.ts → httpServiceWrites.test.ts} +49 -109
- package/src/__tests__/inSessionRefresh.test.ts +53 -0
- package/src/__tests__/linkedClient.test.ts +0 -7
- package/src/__tests__/nearExpiryMintStorm.test.ts +165 -0
- package/src/boot/__tests__/sessionColdBoot.identity.test.ts +1 -1
- package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +1 -1
- package/src/boot/__tests__/sessionColdBoot.test.ts +1 -1
- package/src/boot/sessionColdBoot.ts +3 -0
- package/src/mixins/OxyServices.user.ts +2 -5
- package/src/mixins/__tests__/backgroundCredential.httpIntegration.test.ts +5 -10
- package/src/mixins/__tests__/discoveryErrorHandling.test.ts +3 -4
- package/src/mixins/__tests__/topics.test.ts +2 -3
- package/src/server/cors.ts +0 -1
- package/src/session/__tests__/refresh.identityPin.test.ts +1 -1
- package/src/session/__tests__/refresh.test.ts +115 -2
- package/src/session/refresh.ts +77 -6
- package/src/utils/errorUtils.ts +1 -1
|
@@ -94,8 +94,6 @@ interface RequestConfig extends RequestOptions {
|
|
|
94
94
|
params?: Record<string, unknown>;
|
|
95
95
|
/** @internal Used to prevent infinite auth retry loops */
|
|
96
96
|
_isAuthRetry?: boolean;
|
|
97
|
-
/** @internal Used to prevent infinite CSRF retry loops */
|
|
98
|
-
_isCsrfRetry?: boolean;
|
|
99
97
|
}
|
|
100
98
|
/**
|
|
101
99
|
* Unified HTTP Service
|
|
@@ -141,9 +139,45 @@ export declare class HttpService {
|
|
|
141
139
|
* it, and there is exactly one timestamp to reason about.
|
|
142
140
|
*/
|
|
143
141
|
private lastRefreshWasRateLimited;
|
|
142
|
+
/**
|
|
143
|
+
* The access token the last SUCCESSFUL refresh handed back while it was still
|
|
144
|
+
* inside the refresh lead window, or `null`.
|
|
145
|
+
*
|
|
146
|
+
* The device mint (`POST /session/device/token`) answers with the session's
|
|
147
|
+
* STORED access token for as long as that token has not expired — it only
|
|
148
|
+
* mints a new one once the old one is past `exp`. A client that re-mints
|
|
149
|
+
* {@link TOKEN_REFRESH_LEAD_SECONDS} before `exp` therefore gets the SAME
|
|
150
|
+
* token back, and because that counts as a success (no cooldown), every
|
|
151
|
+
* following request re-ran the preflight and minted again: one mint per
|
|
152
|
+
* request for the last minute of every token, which exhausted the server's
|
|
153
|
+
* 30/min mint budget. The 429 that followed imposed a 60s cooldown that
|
|
154
|
+
* straddled the token's real expiry, so the next request went out with no
|
|
155
|
+
* bearer, drew a 401, found the refresh cooling down, and signed the user out.
|
|
156
|
+
*
|
|
157
|
+
* Remembering the answer ends that: while the current token IS this value and
|
|
158
|
+
* has not yet expired, the server has already said it has nothing fresher, so
|
|
159
|
+
* the preflight sends the still-valid token instead of asking again. Once the
|
|
160
|
+
* token crosses `exp` the preflight asks once more and the server rotates.
|
|
161
|
+
*/
|
|
162
|
+
private noFresherTokenThan;
|
|
144
163
|
private authRefreshHandler;
|
|
145
164
|
private accessTokenProvider;
|
|
146
165
|
private deviceSecretMintInFlight;
|
|
166
|
+
/**
|
|
167
|
+
* Bumped by every {@link endSession}. A re-mint captures it before its first
|
|
168
|
+
* await and plants nothing if it moved: a refresh that was already in flight
|
|
169
|
+
* when the user signed out would otherwise complete a moment later and put a
|
|
170
|
+
* bearer — and through the token listeners, the account — back.
|
|
171
|
+
*/
|
|
172
|
+
private sessionEpoch;
|
|
173
|
+
/**
|
|
174
|
+
* Set by {@link endSession}, cleared by the next {@link setTokens}. Read by the
|
|
175
|
+
* native shared-keychain re-mint arm: after a sign-out a 401 is the expected
|
|
176
|
+
* answer, not a cue to sign back in with the identity key. The device-secret
|
|
177
|
+
* arm is NOT gated on it — minting from a credential the store still holds is
|
|
178
|
+
* how a signed-out tab joins a sign-in made in another tab.
|
|
179
|
+
*/
|
|
180
|
+
private sessionEnded;
|
|
147
181
|
/**
|
|
148
182
|
* Epoch (ms) before which a cache-size telemetry warning must not be
|
|
149
183
|
* re-emitted. Throttles the {@link CACHE_SOFT_MAX_ENTRIES} warning to at most
|
|
@@ -286,11 +320,6 @@ export declare class HttpService {
|
|
|
286
320
|
private buildURL;
|
|
287
321
|
private getCredentialsMode;
|
|
288
322
|
private shouldSendCredentials;
|
|
289
|
-
/**
|
|
290
|
-
* Fetch CSRF token from server (with deduplication)
|
|
291
|
-
* Required for state-changing requests (POST, PUT, PATCH, DELETE)
|
|
292
|
-
*/
|
|
293
|
-
private fetchCsrfToken;
|
|
294
323
|
/**
|
|
295
324
|
* Get auth header with automatic token refresh
|
|
296
325
|
*/
|
|
@@ -309,6 +338,13 @@ export declare class HttpService {
|
|
|
309
338
|
* Public because the handler lives in `session/refresh.ts`, not on this class.
|
|
310
339
|
*/
|
|
311
340
|
noteRefreshRateLimited(): void;
|
|
341
|
+
/**
|
|
342
|
+
* Whether the server has already answered a refresh of the CURRENT access
|
|
343
|
+
* token with that same, still-valid token — i.e. it has nothing fresher to
|
|
344
|
+
* give until the token's `exp`. The proactive scheduler reads this so it waits
|
|
345
|
+
* for expiry instead of re-asking at its 1s floor.
|
|
346
|
+
*/
|
|
347
|
+
isAwaitingCurrentTokenExpiry(): boolean;
|
|
312
348
|
/**
|
|
313
349
|
* Whether the CURRENT stored access token is already past its `exp`. Drives
|
|
314
350
|
* the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}):
|
|
@@ -388,6 +424,22 @@ export declare class HttpService {
|
|
|
388
424
|
patch<T = unknown>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'url' | 'data'>): Promise<T>;
|
|
389
425
|
delete<T = unknown>(url: string, config?: Omit<RequestConfig, 'method' | 'url'>): Promise<T>;
|
|
390
426
|
setTokens(accessToken: string): void;
|
|
427
|
+
/**
|
|
428
|
+
* End the local session: clear the bearer and abandon every re-mint already
|
|
429
|
+
* in flight, so none of them can plant a token after the sign-out.
|
|
430
|
+
*
|
|
431
|
+
* {@link clearTokens} alone only drops the bearer, which is right for a
|
|
432
|
+
* mirror (a linked client following its parent) but not for a sign-out: a
|
|
433
|
+
* refresh started a moment earlier would finish and plant a new one.
|
|
434
|
+
*/
|
|
435
|
+
endSession(): void;
|
|
436
|
+
/**
|
|
437
|
+
* The current session epoch — see {@link endSession}. A re-mint lane that
|
|
438
|
+
* plants tokens itself (the device-secret arm) compares it across its awaits.
|
|
439
|
+
*/
|
|
440
|
+
getSessionEpoch(): number;
|
|
441
|
+
/** Whether {@link endSession} ran and no token has been planted since. */
|
|
442
|
+
hasSessionEnded(): boolean;
|
|
391
443
|
setAuthRefreshHandler(handler: AuthRefreshHandler | null): void;
|
|
392
444
|
setAccessTokenProvider(provider: AccessTokenProvider | null): void;
|
|
393
445
|
clearTokens(): void;
|
|
@@ -96,7 +96,9 @@ export declare class OxyServicesBase {
|
|
|
96
96
|
*/
|
|
97
97
|
setTokens(accessToken: string): void;
|
|
98
98
|
/**
|
|
99
|
-
* Clear stored authentication tokens
|
|
99
|
+
* Clear stored authentication tokens and end the local session: a re-mint
|
|
100
|
+
* already in flight plants nothing, and no new one runs until a token is set
|
|
101
|
+
* again (see `HttpService.endSession`).
|
|
100
102
|
*/
|
|
101
103
|
clearTokens(): void;
|
|
102
104
|
/**
|
|
@@ -502,7 +502,17 @@ export declare function OxyServicesCivicMixin<T extends typeof OxyServicesBase>(
|
|
|
502
502
|
handleError(error: unknown): Error;
|
|
503
503
|
healthCheck(): Promise<{
|
|
504
504
|
status: string;
|
|
505
|
-
users
|
|
505
|
+
users
|
|
506
|
+
/**
|
|
507
|
+
* The VC type tags. `'VerifiableCredential'` is the required base type and is
|
|
508
|
+
* prepended automatically when the caller omits it; provide at least one
|
|
509
|
+
* specific type alongside (e.g. `'EmploymentCredential'`).
|
|
510
|
+
*/
|
|
511
|
+
? /**
|
|
512
|
+
* The VC type tags. `'VerifiableCredential'` is the required base type and is
|
|
513
|
+
* prepended automatically when the caller omits it; provide at least one
|
|
514
|
+
* specific type alongside (e.g. `'EmploymentCredential'`).
|
|
515
|
+
*/: number;
|
|
506
516
|
timestamp?: string;
|
|
507
517
|
[key: string]: any;
|
|
508
518
|
}>;
|
|
@@ -235,10 +235,7 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
|
|
|
235
235
|
* `PostHydrationService`) and is unchanged.
|
|
236
236
|
* - **Plain client (browser / React Native with a user session):** when no
|
|
237
237
|
* service credentials are configured, the chunk is fetched via
|
|
238
|
-
* `makeRequest`, which attaches the configured user bearer.
|
|
239
|
-
* middleware skips bearer-authenticated writes, and `makeRequest` only
|
|
240
|
-
* fetches a CSRF token for cookie-only (no-bearer) state-changing requests,
|
|
241
|
-
* so the user-bearer POST is sent without CSRF and succeeds. Previously
|
|
238
|
+
* `makeRequest`, which attaches the configured user bearer. Previously
|
|
242
239
|
* this method always used the service path, so every client-side caller
|
|
243
240
|
* silently received `[]` because `getServiceToken()` had no credentials.
|
|
244
241
|
*
|
|
@@ -33,6 +33,20 @@ import { type IdentityBinding } from './identitySession';
|
|
|
33
33
|
* window — the scheduler just fires it during idle/background.
|
|
34
34
|
*/
|
|
35
35
|
export declare const TOKEN_REFRESH_LEAD_MS = 60000;
|
|
36
|
+
/**
|
|
37
|
+
* How long past `exp` to re-mint when a successful refresh handed back a token
|
|
38
|
+
* that is STILL inside the lead window.
|
|
39
|
+
*
|
|
40
|
+
* The device mint returns the session's stored access token until that token
|
|
41
|
+
* has actually expired, so asking again before `exp` only returns the same
|
|
42
|
+
* token. Re-arming from expiry in that state computed a negative delay, hit the
|
|
43
|
+
* {@link MIN_SCHEDULE_DELAY_MS} floor and re-minted once a second for the last
|
|
44
|
+
* minute of every token — 30+ mints, the server's whole per-minute budget, and
|
|
45
|
+
* the 429 that followed straddled the real expiry. Waiting until just past
|
|
46
|
+
* `exp` asks exactly once more, when the server will rotate. The margin covers
|
|
47
|
+
* the server's strict `exp < now` comparison and a little clock skew.
|
|
48
|
+
*/
|
|
49
|
+
export declare const REMINT_AFTER_EXPIRY_MS = 2000;
|
|
36
50
|
export interface RefreshDeps {
|
|
37
51
|
oxy: OxyServices;
|
|
38
52
|
store: AuthStateStore;
|
|
@@ -68,6 +82,11 @@ export interface RefreshDeps {
|
|
|
68
82
|
* The device secret is FINE — it is the identity binding that went stale, so
|
|
69
83
|
* the caller must re-establish from the local key, never drop the credential.
|
|
70
84
|
* - `transient` — network / 5xx; keep the secret, a later attempt can succeed.
|
|
85
|
+
* - `session-ended` — the mint succeeded, but the local session was ended
|
|
86
|
+
* (`HttpService.endSession`, i.e. a sign-out) while it was in flight. Nothing
|
|
87
|
+
* is planted. The rotated secret is persisted only if the store still holds
|
|
88
|
+
* the credential that was presented — a store the sign-out cleared stays
|
|
89
|
+
* clear.
|
|
71
90
|
* - `persist-failed` — the mint succeeded but `nextDeviceSecret` could NOT be
|
|
72
91
|
* durably persisted. The token is deliberately NOT planted: advertising a
|
|
73
92
|
* healthy session on a secret that will not survive a reload is exactly the
|
|
@@ -89,6 +108,8 @@ export type DeviceSecretMintOutcome = {
|
|
|
89
108
|
status: 'account-not-on-device';
|
|
90
109
|
} | {
|
|
91
110
|
status: 'transient';
|
|
111
|
+
} | {
|
|
112
|
+
status: 'session-ended';
|
|
92
113
|
} | {
|
|
93
114
|
status: 'persist-failed';
|
|
94
115
|
};
|
|
@@ -93,7 +93,7 @@ export interface ParsedHttpErrorBody {
|
|
|
93
93
|
* - `{ error: '<CODE>', error_description }` — RFC 6749 §5.2 / RFC 6750 §3, the
|
|
94
94
|
* OAuth token and userinfo endpoints. `error_description` is the human text
|
|
95
95
|
* and `error` is the machine code, so both survive.
|
|
96
|
-
* - `{ message, code }` — e.g. the API's
|
|
96
|
+
* - `{ message, code }` — e.g. the API's middleware rejections.
|
|
97
97
|
* - `{ error: '<human message>' }` — legacy hand-rolled routes. With no sibling
|
|
98
98
|
* `message`/`error_description` the string is the message, not a code: a bare
|
|
99
99
|
* `error` string is not machine-readable enough to promote to `code`.
|
package/package.json
CHANGED
package/src/HttpService.ts
CHANGED
|
@@ -18,7 +18,6 @@ import { RequestDeduplicator, RequestQueue, SimpleLogger } from './utils/request
|
|
|
18
18
|
import { retryAsync } from './utils/asyncUtils';
|
|
19
19
|
import { createCancelledError, ErrorCodes, handleHttpError, isCancelledError, parseHttpErrorBody } from './utils/errorUtils';
|
|
20
20
|
import { jwtDecode } from 'jwt-decode';
|
|
21
|
-
import { isNative, getPlatformOS } from './utils/platform';
|
|
22
21
|
import { isReactNative } from '@oxy.so/protocol';
|
|
23
22
|
import { computeIdentityTag, fnv1a32 } from './utils/cacheKey';
|
|
24
23
|
import { redactUrlQuery } from './utils/redactUrl';
|
|
@@ -28,12 +27,6 @@ import { OxyAuthenticationError } from './OxyServices.errors';
|
|
|
28
27
|
import { getBrowserEdgeRegionHeader } from './utils/edgeRegion';
|
|
29
28
|
import { getBrowserActivityIdHeader } from './utils/activityId';
|
|
30
29
|
|
|
31
|
-
/**
|
|
32
|
-
* Check if we're running in a native app environment (React Native, not web)
|
|
33
|
-
* This is used to determine CSRF handling mode
|
|
34
|
-
*/
|
|
35
|
-
const isNativeApp = isNative();
|
|
36
|
-
|
|
37
30
|
interface JwtPayload {
|
|
38
31
|
exp?: number;
|
|
39
32
|
userId?: string;
|
|
@@ -144,8 +137,6 @@ interface RequestConfig extends RequestOptions {
|
|
|
144
137
|
params?: Record<string, unknown>;
|
|
145
138
|
/** @internal Used to prevent infinite auth retry loops */
|
|
146
139
|
_isAuthRetry?: boolean;
|
|
147
|
-
/** @internal Used to prevent infinite CSRF retry loops */
|
|
148
|
-
_isCsrfRetry?: boolean;
|
|
149
140
|
}
|
|
150
141
|
|
|
151
142
|
/**
|
|
@@ -156,27 +147,6 @@ interface RequestConfig extends RequestOptions {
|
|
|
156
147
|
*/
|
|
157
148
|
const DEFAULT_REQUEST_TIMEOUT_MS = 5000;
|
|
158
149
|
|
|
159
|
-
/**
|
|
160
|
-
* Timeout (ms) for the dedicated `GET /csrf-token` fetch. Independent of the
|
|
161
|
-
* regular request timeout: this is a small, fast, unauthenticated call and
|
|
162
|
-
* should never inherit a longer per-request budget.
|
|
163
|
-
*/
|
|
164
|
-
const CSRF_FETCH_TIMEOUT_MS = 5000;
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Number of attempts for fetching a CSRF token before giving up. The first
|
|
168
|
-
* failure is usually a cold edge/cookie race; a single retry recovers it
|
|
169
|
-
* without masking a genuinely broken `/csrf-token` route.
|
|
170
|
-
*/
|
|
171
|
-
const CSRF_FETCH_MAX_ATTEMPTS = 2;
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Backoff (ms) between CSRF-token fetch attempts. Short by design — a CSRF
|
|
175
|
-
* fetch sits in the critical path of a state-changing request, so the retry
|
|
176
|
-
* must add minimal latency.
|
|
177
|
-
*/
|
|
178
|
-
const CSRF_FETCH_RETRY_DELAY_MS = 500;
|
|
179
|
-
|
|
180
150
|
/**
|
|
181
151
|
* Cooldown (ms) applied after a failed access-token refresh before another
|
|
182
152
|
* refresh is attempted while the CURRENT token is still valid (a proactive,
|
|
@@ -240,6 +210,24 @@ const RATE_LIMITED_REFRESH_COOLDOWN_MS = 60_000;
|
|
|
240
210
|
*/
|
|
241
211
|
const TOKEN_REFRESH_LEAD_SECONDS = 60;
|
|
242
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Whether `token` is a JWT that is still valid but already inside the refresh
|
|
215
|
+
* lead window. `false` for an expired, opaque or no-`exp` token.
|
|
216
|
+
*/
|
|
217
|
+
function isInsideRefreshLeadWindow(token: string): boolean {
|
|
218
|
+
try {
|
|
219
|
+
const decoded = jwtDecode<JwtPayload>(token);
|
|
220
|
+
if (typeof decoded.exp !== 'number') {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
const now = Math.floor(Date.now() / 1000);
|
|
224
|
+
// `<=`: the proactive scheduler fires at exactly `exp - lead`.
|
|
225
|
+
return decoded.exp > now && decoded.exp - now <= TOKEN_REFRESH_LEAD_SECONDS;
|
|
226
|
+
} catch {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
243
231
|
/**
|
|
244
232
|
* Soft ceiling on the number of live entries in the identity-scoped GET
|
|
245
233
|
* response cache. Crossing it does NOT evict anything (the {@link TTLCache}
|
|
@@ -266,8 +254,6 @@ const CACHE_SIZE_WARNING_THROTTLE_MS = 60000;
|
|
|
266
254
|
*/
|
|
267
255
|
class TokenStore {
|
|
268
256
|
private accessToken: string | null = null;
|
|
269
|
-
private csrfToken: string | null = null;
|
|
270
|
-
private csrfTokenFetchPromise: Promise<string | null> | null = null;
|
|
271
257
|
|
|
272
258
|
setTokens(accessToken: string): void {
|
|
273
259
|
this.accessToken = accessToken;
|
|
@@ -284,27 +270,6 @@ class TokenStore {
|
|
|
284
270
|
hasAccessToken(): boolean {
|
|
285
271
|
return !!this.accessToken;
|
|
286
272
|
}
|
|
287
|
-
|
|
288
|
-
setCsrfToken(token: string | null): void {
|
|
289
|
-
this.csrfToken = token;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
getCsrfToken(): string | null {
|
|
293
|
-
return this.csrfToken;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
setCsrfTokenFetchPromise(promise: Promise<string | null> | null): void {
|
|
297
|
-
this.csrfTokenFetchPromise = promise;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
getCsrfTokenFetchPromise(): Promise<string | null> | null {
|
|
301
|
-
return this.csrfTokenFetchPromise;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
clearCsrfToken(): void {
|
|
305
|
-
this.csrfToken = null;
|
|
306
|
-
this.csrfTokenFetchPromise = null;
|
|
307
|
-
}
|
|
308
273
|
}
|
|
309
274
|
|
|
310
275
|
/**
|
|
@@ -390,9 +355,45 @@ export class HttpService {
|
|
|
390
355
|
* it, and there is exactly one timestamp to reason about.
|
|
391
356
|
*/
|
|
392
357
|
private lastRefreshWasRateLimited = false;
|
|
358
|
+
/**
|
|
359
|
+
* The access token the last SUCCESSFUL refresh handed back while it was still
|
|
360
|
+
* inside the refresh lead window, or `null`.
|
|
361
|
+
*
|
|
362
|
+
* The device mint (`POST /session/device/token`) answers with the session's
|
|
363
|
+
* STORED access token for as long as that token has not expired — it only
|
|
364
|
+
* mints a new one once the old one is past `exp`. A client that re-mints
|
|
365
|
+
* {@link TOKEN_REFRESH_LEAD_SECONDS} before `exp` therefore gets the SAME
|
|
366
|
+
* token back, and because that counts as a success (no cooldown), every
|
|
367
|
+
* following request re-ran the preflight and minted again: one mint per
|
|
368
|
+
* request for the last minute of every token, which exhausted the server's
|
|
369
|
+
* 30/min mint budget. The 429 that followed imposed a 60s cooldown that
|
|
370
|
+
* straddled the token's real expiry, so the next request went out with no
|
|
371
|
+
* bearer, drew a 401, found the refresh cooling down, and signed the user out.
|
|
372
|
+
*
|
|
373
|
+
* Remembering the answer ends that: while the current token IS this value and
|
|
374
|
+
* has not yet expired, the server has already said it has nothing fresher, so
|
|
375
|
+
* the preflight sends the still-valid token instead of asking again. Once the
|
|
376
|
+
* token crosses `exp` the preflight asks once more and the server rotates.
|
|
377
|
+
*/
|
|
378
|
+
private noFresherTokenThan: string | null = null;
|
|
393
379
|
private authRefreshHandler: AuthRefreshHandler | null = null;
|
|
394
380
|
private accessTokenProvider: AccessTokenProvider | null = null;
|
|
395
381
|
private deviceSecretMintInFlight: Promise<DeviceSecretMintOutcome> | null = null;
|
|
382
|
+
/**
|
|
383
|
+
* Bumped by every {@link endSession}. A re-mint captures it before its first
|
|
384
|
+
* await and plants nothing if it moved: a refresh that was already in flight
|
|
385
|
+
* when the user signed out would otherwise complete a moment later and put a
|
|
386
|
+
* bearer — and through the token listeners, the account — back.
|
|
387
|
+
*/
|
|
388
|
+
private sessionEpoch = 0;
|
|
389
|
+
/**
|
|
390
|
+
* Set by {@link endSession}, cleared by the next {@link setTokens}. Read by the
|
|
391
|
+
* native shared-keychain re-mint arm: after a sign-out a 401 is the expected
|
|
392
|
+
* answer, not a cue to sign back in with the identity key. The device-secret
|
|
393
|
+
* arm is NOT gated on it — minting from a credential the store still holds is
|
|
394
|
+
* how a signed-out tab joins a sign-in made in another tab.
|
|
395
|
+
*/
|
|
396
|
+
private sessionEnded = false;
|
|
396
397
|
|
|
397
398
|
/**
|
|
398
399
|
* Epoch (ms) before which a cache-size telemetry warning must not be
|
|
@@ -590,14 +591,9 @@ export class HttpService {
|
|
|
590
591
|
// it before enqueue means auth-blocked requests hold NO slot while the shared
|
|
591
592
|
// mint runs. `skipAuth` requests (the body-authenticated mint) send NO bearer
|
|
592
593
|
// and skip the near-expiry preflight — see RequestOptions.skipAuth. The
|
|
593
|
-
// 401
|
|
594
|
-
//
|
|
595
|
-
const isStateChangingMethod = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
|
|
594
|
+
// 401 retry re-enters request() with a fresh config, so it re-resolves
|
|
595
|
+
// this here with the refreshed token.
|
|
596
596
|
const authHeader = config.skipAuth ? null : await this.getAuthHeader();
|
|
597
|
-
// CSRF protects cookie-authenticated browser writes. Bearer-authenticated SDK
|
|
598
|
-
// clients are not vulnerable to ambient-cookie CSRF, and linked app APIs
|
|
599
|
-
// should not need to implement a duplicate `/csrf-token` route.
|
|
600
|
-
const csrfToken = isStateChangingMethod && !authHeader ? await this.fetchCsrfToken() : null;
|
|
601
597
|
const edgeRegionHeader = await getBrowserEdgeRegionHeader();
|
|
602
598
|
const activityIdHeader = getBrowserActivityIdHeader();
|
|
603
599
|
|
|
@@ -678,32 +674,6 @@ export class HttpService {
|
|
|
678
674
|
headers['Authorization'] = authHeader;
|
|
679
675
|
}
|
|
680
676
|
|
|
681
|
-
// Add CSRF token header for state-changing requests
|
|
682
|
-
if (csrfToken) {
|
|
683
|
-
headers['X-CSRF-Token'] = csrfToken;
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
// Add native app header for React Native (required for CSRF validation)
|
|
687
|
-
// Native apps can't persist cookies like browsers, so the server uses
|
|
688
|
-
// header-only CSRF validation when this header is present
|
|
689
|
-
if (isNativeApp && isStateChangingMethod) {
|
|
690
|
-
headers['X-Native-App'] = 'true';
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
// Debug logging for CSRF issues, routed through SimpleLogger so it only
|
|
694
|
-
// fires when consumers opt in via `enableLogging`.
|
|
695
|
-
if (isStateChangingMethod) {
|
|
696
|
-
this.logger.debug('CSRF Debug:', {
|
|
697
|
-
url,
|
|
698
|
-
method,
|
|
699
|
-
isNativeApp,
|
|
700
|
-
platformOS: getPlatformOS(),
|
|
701
|
-
hasCsrfToken: !!csrfToken,
|
|
702
|
-
csrfTokenLength: csrfToken?.length,
|
|
703
|
-
hasNativeAppHeader: headers['X-Native-App'] === 'true',
|
|
704
|
-
});
|
|
705
|
-
}
|
|
706
|
-
|
|
707
677
|
// Merge custom headers if provided
|
|
708
678
|
if (config.headers) {
|
|
709
679
|
Object.entries(config.headers).forEach(([key, value]) => {
|
|
@@ -768,35 +738,20 @@ export class HttpService {
|
|
|
768
738
|
if (response.status === 401 && !config._isAuthRetry && !config.skipAuth) {
|
|
769
739
|
const refreshed = await this.refreshAccessToken('response-401');
|
|
770
740
|
if (refreshed) {
|
|
771
|
-
// `deduplicate: false` is REQUIRED on the retry
|
|
772
|
-
//
|
|
773
|
-
//
|
|
741
|
+
// `deduplicate: false` is REQUIRED on the retry. This re-issue runs
|
|
742
|
+
// while the ORIGINAL request is still in-flight under its dedupe
|
|
743
|
+
// key; the refreshed token is
|
|
774
744
|
// for the SAME user, so the identity-scoped key is UNCHANGED — a
|
|
775
745
|
// deduplicated retry would resolve to the still-pending original
|
|
776
746
|
// and await itself (deadlock). Opting the retry out of dedupe makes
|
|
777
747
|
// it a fresh request.
|
|
778
748
|
return this.request<T>({ ...config, _isAuthRetry: true, retry: false, deduplicate: false });
|
|
779
749
|
}
|
|
780
|
-
// Refresh failed or no token — clear tokens
|
|
750
|
+
// Refresh failed or no token — clear tokens
|
|
781
751
|
this.tokenStore.clearTokens();
|
|
782
|
-
this.tokenStore.clearCsrfToken();
|
|
783
752
|
this.notifyTokenChange();
|
|
784
753
|
}
|
|
785
754
|
|
|
786
|
-
// On 403 with CSRF error, clear cached token and retry once
|
|
787
|
-
if (response.status === 403 && !config._isCsrfRetry) {
|
|
788
|
-
try {
|
|
789
|
-
const clonedResponse = response.clone();
|
|
790
|
-
const errBody = await clonedResponse.json() as { code?: string } | null;
|
|
791
|
-
if (errBody?.code === 'CSRF_TOKEN_INVALID' || errBody?.code === 'CSRF_TOKEN_MISSING') {
|
|
792
|
-
this.tokenStore.clearCsrfToken();
|
|
793
|
-
return this.request<T>({ ...config, _isCsrfRetry: true, retry: false, deduplicate: false });
|
|
794
|
-
}
|
|
795
|
-
} catch {
|
|
796
|
-
// Failed to parse error body — not a CSRF error
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
|
|
800
755
|
// Read the error body (may be absent, non-JSON, empty or malformed).
|
|
801
756
|
// Anything unreadable leaves `errorBody` undefined and degrades to the
|
|
802
757
|
// status-based message — an error path that throws its own error is
|
|
@@ -1230,85 +1185,6 @@ export class HttpService {
|
|
|
1230
1185
|
}
|
|
1231
1186
|
}
|
|
1232
1187
|
|
|
1233
|
-
/**
|
|
1234
|
-
* Fetch CSRF token from server (with deduplication)
|
|
1235
|
-
* Required for state-changing requests (POST, PUT, PATCH, DELETE)
|
|
1236
|
-
*/
|
|
1237
|
-
private async fetchCsrfToken(): Promise<string | null> {
|
|
1238
|
-
// Return cached token if available
|
|
1239
|
-
const cachedToken = this.tokenStore.getCsrfToken();
|
|
1240
|
-
if (cachedToken) {
|
|
1241
|
-
this.logger.debug('Using cached CSRF token');
|
|
1242
|
-
return cachedToken;
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
|
-
// Deduplicate concurrent CSRF token fetches
|
|
1246
|
-
const existingPromise = this.tokenStore.getCsrfTokenFetchPromise();
|
|
1247
|
-
if (existingPromise) {
|
|
1248
|
-
this.logger.debug('Waiting for existing CSRF fetch');
|
|
1249
|
-
return existingPromise;
|
|
1250
|
-
}
|
|
1251
|
-
|
|
1252
|
-
const fetchPromise = (async () => {
|
|
1253
|
-
for (let attempt = 1; attempt <= CSRF_FETCH_MAX_ATTEMPTS; attempt++) {
|
|
1254
|
-
try {
|
|
1255
|
-
this.logger.debug('Fetching CSRF token from:', `${this.baseURL}/csrf-token`, `(attempt ${attempt})`);
|
|
1256
|
-
|
|
1257
|
-
// Use AbortController for timeout (more compatible than AbortSignal.timeout)
|
|
1258
|
-
const controller = new AbortController();
|
|
1259
|
-
const timeoutId = setTimeout(() => controller.abort(), CSRF_FETCH_TIMEOUT_MS);
|
|
1260
|
-
|
|
1261
|
-
const response = await fetch(`${this.baseURL}/csrf-token`, {
|
|
1262
|
-
method: 'GET',
|
|
1263
|
-
headers: { 'Accept': 'application/json' },
|
|
1264
|
-
credentials: 'include', // Required to receive and send cookies
|
|
1265
|
-
signal: controller.signal,
|
|
1266
|
-
});
|
|
1267
|
-
|
|
1268
|
-
clearTimeout(timeoutId);
|
|
1269
|
-
|
|
1270
|
-
this.logger.debug('CSRF fetch response:', response.status, response.ok);
|
|
1271
|
-
|
|
1272
|
-
if (response.ok) {
|
|
1273
|
-
const data = await response.json() as { csrfToken?: string };
|
|
1274
|
-
const token = data.csrfToken || null;
|
|
1275
|
-
this.logger.debug('CSRF response data:', {
|
|
1276
|
-
hasCsrfToken: typeof token === 'string' && token.length > 0,
|
|
1277
|
-
csrfTokenLength: token?.length,
|
|
1278
|
-
});
|
|
1279
|
-
this.tokenStore.setCsrfToken(token);
|
|
1280
|
-
this.logger.debug('CSRF token fetched');
|
|
1281
|
-
return token;
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
// Also check response header for CSRF token
|
|
1285
|
-
const headerToken = response.headers.get('X-CSRF-Token');
|
|
1286
|
-
if (headerToken) {
|
|
1287
|
-
this.tokenStore.setCsrfToken(headerToken);
|
|
1288
|
-
this.logger.debug('CSRF token from header');
|
|
1289
|
-
return headerToken;
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
this.logger.debug('CSRF fetch failed with status:', response.status);
|
|
1293
|
-
this.logger.warn('Failed to fetch CSRF token:', response.status);
|
|
1294
|
-
} catch (error) {
|
|
1295
|
-
this.logger.debug('CSRF fetch error:', error);
|
|
1296
|
-
this.logger.warn('CSRF token fetch error:', error);
|
|
1297
|
-
}
|
|
1298
|
-
// Brief backoff before the next attempt.
|
|
1299
|
-
if (attempt < CSRF_FETCH_MAX_ATTEMPTS) {
|
|
1300
|
-
await new Promise(resolve => setTimeout(resolve, CSRF_FETCH_RETRY_DELAY_MS));
|
|
1301
|
-
}
|
|
1302
|
-
}
|
|
1303
|
-
return null;
|
|
1304
|
-
})().finally(() => {
|
|
1305
|
-
this.tokenStore.setCsrfTokenFetchPromise(null);
|
|
1306
|
-
});
|
|
1307
|
-
|
|
1308
|
-
this.tokenStore.setCsrfTokenFetchPromise(fetchPromise);
|
|
1309
|
-
return fetchPromise;
|
|
1310
|
-
}
|
|
1311
|
-
|
|
1312
1188
|
/**
|
|
1313
1189
|
* Get auth header with automatic token refresh
|
|
1314
1190
|
*/
|
|
@@ -1324,6 +1200,12 @@ export class HttpService {
|
|
|
1324
1200
|
|
|
1325
1201
|
// If the token expires within the refresh lead window, refresh it.
|
|
1326
1202
|
if (decoded.exp && decoded.exp - currentTime < TOKEN_REFRESH_LEAD_SECONDS) {
|
|
1203
|
+
// The server already answered this token's refresh with this very token:
|
|
1204
|
+
// it has nothing fresher until `exp`. Use it rather than asking again
|
|
1205
|
+
// (see `noFresherTokenThan`).
|
|
1206
|
+
if (decoded.exp > currentTime && accessToken === this.noFresherTokenThan) {
|
|
1207
|
+
return `Bearer ${accessToken}`;
|
|
1208
|
+
}
|
|
1327
1209
|
const refreshed = await this.refreshAccessToken('preflight');
|
|
1328
1210
|
if (refreshed) return `Bearer ${refreshed}`;
|
|
1329
1211
|
if (decoded.exp > currentTime) {
|
|
@@ -1368,16 +1250,26 @@ export class HttpService {
|
|
|
1368
1250
|
// by calling `noteRefreshRateLimited()` from INSIDE this call, so clearing
|
|
1369
1251
|
// on the way out would discard the flag it just set.
|
|
1370
1252
|
this.lastRefreshWasRateLimited = false;
|
|
1253
|
+
const epoch = this.sessionEpoch;
|
|
1371
1254
|
this.tokenRefreshPromise = this.authRefreshHandler(reason)
|
|
1372
1255
|
.then((newToken) => {
|
|
1256
|
+
if (epoch !== this.sessionEpoch) {
|
|
1257
|
+
// The session ended while this re-mint was in flight. Not a failure
|
|
1258
|
+
// (no cooldown), and nothing to plant.
|
|
1259
|
+
this.logger.debug('Discarded a token refresh that outlived its session');
|
|
1260
|
+
return null;
|
|
1261
|
+
}
|
|
1373
1262
|
if (!newToken) {
|
|
1374
1263
|
this.lastRefreshFailureAt = Date.now();
|
|
1375
1264
|
return null;
|
|
1376
1265
|
}
|
|
1266
|
+
// A token is planted again, so there is a session again.
|
|
1267
|
+
this.sessionEnded = false;
|
|
1377
1268
|
if (this.tokenStore.getAccessToken() !== newToken) {
|
|
1378
1269
|
this.tokenStore.setTokens(newToken);
|
|
1379
1270
|
this.notifyTokenChange();
|
|
1380
1271
|
}
|
|
1272
|
+
this.noFresherTokenThan = isInsideRefreshLeadWindow(newToken) ? newToken : null;
|
|
1381
1273
|
// A success clears the failure timestamp so the next refresh is never
|
|
1382
1274
|
// throttled by a stale cooldown.
|
|
1383
1275
|
this.lastRefreshFailureAt = 0;
|
|
@@ -1414,6 +1306,17 @@ export class HttpService {
|
|
|
1414
1306
|
this.lastRefreshWasRateLimited = true;
|
|
1415
1307
|
}
|
|
1416
1308
|
|
|
1309
|
+
/**
|
|
1310
|
+
* Whether the server has already answered a refresh of the CURRENT access
|
|
1311
|
+
* token with that same, still-valid token — i.e. it has nothing fresher to
|
|
1312
|
+
* give until the token's `exp`. The proactive scheduler reads this so it waits
|
|
1313
|
+
* for expiry instead of re-asking at its 1s floor.
|
|
1314
|
+
*/
|
|
1315
|
+
isAwaitingCurrentTokenExpiry(): boolean {
|
|
1316
|
+
const token = this.tokenStore.getAccessToken();
|
|
1317
|
+
return token !== null && token === this.noFresherTokenThan && isInsideRefreshLeadWindow(token);
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1417
1320
|
/**
|
|
1418
1321
|
* Whether the CURRENT stored access token is already past its `exp`. Drives
|
|
1419
1322
|
* the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}):
|
|
@@ -1582,7 +1485,6 @@ export class HttpService {
|
|
|
1582
1485
|
}
|
|
1583
1486
|
|
|
1584
1487
|
this.tokenStore.clearTokens();
|
|
1585
|
-
this.tokenStore.clearCsrfToken();
|
|
1586
1488
|
this.notifyTokenChange();
|
|
1587
1489
|
}
|
|
1588
1490
|
|
|
@@ -1623,10 +1525,38 @@ export class HttpService {
|
|
|
1623
1525
|
|
|
1624
1526
|
// Token management
|
|
1625
1527
|
setTokens(accessToken: string): void {
|
|
1528
|
+
this.sessionEnded = false;
|
|
1626
1529
|
this.tokenStore.setTokens(accessToken);
|
|
1627
1530
|
this.notifyTokenChange();
|
|
1628
1531
|
}
|
|
1629
1532
|
|
|
1533
|
+
/**
|
|
1534
|
+
* End the local session: clear the bearer and abandon every re-mint already
|
|
1535
|
+
* in flight, so none of them can plant a token after the sign-out.
|
|
1536
|
+
*
|
|
1537
|
+
* {@link clearTokens} alone only drops the bearer, which is right for a
|
|
1538
|
+
* mirror (a linked client following its parent) but not for a sign-out: a
|
|
1539
|
+
* refresh started a moment earlier would finish and plant a new one.
|
|
1540
|
+
*/
|
|
1541
|
+
endSession(): void {
|
|
1542
|
+
this.sessionEpoch += 1;
|
|
1543
|
+
this.sessionEnded = true;
|
|
1544
|
+
this.clearTokens();
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
/**
|
|
1548
|
+
* The current session epoch — see {@link endSession}. A re-mint lane that
|
|
1549
|
+
* plants tokens itself (the device-secret arm) compares it across its awaits.
|
|
1550
|
+
*/
|
|
1551
|
+
getSessionEpoch(): number {
|
|
1552
|
+
return this.sessionEpoch;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
/** Whether {@link endSession} ran and no token has been planted since. */
|
|
1556
|
+
hasSessionEnded(): boolean {
|
|
1557
|
+
return this.sessionEnded;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1630
1560
|
setAuthRefreshHandler(handler: AuthRefreshHandler | null): void {
|
|
1631
1561
|
this.authRefreshHandler = handler;
|
|
1632
1562
|
}
|
|
@@ -1637,7 +1567,6 @@ export class HttpService {
|
|
|
1637
1567
|
|
|
1638
1568
|
clearTokens(): void {
|
|
1639
1569
|
this.tokenStore.clearTokens();
|
|
1640
|
-
this.tokenStore.clearCsrfToken();
|
|
1641
1570
|
// Drop the response cache on logout. The cache is identity-scoped, so a
|
|
1642
1571
|
// different user could never read these entries, but a logged-out client
|
|
1643
1572
|
// must not keep the previous session's personalized data resident in
|
|
@@ -1762,7 +1691,7 @@ export class HttpService {
|
|
|
1762
1691
|
|
|
1763
1692
|
// Test-only utility — clears tokens on this instance
|
|
1764
1693
|
__resetTokensForTests(): void {
|
|
1694
|
+
this.sessionEnded = false;
|
|
1765
1695
|
this.tokenStore.clearTokens();
|
|
1766
|
-
this.tokenStore.clearCsrfToken();
|
|
1767
1696
|
}
|
|
1768
1697
|
}
|