@oxyhq/core 2.3.1 → 2.4.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/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 +25 -6
- package/dist/cjs/mixins/OxyServices.popup.js +16 -0
- package/dist/cjs/utils/ssoReturn.js +54 -24
- 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 +25 -6
- package/dist/esm/mixins/OxyServices.popup.js +16 -0
- package/dist/esm/utils/ssoReturn.js +54 -24
- 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 +1 -1
- package/dist/types/utils/ssoReturn.d.ts +18 -4
- 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 +26 -6
- package/src/mixins/OxyServices.popup.ts +17 -0
- package/src/mixins/__tests__/fedcm.test.ts +63 -2
- package/src/mixins/__tests__/popup.test.ts +67 -0
- package/src/utils/__tests__/consumeSsoReturn.test.ts +196 -3
- package/src/utils/ssoReturn.ts +67 -27
|
@@ -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,7 @@ 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
308
|
/**
|
|
309
309
|
* Check if FedCM is supported in the current browser
|
|
310
310
|
*/
|
|
@@ -72,6 +72,14 @@ export interface ConsumeSsoReturnDeps {
|
|
|
72
72
|
* fails. NEVER rethrown — `consumeSsoReturn` is total. Default: no-op.
|
|
73
73
|
*/
|
|
74
74
|
onExchangeError?: (error: unknown) => void;
|
|
75
|
+
/**
|
|
76
|
+
* Notify URL-driven routers (Expo Router / React Navigation web) that the
|
|
77
|
+
* location changed via `history.replaceState`, which does NOT itself emit
|
|
78
|
+
* `popstate`. Default: dispatch a real `PopStateEvent` on `window` when
|
|
79
|
+
* present; no-op off-web. Called ONLY after a successful same-origin
|
|
80
|
+
* dest restore (never when the dest is rejected/absent). NEVER throws.
|
|
81
|
+
*/
|
|
82
|
+
dispatchPopState?: () => void;
|
|
75
83
|
}
|
|
76
84
|
/**
|
|
77
85
|
* Consume an SSO return: the commit-free, security-critical kernel of the
|
|
@@ -95,10 +103,16 @@ export interface ConsumeSsoReturnDeps {
|
|
|
95
103
|
* outcome-independent attempted-flag (the load2 half of the loop proof).
|
|
96
104
|
* - A throwing exchange is caught, reported via `onExchangeError`, and
|
|
97
105
|
* treated exactly like "no session" (never loops, never rethrows).
|
|
98
|
-
* -
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* DEST key is
|
|
106
|
+
* - On EVERY consumed outcome (ok, none, error, state-mismatch, no-code,
|
|
107
|
+
* failed-exchange, no-sessionId) — not just ok — if the page landed on
|
|
108
|
+
* {@link SSO_CALLBACK_PATH}, the real pre-bounce destination is restored
|
|
109
|
+
* from the DEST key so the user is never stranded on the internal callback
|
|
110
|
+
* path. Same-origin only (an attacker-planted cross-origin or relative-evil
|
|
111
|
+
* dest is rejected). The DEST key is removed unconditionally.
|
|
112
|
+
* - After a same-origin dest restore (which uses `history.replaceState`, that
|
|
113
|
+
* does NOT itself emit `popstate`), a synthetic `popstate` is dispatched so
|
|
114
|
+
* URL-driven routers (Expo Router / React Navigation web) re-sync to the
|
|
115
|
+
* restored route. It is NOT dispatched when the dest is rejected/absent.
|
|
102
116
|
*
|
|
103
117
|
* Total: this function NEVER throws. Off-web it is a no-op returning `null`.
|
|
104
118
|
*
|
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,17 @@ 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
|
|
211
216
|
|
|
212
217
|
/**
|
|
213
218
|
* Check if FedCM is supported in the current browser
|
|
@@ -419,6 +424,21 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
419
424
|
|
|
420
425
|
const loginHint = this.getStoredLoginHint();
|
|
421
426
|
|
|
427
|
+
// Fast-skip: with no stored login hint this browser has never completed a
|
|
428
|
+
// FedCM sign-in for any Oxy account, so silent mediation cannot return a
|
|
429
|
+
// credential — the IdP has nothing to silently re-issue. Doing the full
|
|
430
|
+
// round-trip anyway (mint a nonce via `POST /fedcm/nonce`, then a
|
|
431
|
+
// `navigator.credentials.get` that aborts after `FEDCM_SILENT_TIMEOUT`) is
|
|
432
|
+
// pure latency in the cold-boot critical path. Return `null` immediately so
|
|
433
|
+
// the next cold-boot step (stored-session / iframe / bounce) runs without
|
|
434
|
+
// the wasted nonce mint + abort wait. A genuinely associated browser always
|
|
435
|
+
// has a hint (it is stored only after a real exchange), so this never skips
|
|
436
|
+
// a recoverable session.
|
|
437
|
+
if (!loginHint) {
|
|
438
|
+
debug.log('Silent SSO: No stored login hint — skipping silent mediation (no association on this browser)');
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
|
|
422
442
|
try {
|
|
423
443
|
// Server-minted, origin-bound nonce — required for `/fedcm/exchange`
|
|
424
444
|
// to accept the resulting ID token (anti-replay binding).
|
|
@@ -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');
|
|
@@ -306,6 +314,9 @@ describe('OxyServices FedCM mode enum (active/passive)', () => {
|
|
|
306
314
|
});
|
|
307
315
|
|
|
308
316
|
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
317
|
+
// Seed a login hint so the silent path runs past the no-hint fast-skip and
|
|
318
|
+
// reaches `navigator.credentials.get` (where the mode/mediation are set).
|
|
319
|
+
localStorage.setItem('oxy_fedcm_login_hint', 'prior-user-id');
|
|
309
320
|
jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
|
|
310
321
|
if (url === '/fedcm/nonce') {
|
|
311
322
|
return { nonce: 'n', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
|
|
@@ -463,6 +474,10 @@ describe('OxyServices FedCM single-flight lock (interactive aborts silent)', ()
|
|
|
463
474
|
let interactiveRan = false;
|
|
464
475
|
|
|
465
476
|
const store = new Map<string, string>();
|
|
477
|
+
// Seed a stored login hint so the silent request runs past the no-hint
|
|
478
|
+
// fast-skip and actually reaches `navigator.credentials.get` (where it then
|
|
479
|
+
// hangs, which is what this test needs to observe being aborted).
|
|
480
|
+
store.set('oxy_fedcm_login_hint', 'prior-user-id');
|
|
466
481
|
const localStorageStub = {
|
|
467
482
|
getItem: (k: string) => (store.has(k) ? (store.get(k) as string) : null),
|
|
468
483
|
setItem: (k: string, v: string) => { store.set(k, v); },
|
|
@@ -548,7 +563,53 @@ describe('OxyServices FedCM single-flight lock (interactive aborts silent)', ()
|
|
|
548
563
|
});
|
|
549
564
|
|
|
550
565
|
describe('OxyServices FedCM silent timeout', () => {
|
|
551
|
-
it('uses a
|
|
552
|
-
|
|
566
|
+
it('uses a 4s silent timeout (above the >3s live round-trip, tight enough to bound cold-boot latency)', () => {
|
|
567
|
+
// 4s keeps the >3s measured live success margin while bounding the dead
|
|
568
|
+
// wait on a logged-out browser. Lowered from 10s, which alone could account
|
|
569
|
+
// for most of a 20-30s serial cold-boot stall. Must never drop below 4s.
|
|
570
|
+
expect(OxyServices.FEDCM_SILENT_TIMEOUT).toBe(4000);
|
|
571
|
+
expect(OxyServices.FEDCM_SILENT_TIMEOUT).toBeGreaterThanOrEqual(4000);
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Silent FedCM no-login-hint fast-skip regression test.
|
|
577
|
+
*
|
|
578
|
+
* A browser that has never completed a FedCM sign-in for any Oxy account has no
|
|
579
|
+
* stored login hint, so silent mediation can never return a credential — the
|
|
580
|
+
* IdP has nothing to silently re-issue. Doing the full round-trip anyway (mint a
|
|
581
|
+
* nonce, then a `navigator.credentials.get` that aborts after the silent
|
|
582
|
+
* timeout) is pure latency in the cold-boot critical path. The silent path must
|
|
583
|
+
* return `null` immediately WITHOUT minting a nonce or calling
|
|
584
|
+
* `navigator.credentials.get`.
|
|
585
|
+
*/
|
|
586
|
+
describe('OxyServices FedCM silent fast-skip with no login hint', () => {
|
|
587
|
+
afterEach(() => {
|
|
588
|
+
clearBrowserGlobals();
|
|
589
|
+
jest.restoreAllMocks();
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it('returns null immediately without minting a nonce or calling credentials.get when no hint is stored', async () => {
|
|
593
|
+
let credentialsGetCalled = false;
|
|
594
|
+
installBrowserGlobals({
|
|
595
|
+
credentialsGet: async () => {
|
|
596
|
+
credentialsGetCalled = true;
|
|
597
|
+
return null;
|
|
598
|
+
},
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
602
|
+
// No stored login hint (fresh empty localStorage) → fast-skip.
|
|
603
|
+
const makeRequest = jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
|
|
604
|
+
throw new Error(`unexpected request to ${url}`);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
const result = await oxy.silentSignInWithFedCM();
|
|
608
|
+
|
|
609
|
+
expect(result).toBeNull();
|
|
610
|
+
// The nonce mint was NOT attempted and the browser credential UI was never
|
|
611
|
+
// touched — the round-trip was skipped entirely.
|
|
612
|
+
expect(makeRequest).not.toHaveBeenCalled();
|
|
613
|
+
expect(credentialsGetCalled).toBe(false);
|
|
553
614
|
});
|
|
554
615
|
});
|
|
@@ -305,3 +305,70 @@ describe('OxyServices popup mixin — openBlankPopup helper', () => {
|
|
|
305
305
|
expect(oxy.openBlankPopup()).toBeNull();
|
|
306
306
|
});
|
|
307
307
|
});
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* `waitForIframeAuth` fail-fast regression tests.
|
|
311
|
+
*
|
|
312
|
+
* The cross-domain durable-restore iframe (`/auth/silent` at the per-apex host)
|
|
313
|
+
* posts a message on success. On a FAILED load — host unreachable, blocked by
|
|
314
|
+
* CSP `frame-ancestors`/`X-Frame-Options`, or a dropped network — it never
|
|
315
|
+
* posts, so without an `onerror`/`onabort` handler the silent restore would
|
|
316
|
+
* block for the FULL timeout (dead latency in the cold-boot critical path). The
|
|
317
|
+
* handler must resolve `null` immediately on a load failure, well before the
|
|
318
|
+
* timeout fires.
|
|
319
|
+
*/
|
|
320
|
+
interface FakeIframe {
|
|
321
|
+
onerror: ((this: unknown, ...args: unknown[]) => unknown) | null;
|
|
322
|
+
onabort: ((this: unknown, ...args: unknown[]) => unknown) | null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
describe('OxyServices waitForIframeAuth fail-fast on iframe load error', () => {
|
|
326
|
+
afterEach(() => {
|
|
327
|
+
clearBrowserGlobals();
|
|
328
|
+
jest.restoreAllMocks();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('resolves null immediately when the iframe fires onerror (does not wait for the timeout)', async () => {
|
|
332
|
+
installBrowserGlobals();
|
|
333
|
+
|
|
334
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
335
|
+
const iframe: FakeIframe = { onerror: null, onabort: null };
|
|
336
|
+
|
|
337
|
+
// A long timeout proves the resolution comes from `onerror`, not the timer.
|
|
338
|
+
const LONG_TIMEOUT = 100000;
|
|
339
|
+
const settled = oxy.waitForIframeAuth(
|
|
340
|
+
iframe as unknown as HTMLIFrameElement,
|
|
341
|
+
LONG_TIMEOUT,
|
|
342
|
+
'https://auth.mention.earth',
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
// The handler is installed synchronously; fire it on the next tick.
|
|
346
|
+
await Promise.resolve();
|
|
347
|
+
expect(typeof iframe.onerror).toBe('function');
|
|
348
|
+
iframe.onerror?.call(iframe);
|
|
349
|
+
|
|
350
|
+
await expect(settled).resolves.toBeNull();
|
|
351
|
+
// Cleanup detaches the handlers so a late event cannot double-resolve.
|
|
352
|
+
expect(iframe.onerror).toBeNull();
|
|
353
|
+
expect(iframe.onabort).toBeNull();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('resolves null immediately when the iframe fires onabort', async () => {
|
|
357
|
+
installBrowserGlobals();
|
|
358
|
+
|
|
359
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
360
|
+
const iframe: FakeIframe = { onerror: null, onabort: null };
|
|
361
|
+
|
|
362
|
+
const settled = oxy.waitForIframeAuth(
|
|
363
|
+
iframe as unknown as HTMLIFrameElement,
|
|
364
|
+
100000,
|
|
365
|
+
'https://auth.mention.earth',
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
await Promise.resolve();
|
|
369
|
+
expect(typeof iframe.onabort).toBe('function');
|
|
370
|
+
iframe.onabort?.call(iframe);
|
|
371
|
+
|
|
372
|
+
await expect(settled).resolves.toBeNull();
|
|
373
|
+
});
|
|
374
|
+
});
|