@oxy.so/core 1.9.1 → 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 +56 -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 +32 -2
- package/dist/cjs/utils/errorUtils.js +1 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +56 -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 +32 -2
- package/dist/esm/utils/errorUtils.js +1 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +31 -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 +7 -0
- package/dist/types/utils/errorUtils.d.ts +1 -1
- package/package.json +1 -1
- package/src/HttpService.ts +59 -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/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 +38 -2
- 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
|
|
@@ -165,6 +163,21 @@ export declare class HttpService {
|
|
|
165
163
|
private authRefreshHandler;
|
|
166
164
|
private accessTokenProvider;
|
|
167
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;
|
|
168
181
|
/**
|
|
169
182
|
* Epoch (ms) before which a cache-size telemetry warning must not be
|
|
170
183
|
* re-emitted. Throttles the {@link CACHE_SOFT_MAX_ENTRIES} warning to at most
|
|
@@ -307,11 +320,6 @@ export declare class HttpService {
|
|
|
307
320
|
private buildURL;
|
|
308
321
|
private getCredentialsMode;
|
|
309
322
|
private shouldSendCredentials;
|
|
310
|
-
/**
|
|
311
|
-
* Fetch CSRF token from server (with deduplication)
|
|
312
|
-
* Required for state-changing requests (POST, PUT, PATCH, DELETE)
|
|
313
|
-
*/
|
|
314
|
-
private fetchCsrfToken;
|
|
315
323
|
/**
|
|
316
324
|
* Get auth header with automatic token refresh
|
|
317
325
|
*/
|
|
@@ -416,6 +424,22 @@ export declare class HttpService {
|
|
|
416
424
|
patch<T = unknown>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'url' | 'data'>): Promise<T>;
|
|
417
425
|
delete<T = unknown>(url: string, config?: Omit<RequestConfig, 'method' | 'url'>): Promise<T>;
|
|
418
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;
|
|
419
443
|
setAuthRefreshHandler(handler: AuthRefreshHandler | null): void;
|
|
420
444
|
setAccessTokenProvider(provider: AccessTokenProvider | null): void;
|
|
421
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
|
*
|
|
@@ -82,6 +82,11 @@ export interface RefreshDeps {
|
|
|
82
82
|
* The device secret is FINE — it is the identity binding that went stale, so
|
|
83
83
|
* the caller must re-establish from the local key, never drop the credential.
|
|
84
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.
|
|
85
90
|
* - `persist-failed` — the mint succeeded but `nextDeviceSecret` could NOT be
|
|
86
91
|
* durably persisted. The token is deliberately NOT planted: advertising a
|
|
87
92
|
* healthy session on a secret that will not survive a reload is exactly the
|
|
@@ -103,6 +108,8 @@ export type DeviceSecretMintOutcome = {
|
|
|
103
108
|
status: 'account-not-on-device';
|
|
104
109
|
} | {
|
|
105
110
|
status: 'transient';
|
|
111
|
+
} | {
|
|
112
|
+
status: 'session-ended';
|
|
106
113
|
} | {
|
|
107
114
|
status: 'persist-failed';
|
|
108
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,
|
|
@@ -284,8 +254,6 @@ const CACHE_SIZE_WARNING_THROTTLE_MS = 60000;
|
|
|
284
254
|
*/
|
|
285
255
|
class TokenStore {
|
|
286
256
|
private accessToken: string | null = null;
|
|
287
|
-
private csrfToken: string | null = null;
|
|
288
|
-
private csrfTokenFetchPromise: Promise<string | null> | null = null;
|
|
289
257
|
|
|
290
258
|
setTokens(accessToken: string): void {
|
|
291
259
|
this.accessToken = accessToken;
|
|
@@ -302,27 +270,6 @@ class TokenStore {
|
|
|
302
270
|
hasAccessToken(): boolean {
|
|
303
271
|
return !!this.accessToken;
|
|
304
272
|
}
|
|
305
|
-
|
|
306
|
-
setCsrfToken(token: string | null): void {
|
|
307
|
-
this.csrfToken = token;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
getCsrfToken(): string | null {
|
|
311
|
-
return this.csrfToken;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
setCsrfTokenFetchPromise(promise: Promise<string | null> | null): void {
|
|
315
|
-
this.csrfTokenFetchPromise = promise;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
getCsrfTokenFetchPromise(): Promise<string | null> | null {
|
|
319
|
-
return this.csrfTokenFetchPromise;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
clearCsrfToken(): void {
|
|
323
|
-
this.csrfToken = null;
|
|
324
|
-
this.csrfTokenFetchPromise = null;
|
|
325
|
-
}
|
|
326
273
|
}
|
|
327
274
|
|
|
328
275
|
/**
|
|
@@ -432,6 +379,21 @@ export class HttpService {
|
|
|
432
379
|
private authRefreshHandler: AuthRefreshHandler | null = null;
|
|
433
380
|
private accessTokenProvider: AccessTokenProvider | null = null;
|
|
434
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;
|
|
435
397
|
|
|
436
398
|
/**
|
|
437
399
|
* Epoch (ms) before which a cache-size telemetry warning must not be
|
|
@@ -629,14 +591,9 @@ export class HttpService {
|
|
|
629
591
|
// it before enqueue means auth-blocked requests hold NO slot while the shared
|
|
630
592
|
// mint runs. `skipAuth` requests (the body-authenticated mint) send NO bearer
|
|
631
593
|
// and skip the near-expiry preflight — see RequestOptions.skipAuth. The
|
|
632
|
-
// 401
|
|
633
|
-
//
|
|
634
|
-
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.
|
|
635
596
|
const authHeader = config.skipAuth ? null : await this.getAuthHeader();
|
|
636
|
-
// CSRF protects cookie-authenticated browser writes. Bearer-authenticated SDK
|
|
637
|
-
// clients are not vulnerable to ambient-cookie CSRF, and linked app APIs
|
|
638
|
-
// should not need to implement a duplicate `/csrf-token` route.
|
|
639
|
-
const csrfToken = isStateChangingMethod && !authHeader ? await this.fetchCsrfToken() : null;
|
|
640
597
|
const edgeRegionHeader = await getBrowserEdgeRegionHeader();
|
|
641
598
|
const activityIdHeader = getBrowserActivityIdHeader();
|
|
642
599
|
|
|
@@ -717,32 +674,6 @@ export class HttpService {
|
|
|
717
674
|
headers['Authorization'] = authHeader;
|
|
718
675
|
}
|
|
719
676
|
|
|
720
|
-
// Add CSRF token header for state-changing requests
|
|
721
|
-
if (csrfToken) {
|
|
722
|
-
headers['X-CSRF-Token'] = csrfToken;
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
// Add native app header for React Native (required for CSRF validation)
|
|
726
|
-
// Native apps can't persist cookies like browsers, so the server uses
|
|
727
|
-
// header-only CSRF validation when this header is present
|
|
728
|
-
if (isNativeApp && isStateChangingMethod) {
|
|
729
|
-
headers['X-Native-App'] = 'true';
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
// Debug logging for CSRF issues, routed through SimpleLogger so it only
|
|
733
|
-
// fires when consumers opt in via `enableLogging`.
|
|
734
|
-
if (isStateChangingMethod) {
|
|
735
|
-
this.logger.debug('CSRF Debug:', {
|
|
736
|
-
url,
|
|
737
|
-
method,
|
|
738
|
-
isNativeApp,
|
|
739
|
-
platformOS: getPlatformOS(),
|
|
740
|
-
hasCsrfToken: !!csrfToken,
|
|
741
|
-
csrfTokenLength: csrfToken?.length,
|
|
742
|
-
hasNativeAppHeader: headers['X-Native-App'] === 'true',
|
|
743
|
-
});
|
|
744
|
-
}
|
|
745
|
-
|
|
746
677
|
// Merge custom headers if provided
|
|
747
678
|
if (config.headers) {
|
|
748
679
|
Object.entries(config.headers).forEach(([key, value]) => {
|
|
@@ -807,35 +738,20 @@ export class HttpService {
|
|
|
807
738
|
if (response.status === 401 && !config._isAuthRetry && !config.skipAuth) {
|
|
808
739
|
const refreshed = await this.refreshAccessToken('response-401');
|
|
809
740
|
if (refreshed) {
|
|
810
|
-
// `deduplicate: false` is REQUIRED on the retry
|
|
811
|
-
//
|
|
812
|
-
//
|
|
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
|
|
813
744
|
// for the SAME user, so the identity-scoped key is UNCHANGED — a
|
|
814
745
|
// deduplicated retry would resolve to the still-pending original
|
|
815
746
|
// and await itself (deadlock). Opting the retry out of dedupe makes
|
|
816
747
|
// it a fresh request.
|
|
817
748
|
return this.request<T>({ ...config, _isAuthRetry: true, retry: false, deduplicate: false });
|
|
818
749
|
}
|
|
819
|
-
// Refresh failed or no token — clear tokens
|
|
750
|
+
// Refresh failed or no token — clear tokens
|
|
820
751
|
this.tokenStore.clearTokens();
|
|
821
|
-
this.tokenStore.clearCsrfToken();
|
|
822
752
|
this.notifyTokenChange();
|
|
823
753
|
}
|
|
824
754
|
|
|
825
|
-
// On 403 with CSRF error, clear cached token and retry once
|
|
826
|
-
if (response.status === 403 && !config._isCsrfRetry) {
|
|
827
|
-
try {
|
|
828
|
-
const clonedResponse = response.clone();
|
|
829
|
-
const errBody = await clonedResponse.json() as { code?: string } | null;
|
|
830
|
-
if (errBody?.code === 'CSRF_TOKEN_INVALID' || errBody?.code === 'CSRF_TOKEN_MISSING') {
|
|
831
|
-
this.tokenStore.clearCsrfToken();
|
|
832
|
-
return this.request<T>({ ...config, _isCsrfRetry: true, retry: false, deduplicate: false });
|
|
833
|
-
}
|
|
834
|
-
} catch {
|
|
835
|
-
// Failed to parse error body — not a CSRF error
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
|
|
839
755
|
// Read the error body (may be absent, non-JSON, empty or malformed).
|
|
840
756
|
// Anything unreadable leaves `errorBody` undefined and degrades to the
|
|
841
757
|
// status-based message — an error path that throws its own error is
|
|
@@ -1269,85 +1185,6 @@ export class HttpService {
|
|
|
1269
1185
|
}
|
|
1270
1186
|
}
|
|
1271
1187
|
|
|
1272
|
-
/**
|
|
1273
|
-
* Fetch CSRF token from server (with deduplication)
|
|
1274
|
-
* Required for state-changing requests (POST, PUT, PATCH, DELETE)
|
|
1275
|
-
*/
|
|
1276
|
-
private async fetchCsrfToken(): Promise<string | null> {
|
|
1277
|
-
// Return cached token if available
|
|
1278
|
-
const cachedToken = this.tokenStore.getCsrfToken();
|
|
1279
|
-
if (cachedToken) {
|
|
1280
|
-
this.logger.debug('Using cached CSRF token');
|
|
1281
|
-
return cachedToken;
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
// Deduplicate concurrent CSRF token fetches
|
|
1285
|
-
const existingPromise = this.tokenStore.getCsrfTokenFetchPromise();
|
|
1286
|
-
if (existingPromise) {
|
|
1287
|
-
this.logger.debug('Waiting for existing CSRF fetch');
|
|
1288
|
-
return existingPromise;
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
const fetchPromise = (async () => {
|
|
1292
|
-
for (let attempt = 1; attempt <= CSRF_FETCH_MAX_ATTEMPTS; attempt++) {
|
|
1293
|
-
try {
|
|
1294
|
-
this.logger.debug('Fetching CSRF token from:', `${this.baseURL}/csrf-token`, `(attempt ${attempt})`);
|
|
1295
|
-
|
|
1296
|
-
// Use AbortController for timeout (more compatible than AbortSignal.timeout)
|
|
1297
|
-
const controller = new AbortController();
|
|
1298
|
-
const timeoutId = setTimeout(() => controller.abort(), CSRF_FETCH_TIMEOUT_MS);
|
|
1299
|
-
|
|
1300
|
-
const response = await fetch(`${this.baseURL}/csrf-token`, {
|
|
1301
|
-
method: 'GET',
|
|
1302
|
-
headers: { 'Accept': 'application/json' },
|
|
1303
|
-
credentials: 'include', // Required to receive and send cookies
|
|
1304
|
-
signal: controller.signal,
|
|
1305
|
-
});
|
|
1306
|
-
|
|
1307
|
-
clearTimeout(timeoutId);
|
|
1308
|
-
|
|
1309
|
-
this.logger.debug('CSRF fetch response:', response.status, response.ok);
|
|
1310
|
-
|
|
1311
|
-
if (response.ok) {
|
|
1312
|
-
const data = await response.json() as { csrfToken?: string };
|
|
1313
|
-
const token = data.csrfToken || null;
|
|
1314
|
-
this.logger.debug('CSRF response data:', {
|
|
1315
|
-
hasCsrfToken: typeof token === 'string' && token.length > 0,
|
|
1316
|
-
csrfTokenLength: token?.length,
|
|
1317
|
-
});
|
|
1318
|
-
this.tokenStore.setCsrfToken(token);
|
|
1319
|
-
this.logger.debug('CSRF token fetched');
|
|
1320
|
-
return token;
|
|
1321
|
-
}
|
|
1322
|
-
|
|
1323
|
-
// Also check response header for CSRF token
|
|
1324
|
-
const headerToken = response.headers.get('X-CSRF-Token');
|
|
1325
|
-
if (headerToken) {
|
|
1326
|
-
this.tokenStore.setCsrfToken(headerToken);
|
|
1327
|
-
this.logger.debug('CSRF token from header');
|
|
1328
|
-
return headerToken;
|
|
1329
|
-
}
|
|
1330
|
-
|
|
1331
|
-
this.logger.debug('CSRF fetch failed with status:', response.status);
|
|
1332
|
-
this.logger.warn('Failed to fetch CSRF token:', response.status);
|
|
1333
|
-
} catch (error) {
|
|
1334
|
-
this.logger.debug('CSRF fetch error:', error);
|
|
1335
|
-
this.logger.warn('CSRF token fetch error:', error);
|
|
1336
|
-
}
|
|
1337
|
-
// Brief backoff before the next attempt.
|
|
1338
|
-
if (attempt < CSRF_FETCH_MAX_ATTEMPTS) {
|
|
1339
|
-
await new Promise(resolve => setTimeout(resolve, CSRF_FETCH_RETRY_DELAY_MS));
|
|
1340
|
-
}
|
|
1341
|
-
}
|
|
1342
|
-
return null;
|
|
1343
|
-
})().finally(() => {
|
|
1344
|
-
this.tokenStore.setCsrfTokenFetchPromise(null);
|
|
1345
|
-
});
|
|
1346
|
-
|
|
1347
|
-
this.tokenStore.setCsrfTokenFetchPromise(fetchPromise);
|
|
1348
|
-
return fetchPromise;
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
1188
|
/**
|
|
1352
1189
|
* Get auth header with automatic token refresh
|
|
1353
1190
|
*/
|
|
@@ -1413,12 +1250,21 @@ export class HttpService {
|
|
|
1413
1250
|
// by calling `noteRefreshRateLimited()` from INSIDE this call, so clearing
|
|
1414
1251
|
// on the way out would discard the flag it just set.
|
|
1415
1252
|
this.lastRefreshWasRateLimited = false;
|
|
1253
|
+
const epoch = this.sessionEpoch;
|
|
1416
1254
|
this.tokenRefreshPromise = this.authRefreshHandler(reason)
|
|
1417
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
|
+
}
|
|
1418
1262
|
if (!newToken) {
|
|
1419
1263
|
this.lastRefreshFailureAt = Date.now();
|
|
1420
1264
|
return null;
|
|
1421
1265
|
}
|
|
1266
|
+
// A token is planted again, so there is a session again.
|
|
1267
|
+
this.sessionEnded = false;
|
|
1422
1268
|
if (this.tokenStore.getAccessToken() !== newToken) {
|
|
1423
1269
|
this.tokenStore.setTokens(newToken);
|
|
1424
1270
|
this.notifyTokenChange();
|
|
@@ -1639,7 +1485,6 @@ export class HttpService {
|
|
|
1639
1485
|
}
|
|
1640
1486
|
|
|
1641
1487
|
this.tokenStore.clearTokens();
|
|
1642
|
-
this.tokenStore.clearCsrfToken();
|
|
1643
1488
|
this.notifyTokenChange();
|
|
1644
1489
|
}
|
|
1645
1490
|
|
|
@@ -1680,10 +1525,38 @@ export class HttpService {
|
|
|
1680
1525
|
|
|
1681
1526
|
// Token management
|
|
1682
1527
|
setTokens(accessToken: string): void {
|
|
1528
|
+
this.sessionEnded = false;
|
|
1683
1529
|
this.tokenStore.setTokens(accessToken);
|
|
1684
1530
|
this.notifyTokenChange();
|
|
1685
1531
|
}
|
|
1686
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
|
+
|
|
1687
1560
|
setAuthRefreshHandler(handler: AuthRefreshHandler | null): void {
|
|
1688
1561
|
this.authRefreshHandler = handler;
|
|
1689
1562
|
}
|
|
@@ -1694,7 +1567,6 @@ export class HttpService {
|
|
|
1694
1567
|
|
|
1695
1568
|
clearTokens(): void {
|
|
1696
1569
|
this.tokenStore.clearTokens();
|
|
1697
|
-
this.tokenStore.clearCsrfToken();
|
|
1698
1570
|
// Drop the response cache on logout. The cache is identity-scoped, so a
|
|
1699
1571
|
// different user could never read these entries, but a logged-out client
|
|
1700
1572
|
// must not keep the previous session's personalized data resident in
|
|
@@ -1819,7 +1691,7 @@ export class HttpService {
|
|
|
1819
1691
|
|
|
1820
1692
|
// Test-only utility — clears tokens on this instance
|
|
1821
1693
|
__resetTokensForTests(): void {
|
|
1694
|
+
this.sessionEnded = false;
|
|
1822
1695
|
this.tokenStore.clearTokens();
|
|
1823
|
-
this.tokenStore.clearCsrfToken();
|
|
1824
1696
|
}
|
|
1825
1697
|
}
|
package/src/OxyServices.base.ts
CHANGED
|
@@ -212,10 +212,12 @@ export class OxyServicesBase {
|
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
/**
|
|
215
|
-
* Clear stored authentication tokens
|
|
215
|
+
* Clear stored authentication tokens and end the local session: a re-mint
|
|
216
|
+
* already in flight plants nothing, and no new one runs until a token is set
|
|
217
|
+
* again (see `HttpService.endSession`).
|
|
216
218
|
*/
|
|
217
219
|
public clearTokens(): void {
|
|
218
|
-
this.httpService.
|
|
220
|
+
this.httpService.endSession();
|
|
219
221
|
this._cachedUserId = undefined;
|
|
220
222
|
this._cachedAccessToken = null;
|
|
221
223
|
}
|
|
@@ -46,8 +46,8 @@ describe('HttpService form-urlencoded bodies', () => {
|
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
* POST a form body and return the token-endpoint call. Selected by URL
|
|
50
|
-
*
|
|
49
|
+
* POST a form body and return the token-endpoint call. Selected by URL so
|
|
50
|
+
* any preflight a future change adds cannot shift the call under test.
|
|
51
51
|
*/
|
|
52
52
|
async function postForm(body: URLSearchParams): Promise<FetchCall> {
|
|
53
53
|
const calls: FetchCall[] = [];
|
|
@@ -111,9 +111,6 @@ describe('HttpService form-urlencoded bodies', () => {
|
|
|
111
111
|
it('prefers OAuth error_description over the bare error code', async () => {
|
|
112
112
|
globalThis.fetch = async (input) => {
|
|
113
113
|
const url = String(input);
|
|
114
|
-
if (url.endsWith('/csrf-token')) {
|
|
115
|
-
return jsonResponse({ csrfToken: 'csrf-test' });
|
|
116
|
-
}
|
|
117
114
|
return new Response(
|
|
118
115
|
JSON.stringify({
|
|
119
116
|
error: 'invalid_grant',
|