@oxyhq/core 2.3.2 → 2.4.1
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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/AuthManager.js +8 -5
- package/dist/cjs/mixins/OxyServices.auth.js +24 -1
- package/dist/cjs/mixins/OxyServices.fedcm.js +77 -8
- package/dist/cjs/mixins/OxyServices.popup.js +16 -0
- package/dist/cjs/utils/coldBoot.js +66 -17
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/AuthManager.js +8 -5
- package/dist/esm/mixins/OxyServices.auth.js +24 -1
- package/dist/esm/mixins/OxyServices.fedcm.js +77 -8
- package/dist/esm/mixins/OxyServices.popup.js +16 -0
- package/dist/esm/utils/coldBoot.js +66 -17
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/AuthManager.d.ts +3 -3
- package/dist/types/AuthManagerTypes.d.ts +17 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/mixins/OxyServices.auth.d.ts +20 -1
- package/dist/types/mixins/OxyServices.fedcm.d.ts +2 -1
- package/dist/types/utils/coldBoot.d.ts +26 -0
- package/package.json +1 -1
- package/src/AuthManager.ts +9 -5
- package/src/AuthManagerTypes.ts +18 -0
- package/src/index.ts +1 -0
- package/src/mixins/OxyServices.auth.ts +44 -1
- package/src/mixins/OxyServices.fedcm.ts +82 -8
- package/src/mixins/OxyServices.popup.ts +17 -0
- package/src/mixins/__tests__/fedcm.test.ts +115 -2
- package/src/mixins/__tests__/popup.test.ts +67 -0
- package/src/utils/__tests__/coldBoot.test.ts +150 -0
- package/src/utils/coldBoot.ts +96 -17
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import type { OxyServices } from './OxyServices';
|
|
10
10
|
import type { SessionLoginResponse, MinimalUserData } from './models/session';
|
|
11
|
-
import type { AuthManagerAccount, RestoreFromCookiesResult, SwitchAuthuserResult } from './AuthManagerTypes';
|
|
11
|
+
import type { AuthManagerAccount, RestoreFromCookiesResult, RestoreFromCookiesOptions, SwitchAuthuserResult } from './AuthManagerTypes';
|
|
12
12
|
/**
|
|
13
13
|
* Storage adapter interface for platform-agnostic storage.
|
|
14
14
|
*/
|
|
@@ -269,7 +269,7 @@ export declare class AuthManager {
|
|
|
269
269
|
* Returns the active user on success, or `null` when neither path
|
|
270
270
|
* restored a session.
|
|
271
271
|
*/
|
|
272
|
-
initialize(): Promise<MinimalUserData | null>;
|
|
272
|
+
initialize(options?: RestoreFromCookiesOptions): Promise<MinimalUserData | null>;
|
|
273
273
|
/**
|
|
274
274
|
* Read the persisted active `authuser` slot index. Returns `null` when
|
|
275
275
|
* none is persisted, the value is corrupt, or the storage adapter has no
|
|
@@ -343,7 +343,7 @@ export declare class AuthManager {
|
|
|
343
343
|
* proceed unauthenticated. State is NOT cleared on failure; existing
|
|
344
344
|
* accounts (if any) remain intact.
|
|
345
345
|
*/
|
|
346
|
-
restoreFromCookies(): Promise<RestoreFromCookiesResult>;
|
|
346
|
+
restoreFromCookies(options?: RestoreFromCookiesOptions): Promise<RestoreFromCookiesResult>;
|
|
347
347
|
/**
|
|
348
348
|
* Switch the active account to a different device-local slot.
|
|
349
349
|
*
|
|
@@ -55,6 +55,23 @@ export interface RestoreFromCookiesResult {
|
|
|
55
55
|
accounts: AuthManagerAccount[];
|
|
56
56
|
activeAuthuser: number | null;
|
|
57
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Options for `AuthManager.restoreFromCookies()` / `AuthManager.initialize()`.
|
|
60
|
+
*/
|
|
61
|
+
export interface RestoreFromCookiesOptions {
|
|
62
|
+
/**
|
|
63
|
+
* Abort the underlying `POST /auth/refresh-all` after this many milliseconds
|
|
64
|
+
* and treat it as "no signed-in accounts" instead of hanging. Forwarded
|
|
65
|
+
* verbatim to `OxyServices.refreshAllSessions({ timeout })`.
|
|
66
|
+
*
|
|
67
|
+
* Intended for the cold-boot cookie-restore step on a cross-domain RP, where
|
|
68
|
+
* the `Domain=oxy.so` refresh cookie never reaches `api.<apex>` and the
|
|
69
|
+
* request can stall with no useful answer. Omit (the default) to wait
|
|
70
|
+
* indefinitely — the warm cross-tab cascade path passes nothing, preserving
|
|
71
|
+
* its existing behaviour.
|
|
72
|
+
*/
|
|
73
|
+
timeout?: number;
|
|
74
|
+
}
|
|
58
75
|
/**
|
|
59
76
|
* Outcome of `AuthManager.switchAuthuser()`.
|
|
60
77
|
*
|
package/dist/types/index.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ export { OxyServices, OxyAuthenticationError, OxyAuthenticationTimeoutError } fr
|
|
|
21
21
|
export { OXY_CLOUD_URL, oxyClient } from './OxyServices';
|
|
22
22
|
export { AuthManager, createAuthManager } from './AuthManager';
|
|
23
23
|
export type { StorageAdapter, AuthStateChangeCallback, AuthMethod, AuthManagerConfig, } from './AuthManager';
|
|
24
|
-
export type { AuthManagerAccount, RestoreFromCookiesResult, SwitchAuthuserResult, } from './AuthManagerTypes';
|
|
24
|
+
export type { AuthManagerAccount, RestoreFromCookiesResult, RestoreFromCookiesOptions, SwitchAuthuserResult, } from './AuthManagerTypes';
|
|
25
25
|
export { CrossDomainAuth, createCrossDomainAuth } from './CrossDomainAuth';
|
|
26
26
|
export type { CrossDomainAuthOptions } from './CrossDomainAuth';
|
|
27
27
|
export type { FedCMAuthOptions, FedCMConfig, AuthorizedApp } from './mixins/OxyServices.fedcm';
|
|
@@ -10,6 +10,25 @@ export interface ChallengeResponse {
|
|
|
10
10
|
challenge: string;
|
|
11
11
|
expiresAt: string;
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* Options for {@link refreshAllSessions}.
|
|
15
|
+
*/
|
|
16
|
+
export interface RefreshAllOptions {
|
|
17
|
+
/**
|
|
18
|
+
* Abort the `POST /auth/refresh-all` request after this many milliseconds and
|
|
19
|
+
* resolve as "no signed-in accounts" (`{ accounts: [] }`) rather than hanging.
|
|
20
|
+
*
|
|
21
|
+
* Why: on a cross-domain RP (e.g. `mention.earth`) the `Domain=oxy.so` refresh
|
|
22
|
+
* cookie never reaches `api.<apex>`, so this request can stall behind a slow
|
|
23
|
+
* or unreachable endpoint with no useful answer coming back. As one step of
|
|
24
|
+
* the ordered cold-boot sequence, a stalled refresh is dead latency in front
|
|
25
|
+
* of the steps that actually hold the answer. A bounded abort lets cold boot
|
|
26
|
+
* fall through quickly. Omit (or pass `0`/negative) to wait indefinitely
|
|
27
|
+
* (the legacy behaviour). The abort is treated identically to a 401 — the
|
|
28
|
+
* "not signed in on this device" path — never an error.
|
|
29
|
+
*/
|
|
30
|
+
timeout?: number;
|
|
31
|
+
}
|
|
13
32
|
export interface RegistrationRequest {
|
|
14
33
|
publicKey: string;
|
|
15
34
|
username: string;
|
|
@@ -293,7 +312,7 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
|
|
|
293
312
|
* tokens do. Each access token still needs to be planted via
|
|
294
313
|
* `setTokens(...)` (or per-account in-memory storage) at the consumer.
|
|
295
314
|
*/
|
|
296
|
-
refreshAllSessions(): Promise<RefreshAllResponse>;
|
|
315
|
+
refreshAllSessions(options?: RefreshAllOptions): Promise<RefreshAllResponse>;
|
|
297
316
|
/**
|
|
298
317
|
* Rotate a single refresh-cookie slot and return the fresh access token.
|
|
299
318
|
*
|
|
@@ -304,7 +304,8 @@ export declare function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(
|
|
|
304
304
|
};
|
|
305
305
|
readonly DEFAULT_CONFIG_URL: "https://auth.oxy.so/fedcm.json";
|
|
306
306
|
readonly FEDCM_TIMEOUT: 15000;
|
|
307
|
-
readonly FEDCM_SILENT_TIMEOUT:
|
|
307
|
+
readonly FEDCM_SILENT_TIMEOUT: 4000;
|
|
308
|
+
readonly FEDCM_ABORT_SETTLE_GRACE_MS: 500;
|
|
308
309
|
/**
|
|
309
310
|
* Check if FedCM is supported in the current browser
|
|
310
311
|
*/
|
|
@@ -83,6 +83,32 @@ export interface RunColdBootOptions<S> {
|
|
|
83
83
|
* the runner does not guard against an observer that itself throws.
|
|
84
84
|
*/
|
|
85
85
|
readonly onStepError?: (id: string, error: unknown) => void;
|
|
86
|
+
/**
|
|
87
|
+
* Optional HARD overall deadline (ms) for the entire ordered step loop —
|
|
88
|
+
* defense-in-depth so a single non-settling step can NEVER hang the whole
|
|
89
|
+
* cold boot forever.
|
|
90
|
+
*
|
|
91
|
+
* Each step's `run()` is raced against the SHARED remaining time. If a step
|
|
92
|
+
* fails to settle before the deadline, the runner abandons the await for that
|
|
93
|
+
* step (reporting it via `onStepDeadline`) and CONTINUES to the next step,
|
|
94
|
+
* each now racing against an already-expired deadline. This is deliberate:
|
|
95
|
+
* the runner keeps iterating so the TERMINAL step (e.g. the `/sso` bounce,
|
|
96
|
+
* whose `run()` performs its side effect synchronously before its first
|
|
97
|
+
* `await`) still gets to fire. A step that has nothing to contribute after
|
|
98
|
+
* the deadline simply doesn't settle and is skipped in turn.
|
|
99
|
+
*
|
|
100
|
+
* Per-step timeouts inside `run()` remain the first line of defense and
|
|
101
|
+
* should keep every step well under this budget on a healthy load; this only
|
|
102
|
+
* trips when one of them regresses (the production FedCM-silent hang). When
|
|
103
|
+
* omitted there is NO overall deadline (unchanged legacy behaviour).
|
|
104
|
+
*/
|
|
105
|
+
readonly overallDeadlineMs?: number;
|
|
106
|
+
/**
|
|
107
|
+
* Optional observer invoked once per step that was abandoned because the
|
|
108
|
+
* overall deadline expired before it settled. Receives the step `id`. Must
|
|
109
|
+
* not throw.
|
|
110
|
+
*/
|
|
111
|
+
readonly onStepDeadline?: (id: string) => void;
|
|
86
112
|
}
|
|
87
113
|
/**
|
|
88
114
|
* Run the ordered cold-boot steps and resolve to the first recovered session,
|
package/package.json
CHANGED
package/src/AuthManager.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
import type {
|
|
21
21
|
AuthManagerAccount,
|
|
22
22
|
RestoreFromCookiesResult,
|
|
23
|
+
RestoreFromCookiesOptions,
|
|
23
24
|
SwitchAuthuserResult,
|
|
24
25
|
} from './AuthManagerTypes';
|
|
25
26
|
import { retryAsync } from './utils/asyncUtils';
|
|
@@ -899,9 +900,10 @@ export class AuthManager {
|
|
|
899
900
|
* Returns the active user on success, or `null` when neither path
|
|
900
901
|
* restored a session.
|
|
901
902
|
*/
|
|
902
|
-
async initialize(): Promise<MinimalUserData | null> {
|
|
903
|
-
// 1. Cookie path (preferred).
|
|
904
|
-
|
|
903
|
+
async initialize(options: RestoreFromCookiesOptions = {}): Promise<MinimalUserData | null> {
|
|
904
|
+
// 1. Cookie path (preferred). Forward the optional cold-boot fail-fast
|
|
905
|
+
// timeout so a cross-domain stall cannot hang provider init.
|
|
906
|
+
const cookieResult = await this.restoreFromCookies(options);
|
|
905
907
|
if (cookieResult.accounts.length > 0) {
|
|
906
908
|
return this.currentUser;
|
|
907
909
|
}
|
|
@@ -1126,7 +1128,7 @@ export class AuthManager {
|
|
|
1126
1128
|
* proceed unauthenticated. State is NOT cleared on failure; existing
|
|
1127
1129
|
* accounts (if any) remain intact.
|
|
1128
1130
|
*/
|
|
1129
|
-
async restoreFromCookies(): Promise<RestoreFromCookiesResult> {
|
|
1131
|
+
async restoreFromCookies(options: RestoreFromCookiesOptions = {}): Promise<RestoreFromCookiesResult> {
|
|
1130
1132
|
// Cross-tab cascade debounce. If we restored within the last
|
|
1131
1133
|
// _RESTORE_DEBOUNCE_MS for the currently-active slot, skip the network
|
|
1132
1134
|
// round-trip and return the cached registry verbatim. A burst of N
|
|
@@ -1145,7 +1147,9 @@ export class AuthManager {
|
|
|
1145
1147
|
|
|
1146
1148
|
let snapshot: RefreshAllResponse;
|
|
1147
1149
|
try {
|
|
1148
|
-
|
|
1150
|
+
// Forward the optional cold-boot fail-fast timeout. Undefined (the warm
|
|
1151
|
+
// cross-tab cascade default) preserves the wait-indefinitely behaviour.
|
|
1152
|
+
snapshot = await this.oxyServices.refreshAllSessions({ timeout: options.timeout });
|
|
1149
1153
|
} catch {
|
|
1150
1154
|
return { accounts: [], activeAuthuser: null };
|
|
1151
1155
|
}
|
package/src/AuthManagerTypes.ts
CHANGED
|
@@ -59,6 +59,24 @@ export interface RestoreFromCookiesResult {
|
|
|
59
59
|
activeAuthuser: number | null;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Options for `AuthManager.restoreFromCookies()` / `AuthManager.initialize()`.
|
|
64
|
+
*/
|
|
65
|
+
export interface RestoreFromCookiesOptions {
|
|
66
|
+
/**
|
|
67
|
+
* Abort the underlying `POST /auth/refresh-all` after this many milliseconds
|
|
68
|
+
* and treat it as "no signed-in accounts" instead of hanging. Forwarded
|
|
69
|
+
* verbatim to `OxyServices.refreshAllSessions({ timeout })`.
|
|
70
|
+
*
|
|
71
|
+
* Intended for the cold-boot cookie-restore step on a cross-domain RP, where
|
|
72
|
+
* the `Domain=oxy.so` refresh cookie never reaches `api.<apex>` and the
|
|
73
|
+
* request can stall with no useful answer. Omit (the default) to wait
|
|
74
|
+
* indefinitely — the warm cross-tab cascade path passes nothing, preserving
|
|
75
|
+
* its existing behaviour.
|
|
76
|
+
*/
|
|
77
|
+
timeout?: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
62
80
|
/**
|
|
63
81
|
* Outcome of `AuthManager.switchAuthuser()`.
|
|
64
82
|
*
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,26 @@ export interface ChallengeResponse {
|
|
|
20
20
|
expiresAt: string;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Options for {@link refreshAllSessions}.
|
|
25
|
+
*/
|
|
26
|
+
export interface RefreshAllOptions {
|
|
27
|
+
/**
|
|
28
|
+
* Abort the `POST /auth/refresh-all` request after this many milliseconds and
|
|
29
|
+
* resolve as "no signed-in accounts" (`{ accounts: [] }`) rather than hanging.
|
|
30
|
+
*
|
|
31
|
+
* Why: on a cross-domain RP (e.g. `mention.earth`) the `Domain=oxy.so` refresh
|
|
32
|
+
* cookie never reaches `api.<apex>`, so this request can stall behind a slow
|
|
33
|
+
* or unreachable endpoint with no useful answer coming back. As one step of
|
|
34
|
+
* the ordered cold-boot sequence, a stalled refresh is dead latency in front
|
|
35
|
+
* of the steps that actually hold the answer. A bounded abort lets cold boot
|
|
36
|
+
* fall through quickly. Omit (or pass `0`/negative) to wait indefinitely
|
|
37
|
+
* (the legacy behaviour). The abort is treated identically to a 401 — the
|
|
38
|
+
* "not signed in on this device" path — never an error.
|
|
39
|
+
*/
|
|
40
|
+
timeout?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
23
43
|
export interface RegistrationRequest {
|
|
24
44
|
publicKey: string;
|
|
25
45
|
username: string;
|
|
@@ -619,18 +639,41 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
619
639
|
* tokens do. Each access token still needs to be planted via
|
|
620
640
|
* `setTokens(...)` (or per-account in-memory storage) at the consumer.
|
|
621
641
|
*/
|
|
622
|
-
async refreshAllSessions(): Promise<RefreshAllResponse> {
|
|
642
|
+
async refreshAllSessions(options: RefreshAllOptions = {}): Promise<RefreshAllResponse> {
|
|
623
643
|
const url = `${this.getSessionBaseUrl().replace(/\/$/, '')}/auth/refresh-all`;
|
|
624
644
|
|
|
645
|
+
// Optional bounded abort (see `RefreshAllOptions.timeout`). A positive
|
|
646
|
+
// timeout arms an `AbortController` that aborts the in-flight request; an
|
|
647
|
+
// abort is treated as "no signed-in accounts on this device" — the same
|
|
648
|
+
// outcome as a 401 — so a cross-domain stall falls through cleanly instead
|
|
649
|
+
// of hanging the cold boot.
|
|
650
|
+
const timeout = typeof options.timeout === 'number' && options.timeout > 0 ? options.timeout : undefined;
|
|
651
|
+
const controller = timeout !== undefined ? new AbortController() : undefined;
|
|
652
|
+
const timeoutId = timeout !== undefined && controller
|
|
653
|
+
? setTimeout(() => controller.abort(), timeout)
|
|
654
|
+
: undefined;
|
|
655
|
+
|
|
625
656
|
let response: Response;
|
|
626
657
|
try {
|
|
627
658
|
response = await fetch(url, {
|
|
628
659
|
method: 'POST',
|
|
629
660
|
credentials: 'include',
|
|
630
661
|
headers: { Accept: 'application/json' },
|
|
662
|
+
signal: controller?.signal,
|
|
631
663
|
});
|
|
632
664
|
} catch (error) {
|
|
665
|
+
// A bounded-timeout abort is the "not signed in / cross-domain stall"
|
|
666
|
+
// path, NOT an error. The browser raises a DOMException named
|
|
667
|
+
// 'AbortError' (some runtimes use a generic Error); match on the name so
|
|
668
|
+
// we never throw the timeout into the cold-boot error handler.
|
|
669
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
670
|
+
return { accounts: [] };
|
|
671
|
+
}
|
|
633
672
|
throw this.handleError(error);
|
|
673
|
+
} finally {
|
|
674
|
+
if (timeoutId !== undefined) {
|
|
675
|
+
clearTimeout(timeoutId);
|
|
676
|
+
}
|
|
634
677
|
}
|
|
635
678
|
|
|
636
679
|
if (response.status === 401) {
|
|
@@ -202,12 +202,28 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
public static readonly FEDCM_TIMEOUT = 15000; // 15 seconds for interactive
|
|
205
|
-
// Silent mediation runs on page load
|
|
206
|
-
//
|
|
207
|
-
// round-trip
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
|
|
205
|
+
// Silent mediation runs on page load as ONE step of the ordered cold-boot
|
|
206
|
+
// sequence (mint nonce → navigator.credentials.get → /fedcm/exchange). The
|
|
207
|
+
// real round-trip was measured at >3s for live users, so the budget must stay
|
|
208
|
+
// comfortably above 3s. It must ALSO be tight: on a logged-out browser this
|
|
209
|
+
// step never resolves a credential, and every millisecond it spends timing
|
|
210
|
+
// out is pure latency in front of the steps that actually hold the answer
|
|
211
|
+
// (stored-session bearer, the per-apex silent iframe, the /sso bounce). 4s is
|
|
212
|
+
// the floor that preserves the >3s success margin while bounding the dead
|
|
213
|
+
// wait — down from the previous 10s, which alone could account for most of a
|
|
214
|
+
// 20-30s cold-boot stall. Do NOT lower below 4s (it would clip live success).
|
|
215
|
+
public static readonly FEDCM_SILENT_TIMEOUT = 4000; // 4 seconds for silent mediation
|
|
216
|
+
|
|
217
|
+
// Grace margin between the cooperative abort deadline (`FEDCM_SILENT_TIMEOUT`
|
|
218
|
+
// / `FEDCM_TIMEOUT`) and the HARD settle of `requestIdentityCredential`. The
|
|
219
|
+
// abort fires first; a well-behaved browser surfaces its own `AbortError`
|
|
220
|
+
// within this window (keeping the existing error path intact). If — as seen
|
|
221
|
+
// in production — `navigator.credentials.get()` ignores the abort and the
|
|
222
|
+
// awaited promise never settles, the hard settle resolves the request to
|
|
223
|
+
// `null` this many ms later, guaranteeing the cold-boot step always settles.
|
|
224
|
+
// 500ms is ample for a browser to deliver an abort rejection while keeping the
|
|
225
|
+
// worst-case dead wait tight (silent: 4.5s, interactive: 15.5s).
|
|
226
|
+
public static readonly FEDCM_ABORT_SETTLE_GRACE_MS = 500;
|
|
211
227
|
|
|
212
228
|
/**
|
|
213
229
|
* Check if FedCM is supported in the current browser
|
|
@@ -419,6 +435,21 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
419
435
|
|
|
420
436
|
const loginHint = this.getStoredLoginHint();
|
|
421
437
|
|
|
438
|
+
// Fast-skip: with no stored login hint this browser has never completed a
|
|
439
|
+
// FedCM sign-in for any Oxy account, so silent mediation cannot return a
|
|
440
|
+
// credential — the IdP has nothing to silently re-issue. Doing the full
|
|
441
|
+
// round-trip anyway (mint a nonce via `POST /fedcm/nonce`, then a
|
|
442
|
+
// `navigator.credentials.get` that aborts after `FEDCM_SILENT_TIMEOUT`) is
|
|
443
|
+
// pure latency in the cold-boot critical path. Return `null` immediately so
|
|
444
|
+
// the next cold-boot step (stored-session / iframe / bounce) runs without
|
|
445
|
+
// the wasted nonce mint + abort wait. A genuinely associated browser always
|
|
446
|
+
// has a hint (it is stored only after a real exchange), so this never skips
|
|
447
|
+
// a recoverable session.
|
|
448
|
+
if (!loginHint) {
|
|
449
|
+
debug.log('Silent SSO: No stored login hint — skipping silent mediation (no association on this browser)');
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
|
|
422
453
|
try {
|
|
423
454
|
// Server-minted, origin-bound nonce — required for `/fedcm/exchange`
|
|
424
455
|
// to accept the resulting ID token (anti-replay binding).
|
|
@@ -586,6 +617,37 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
586
617
|
controller.abort();
|
|
587
618
|
}, timeoutMs);
|
|
588
619
|
|
|
620
|
+
// Hard settle guarantee for the timeout path.
|
|
621
|
+
//
|
|
622
|
+
// The `setTimeout` above aborts the request's `AbortController`, which is
|
|
623
|
+
// the COOPERATIVE cancel signal. For a regular `fetch` an abort deterministically
|
|
624
|
+
// rejects the awaited promise — but `navigator.credentials.get()` is a
|
|
625
|
+
// browser-internal FedCM primitive whose abort behaviour is NOT guaranteed
|
|
626
|
+
// to settle the awaited promise in every Chrome version / internal state
|
|
627
|
+
// (the credential request can sit "pending" while the browser-side flow is
|
|
628
|
+
// stuck, ignoring the signal). If that happens, `await credentials.get(...)`
|
|
629
|
+
// never resolves OR rejects, this IIFE hangs forever, and — because this is
|
|
630
|
+
// ONE step of the ordered cold-boot sequence — the whole cold boot hangs and
|
|
631
|
+
// the terminal `/sso` bounce never fires. That was the production hang.
|
|
632
|
+
//
|
|
633
|
+
// `settlePromise` races the credential lookup against a timer that ALWAYS
|
|
634
|
+
// resolves to `null` shortly after the abort deadline. The abort still fires
|
|
635
|
+
// first (so the browser is asked to cancel), but even if `credentials.get`
|
|
636
|
+
// never settles, the race resolves and the step falls through cleanly to the
|
|
637
|
+
// next cold-boot step. The small `FEDCM_ABORT_SETTLE_GRACE_MS` margin gives a
|
|
638
|
+
// well-behaved browser the chance to surface its own AbortError (preserving
|
|
639
|
+
// the existing error path) before we force a clean `null`.
|
|
640
|
+
let settleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
641
|
+
const settlePromise = new Promise<FedCMIdentityCredential | null>((resolve) => {
|
|
642
|
+
const ctor = this.constructor as typeof OxyServicesBase & {
|
|
643
|
+
FEDCM_ABORT_SETTLE_GRACE_MS: number;
|
|
644
|
+
};
|
|
645
|
+
settleTimer = setTimeout(() => {
|
|
646
|
+
debug.log('Request hard-settled to null', timeoutMs + ctor.FEDCM_ABORT_SETTLE_GRACE_MS, 'ms (credentials.get never settled after abort)');
|
|
647
|
+
resolve(null);
|
|
648
|
+
}, timeoutMs + ctor.FEDCM_ABORT_SETTLE_GRACE_MS);
|
|
649
|
+
});
|
|
650
|
+
|
|
589
651
|
// Normalise the caller's mode to the modern W3C value first. A modern
|
|
590
652
|
// browser accepts it; an older one (Chrome 125–131) rejects it with a
|
|
591
653
|
// synchronous TypeError, in which case we retry with the legacy value.
|
|
@@ -625,7 +687,13 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
625
687
|
debug.log('Calling navigator.credentials.get with mediation:', requestedMediation, modernMode ? `mode: ${modernMode}` : '');
|
|
626
688
|
let credential: FedCMIdentityCredential | null;
|
|
627
689
|
try {
|
|
628
|
-
|
|
690
|
+
// Race the browser FedCM lookup against the hard settle guarantee so
|
|
691
|
+
// a `credentials.get` that ignores the abort signal can never hang
|
|
692
|
+
// the cold boot (see `settlePromise`).
|
|
693
|
+
credential = await Promise.race([
|
|
694
|
+
credentials.get(buildCredentialOptions(modernMode)),
|
|
695
|
+
settlePromise,
|
|
696
|
+
]);
|
|
629
697
|
} catch (modeError) {
|
|
630
698
|
// Chrome 125–131 only knows the legacy 'button'/'widget' enum and
|
|
631
699
|
// throws a synchronous TypeError for the modern 'active'/'passive'
|
|
@@ -633,7 +701,10 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
633
701
|
if (modernMode && isUnknownModeEnumError(modeError)) {
|
|
634
702
|
const legacyMode = MODERN_TO_LEGACY_MODE[modernMode];
|
|
635
703
|
debug.log(`Browser rejected modern mode '${modernMode}'; retrying with legacy mode '${legacyMode}'`);
|
|
636
|
-
credential = await
|
|
704
|
+
credential = await Promise.race([
|
|
705
|
+
credentials.get(buildCredentialOptions(legacyMode)),
|
|
706
|
+
settlePromise,
|
|
707
|
+
]);
|
|
637
708
|
} else {
|
|
638
709
|
throw modeError;
|
|
639
710
|
}
|
|
@@ -660,6 +731,9 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
660
731
|
throw error;
|
|
661
732
|
} finally {
|
|
662
733
|
clearTimeout(timeout);
|
|
734
|
+
if (settleTimer !== undefined) {
|
|
735
|
+
clearTimeout(settleTimer);
|
|
736
|
+
}
|
|
663
737
|
// Only reset the shared lock if it still belongs to THIS request. When an
|
|
664
738
|
// interactive request aborts a slow silent one, the silent settles (and
|
|
665
739
|
// runs this `finally`) AFTER the interactive has already taken over the
|
|
@@ -522,8 +522,25 @@ export function OxyServicesPopupAuthMixin<T extends typeof OxyServicesBase>(Base
|
|
|
522
522
|
resolve(session || null);
|
|
523
523
|
};
|
|
524
524
|
|
|
525
|
+
// Fail-fast on a load failure. When the per-apex `/auth/silent` host is
|
|
526
|
+
// unreachable, blocked by CSP `frame-ancestors`/`X-Frame-Options`, or the
|
|
527
|
+
// network drops, the iframe never posts a message — without this handler
|
|
528
|
+
// the silent restore would block for the FULL `timeout` (dead latency in
|
|
529
|
+
// the cold-boot critical path). `onerror`/`onabort` fire on a failed load,
|
|
530
|
+
// so resolve `null` immediately and let the next cold-boot step run. The
|
|
531
|
+
// success path posts a message and is handled above; these only catch the
|
|
532
|
+
// no-message failure modes.
|
|
533
|
+
const failFast = () => {
|
|
534
|
+
cleanup();
|
|
535
|
+
resolve(null);
|
|
536
|
+
};
|
|
537
|
+
iframe.onerror = failFast;
|
|
538
|
+
iframe.onabort = failFast;
|
|
539
|
+
|
|
525
540
|
const cleanup = () => {
|
|
526
541
|
clearTimeout(timeoutId);
|
|
542
|
+
iframe.onerror = null;
|
|
543
|
+
iframe.onabort = null;
|
|
527
544
|
window.removeEventListener('message', messageHandler);
|
|
528
545
|
};
|
|
529
546
|
|
|
@@ -82,6 +82,11 @@ describe('OxyServices FedCM nonce binding', () => {
|
|
|
82
82
|
});
|
|
83
83
|
|
|
84
84
|
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
85
|
+
// Silent mediation now fast-skips when there is NO stored login hint (a
|
|
86
|
+
// browser with no prior FedCM association can never get a silent credential,
|
|
87
|
+
// so the nonce mint + credential request would be pure latency). Seed a hint
|
|
88
|
+
// so the silent round-trip actually runs and we can assert the nonce binding.
|
|
89
|
+
localStorage.setItem('oxy_fedcm_login_hint', 'prior-user-id');
|
|
85
90
|
const makeRequest = jest
|
|
86
91
|
.spyOn(oxy, 'makeRequest')
|
|
87
92
|
.mockImplementation(async (_method: string, url: string) => {
|
|
@@ -147,6 +152,9 @@ describe('OxyServices FedCM nonce binding', () => {
|
|
|
147
152
|
});
|
|
148
153
|
|
|
149
154
|
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
155
|
+
// Seed a login hint so the silent path runs past the no-hint fast-skip and
|
|
156
|
+
// exercises the nonce-mint fallback.
|
|
157
|
+
localStorage.setItem('oxy_fedcm_login_hint', 'prior-user-id');
|
|
150
158
|
jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
|
|
151
159
|
if (url === '/fedcm/nonce') {
|
|
152
160
|
throw new Error('network down');
|
|
@@ -184,6 +192,58 @@ describe('OxyServices FedCM nonce binding', () => {
|
|
|
184
192
|
|
|
185
193
|
await expect(oxy.silentSignInWithFedCM()).resolves.toBeNull();
|
|
186
194
|
});
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Production-hang regression. `navigator.credentials.get()` is a
|
|
198
|
+
* browser-internal FedCM primitive that, in some Chrome states, IGNORES its
|
|
199
|
+
* abort signal — the awaited promise never settles. The cooperative
|
|
200
|
+
* `setTimeout`→`controller.abort()` alone cannot unblock that await, so the
|
|
201
|
+
* `fedcm-silent` cold-boot step (and the whole cold boot) hangs forever and
|
|
202
|
+
* the terminal `/sso` bounce never fires.
|
|
203
|
+
*
|
|
204
|
+
* The hard settle guarantee (`Promise.race` against a timer that resolves
|
|
205
|
+
* `null` at `FEDCM_SILENT_TIMEOUT + FEDCM_ABORT_SETTLE_GRACE_MS`) must resolve
|
|
206
|
+
* the request to `null` regardless. This test models the hung primitive and
|
|
207
|
+
* asserts `silentSignInWithFedCM()` settles to `null` within that bound.
|
|
208
|
+
*/
|
|
209
|
+
it('hard-settles to null when navigator.credentials.get never settles (ignores abort)', async () => {
|
|
210
|
+
jest.useFakeTimers();
|
|
211
|
+
try {
|
|
212
|
+
installBrowserGlobals({
|
|
213
|
+
// Never resolves or rejects, and never observes the abort signal —
|
|
214
|
+
// models the production hung FedCM credential request.
|
|
215
|
+
credentialsGet: () => new Promise<unknown>(() => {}),
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
219
|
+
localStorage.setItem('oxy_fedcm_login_hint', 'prior-user-id');
|
|
220
|
+
jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
|
|
221
|
+
if (url === '/fedcm/nonce') {
|
|
222
|
+
return { nonce: 'n', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
|
|
223
|
+
}
|
|
224
|
+
throw new Error(`unexpected request to ${url}`);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
let settled = false;
|
|
228
|
+
const promise = oxy.silentSignInWithFedCM().then((r) => {
|
|
229
|
+
settled = true;
|
|
230
|
+
return r;
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// Before the hard-settle deadline it must still be pending — proving the
|
|
234
|
+
// primitive really is hung (so the test would fail without the fix).
|
|
235
|
+
await jest.advanceTimersByTimeAsync(4000);
|
|
236
|
+
expect(settled).toBe(false);
|
|
237
|
+
|
|
238
|
+
// FEDCM_SILENT_TIMEOUT (4000) + FEDCM_ABORT_SETTLE_GRACE_MS (500) = 4500.
|
|
239
|
+
await jest.advanceTimersByTimeAsync(600);
|
|
240
|
+
await expect(promise).resolves.toBeNull();
|
|
241
|
+
expect(settled).toBe(true);
|
|
242
|
+
} finally {
|
|
243
|
+
jest.runOnlyPendingTimers();
|
|
244
|
+
jest.useRealTimers();
|
|
245
|
+
}
|
|
246
|
+
});
|
|
187
247
|
});
|
|
188
248
|
|
|
189
249
|
/**
|
|
@@ -306,6 +366,9 @@ describe('OxyServices FedCM mode enum (active/passive)', () => {
|
|
|
306
366
|
});
|
|
307
367
|
|
|
308
368
|
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
369
|
+
// Seed a login hint so the silent path runs past the no-hint fast-skip and
|
|
370
|
+
// reaches `navigator.credentials.get` (where the mode/mediation are set).
|
|
371
|
+
localStorage.setItem('oxy_fedcm_login_hint', 'prior-user-id');
|
|
309
372
|
jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
|
|
310
373
|
if (url === '/fedcm/nonce') {
|
|
311
374
|
return { nonce: 'n', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
|
|
@@ -463,6 +526,10 @@ describe('OxyServices FedCM single-flight lock (interactive aborts silent)', ()
|
|
|
463
526
|
let interactiveRan = false;
|
|
464
527
|
|
|
465
528
|
const store = new Map<string, string>();
|
|
529
|
+
// Seed a stored login hint so the silent request runs past the no-hint
|
|
530
|
+
// fast-skip and actually reaches `navigator.credentials.get` (where it then
|
|
531
|
+
// hangs, which is what this test needs to observe being aborted).
|
|
532
|
+
store.set('oxy_fedcm_login_hint', 'prior-user-id');
|
|
466
533
|
const localStorageStub = {
|
|
467
534
|
getItem: (k: string) => (store.has(k) ? (store.get(k) as string) : null),
|
|
468
535
|
setItem: (k: string, v: string) => { store.set(k, v); },
|
|
@@ -548,7 +615,53 @@ describe('OxyServices FedCM single-flight lock (interactive aborts silent)', ()
|
|
|
548
615
|
});
|
|
549
616
|
|
|
550
617
|
describe('OxyServices FedCM silent timeout', () => {
|
|
551
|
-
it('uses a
|
|
552
|
-
|
|
618
|
+
it('uses a 4s silent timeout (above the >3s live round-trip, tight enough to bound cold-boot latency)', () => {
|
|
619
|
+
// 4s keeps the >3s measured live success margin while bounding the dead
|
|
620
|
+
// wait on a logged-out browser. Lowered from 10s, which alone could account
|
|
621
|
+
// for most of a 20-30s serial cold-boot stall. Must never drop below 4s.
|
|
622
|
+
expect(OxyServices.FEDCM_SILENT_TIMEOUT).toBe(4000);
|
|
623
|
+
expect(OxyServices.FEDCM_SILENT_TIMEOUT).toBeGreaterThanOrEqual(4000);
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Silent FedCM no-login-hint fast-skip regression test.
|
|
629
|
+
*
|
|
630
|
+
* A browser that has never completed a FedCM sign-in for any Oxy account has no
|
|
631
|
+
* stored login hint, so silent mediation can never return a credential — the
|
|
632
|
+
* IdP has nothing to silently re-issue. Doing the full round-trip anyway (mint a
|
|
633
|
+
* nonce, then a `navigator.credentials.get` that aborts after the silent
|
|
634
|
+
* timeout) is pure latency in the cold-boot critical path. The silent path must
|
|
635
|
+
* return `null` immediately WITHOUT minting a nonce or calling
|
|
636
|
+
* `navigator.credentials.get`.
|
|
637
|
+
*/
|
|
638
|
+
describe('OxyServices FedCM silent fast-skip with no login hint', () => {
|
|
639
|
+
afterEach(() => {
|
|
640
|
+
clearBrowserGlobals();
|
|
641
|
+
jest.restoreAllMocks();
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it('returns null immediately without minting a nonce or calling credentials.get when no hint is stored', async () => {
|
|
645
|
+
let credentialsGetCalled = false;
|
|
646
|
+
installBrowserGlobals({
|
|
647
|
+
credentialsGet: async () => {
|
|
648
|
+
credentialsGetCalled = true;
|
|
649
|
+
return null;
|
|
650
|
+
},
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
654
|
+
// No stored login hint (fresh empty localStorage) → fast-skip.
|
|
655
|
+
const makeRequest = jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
|
|
656
|
+
throw new Error(`unexpected request to ${url}`);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
const result = await oxy.silentSignInWithFedCM();
|
|
660
|
+
|
|
661
|
+
expect(result).toBeNull();
|
|
662
|
+
// The nonce mint was NOT attempted and the browser credential UI was never
|
|
663
|
+
// touched — the round-trip was skipped entirely.
|
|
664
|
+
expect(makeRequest).not.toHaveBeenCalled();
|
|
665
|
+
expect(credentialsGetCalled).toBe(false);
|
|
553
666
|
});
|
|
554
667
|
});
|