@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
package/dist/esm/AuthManager.js
CHANGED
|
@@ -729,9 +729,10 @@ export class AuthManager {
|
|
|
729
729
|
* Returns the active user on success, or `null` when neither path
|
|
730
730
|
* restored a session.
|
|
731
731
|
*/
|
|
732
|
-
async initialize() {
|
|
733
|
-
// 1. Cookie path (preferred).
|
|
734
|
-
|
|
732
|
+
async initialize(options = {}) {
|
|
733
|
+
// 1. Cookie path (preferred). Forward the optional cold-boot fail-fast
|
|
734
|
+
// timeout so a cross-domain stall cannot hang provider init.
|
|
735
|
+
const cookieResult = await this.restoreFromCookies(options);
|
|
735
736
|
if (cookieResult.accounts.length > 0) {
|
|
736
737
|
return this.currentUser;
|
|
737
738
|
}
|
|
@@ -948,7 +949,7 @@ export class AuthManager {
|
|
|
948
949
|
* proceed unauthenticated. State is NOT cleared on failure; existing
|
|
949
950
|
* accounts (if any) remain intact.
|
|
950
951
|
*/
|
|
951
|
-
async restoreFromCookies() {
|
|
952
|
+
async restoreFromCookies(options = {}) {
|
|
952
953
|
// Cross-tab cascade debounce. If we restored within the last
|
|
953
954
|
// _RESTORE_DEBOUNCE_MS for the currently-active slot, skip the network
|
|
954
955
|
// round-trip and return the cached registry verbatim. A burst of N
|
|
@@ -966,7 +967,9 @@ export class AuthManager {
|
|
|
966
967
|
}
|
|
967
968
|
let snapshot;
|
|
968
969
|
try {
|
|
969
|
-
|
|
970
|
+
// Forward the optional cold-boot fail-fast timeout. Undefined (the warm
|
|
971
|
+
// cross-tab cascade default) preserves the wait-indefinitely behaviour.
|
|
972
|
+
snapshot = await this.oxyServices.refreshAllSessions({ timeout: options.timeout });
|
|
970
973
|
}
|
|
971
974
|
catch {
|
|
972
975
|
return { accounts: [], activeAuthuser: null };
|
|
@@ -449,19 +449,42 @@ export function OxyServicesAuthMixin(Base) {
|
|
|
449
449
|
* tokens do. Each access token still needs to be planted via
|
|
450
450
|
* `setTokens(...)` (or per-account in-memory storage) at the consumer.
|
|
451
451
|
*/
|
|
452
|
-
async refreshAllSessions() {
|
|
452
|
+
async refreshAllSessions(options = {}) {
|
|
453
453
|
const url = `${this.getSessionBaseUrl().replace(/\/$/, '')}/auth/refresh-all`;
|
|
454
|
+
// Optional bounded abort (see `RefreshAllOptions.timeout`). A positive
|
|
455
|
+
// timeout arms an `AbortController` that aborts the in-flight request; an
|
|
456
|
+
// abort is treated as "no signed-in accounts on this device" — the same
|
|
457
|
+
// outcome as a 401 — so a cross-domain stall falls through cleanly instead
|
|
458
|
+
// of hanging the cold boot.
|
|
459
|
+
const timeout = typeof options.timeout === 'number' && options.timeout > 0 ? options.timeout : undefined;
|
|
460
|
+
const controller = timeout !== undefined ? new AbortController() : undefined;
|
|
461
|
+
const timeoutId = timeout !== undefined && controller
|
|
462
|
+
? setTimeout(() => controller.abort(), timeout)
|
|
463
|
+
: undefined;
|
|
454
464
|
let response;
|
|
455
465
|
try {
|
|
456
466
|
response = await fetch(url, {
|
|
457
467
|
method: 'POST',
|
|
458
468
|
credentials: 'include',
|
|
459
469
|
headers: { Accept: 'application/json' },
|
|
470
|
+
signal: controller?.signal,
|
|
460
471
|
});
|
|
461
472
|
}
|
|
462
473
|
catch (error) {
|
|
474
|
+
// A bounded-timeout abort is the "not signed in / cross-domain stall"
|
|
475
|
+
// path, NOT an error. The browser raises a DOMException named
|
|
476
|
+
// 'AbortError' (some runtimes use a generic Error); match on the name so
|
|
477
|
+
// we never throw the timeout into the cold-boot error handler.
|
|
478
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
479
|
+
return { accounts: [] };
|
|
480
|
+
}
|
|
463
481
|
throw this.handleError(error);
|
|
464
482
|
}
|
|
483
|
+
finally {
|
|
484
|
+
if (timeoutId !== undefined) {
|
|
485
|
+
clearTimeout(timeoutId);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
465
488
|
if (response.status === 401) {
|
|
466
489
|
return { accounts: [] };
|
|
467
490
|
}
|
|
@@ -290,6 +290,20 @@ export function OxyServicesFedCMMixin(Base) {
|
|
|
290
290
|
// Optional/interactive mediation should only happen when the user clicks "Sign In".
|
|
291
291
|
let credential = null;
|
|
292
292
|
const loginHint = this.getStoredLoginHint();
|
|
293
|
+
// Fast-skip: with no stored login hint this browser has never completed a
|
|
294
|
+
// FedCM sign-in for any Oxy account, so silent mediation cannot return a
|
|
295
|
+
// credential — the IdP has nothing to silently re-issue. Doing the full
|
|
296
|
+
// round-trip anyway (mint a nonce via `POST /fedcm/nonce`, then a
|
|
297
|
+
// `navigator.credentials.get` that aborts after `FEDCM_SILENT_TIMEOUT`) is
|
|
298
|
+
// pure latency in the cold-boot critical path. Return `null` immediately so
|
|
299
|
+
// the next cold-boot step (stored-session / iframe / bounce) runs without
|
|
300
|
+
// the wasted nonce mint + abort wait. A genuinely associated browser always
|
|
301
|
+
// has a hint (it is stored only after a real exchange), so this never skips
|
|
302
|
+
// a recoverable session.
|
|
303
|
+
if (!loginHint) {
|
|
304
|
+
debug.log('Silent SSO: No stored login hint — skipping silent mediation (no association on this browser)');
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
293
307
|
try {
|
|
294
308
|
// Server-minted, origin-bound nonce — required for `/fedcm/exchange`
|
|
295
309
|
// to accept the resulting ID token (anti-replay binding).
|
|
@@ -737,12 +751,17 @@ export function OxyServicesFedCMMixin(Base) {
|
|
|
737
751
|
_a.DEFAULT_CONFIG_URL = 'https://auth.oxy.so/fedcm.json',
|
|
738
752
|
_a.FEDCM_TIMEOUT = 15000 // 15 seconds for interactive
|
|
739
753
|
,
|
|
740
|
-
// Silent mediation runs on page load
|
|
741
|
-
//
|
|
742
|
-
// round-trip
|
|
743
|
-
//
|
|
744
|
-
//
|
|
745
|
-
|
|
754
|
+
// Silent mediation runs on page load as ONE step of the ordered cold-boot
|
|
755
|
+
// sequence (mint nonce → navigator.credentials.get → /fedcm/exchange). The
|
|
756
|
+
// real round-trip was measured at >3s for live users, so the budget must stay
|
|
757
|
+
// comfortably above 3s. It must ALSO be tight: on a logged-out browser this
|
|
758
|
+
// step never resolves a credential, and every millisecond it spends timing
|
|
759
|
+
// out is pure latency in front of the steps that actually hold the answer
|
|
760
|
+
// (stored-session bearer, the per-apex silent iframe, the /sso bounce). 4s is
|
|
761
|
+
// the floor that preserves the >3s success margin while bounding the dead
|
|
762
|
+
// wait — down from the previous 10s, which alone could account for most of a
|
|
763
|
+
// 20-30s cold-boot stall. Do NOT lower below 4s (it would clip live success).
|
|
764
|
+
_a.FEDCM_SILENT_TIMEOUT = 4000 // 4 seconds for silent mediation
|
|
746
765
|
,
|
|
747
766
|
_a;
|
|
748
767
|
}
|
|
@@ -407,8 +407,24 @@ export function OxyServicesPopupAuthMixin(Base) {
|
|
|
407
407
|
cleanup();
|
|
408
408
|
resolve(session || null);
|
|
409
409
|
};
|
|
410
|
+
// Fail-fast on a load failure. When the per-apex `/auth/silent` host is
|
|
411
|
+
// unreachable, blocked by CSP `frame-ancestors`/`X-Frame-Options`, or the
|
|
412
|
+
// network drops, the iframe never posts a message — without this handler
|
|
413
|
+
// the silent restore would block for the FULL `timeout` (dead latency in
|
|
414
|
+
// the cold-boot critical path). `onerror`/`onabort` fire on a failed load,
|
|
415
|
+
// so resolve `null` immediately and let the next cold-boot step run. The
|
|
416
|
+
// success path posts a message and is handled above; these only catch the
|
|
417
|
+
// no-message failure modes.
|
|
418
|
+
const failFast = () => {
|
|
419
|
+
cleanup();
|
|
420
|
+
resolve(null);
|
|
421
|
+
};
|
|
422
|
+
iframe.onerror = failFast;
|
|
423
|
+
iframe.onabort = failFast;
|
|
410
424
|
const cleanup = () => {
|
|
411
425
|
clearTimeout(timeoutId);
|
|
426
|
+
iframe.onerror = null;
|
|
427
|
+
iframe.onabort = null;
|
|
412
428
|
window.removeEventListener('message', messageHandler);
|
|
413
429
|
};
|
|
414
430
|
window.addEventListener('message', messageHandler);
|
|
@@ -90,10 +90,16 @@ export function parseSsoReturnFragment(hash) {
|
|
|
90
90
|
* outcome-independent attempted-flag (the load2 half of the loop proof).
|
|
91
91
|
* - A throwing exchange is caught, reported via `onExchangeError`, and
|
|
92
92
|
* treated exactly like "no session" (never loops, never rethrows).
|
|
93
|
-
* -
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* DEST key is
|
|
93
|
+
* - On EVERY consumed outcome (ok, none, error, state-mismatch, no-code,
|
|
94
|
+
* failed-exchange, no-sessionId) — not just ok — if the page landed on
|
|
95
|
+
* {@link SSO_CALLBACK_PATH}, the real pre-bounce destination is restored
|
|
96
|
+
* from the DEST key so the user is never stranded on the internal callback
|
|
97
|
+
* path. Same-origin only (an attacker-planted cross-origin or relative-evil
|
|
98
|
+
* dest is rejected). The DEST key is removed unconditionally.
|
|
99
|
+
* - After a same-origin dest restore (which uses `history.replaceState`, that
|
|
100
|
+
* does NOT itself emit `popstate`), a synthetic `popstate` is dispatched so
|
|
101
|
+
* URL-driven routers (Expo Router / React Navigation web) re-sync to the
|
|
102
|
+
* restored route. It is NOT dispatched when the dest is rejected/absent.
|
|
97
103
|
*
|
|
98
104
|
* Total: this function NEVER throws. Off-web it is a no-op returning `null`.
|
|
99
105
|
*
|
|
@@ -112,6 +118,21 @@ export async function consumeSsoReturn(oxy, deps = {}) {
|
|
|
112
118
|
const location = deps.location ?? window.location;
|
|
113
119
|
const history = deps.history ?? window.history;
|
|
114
120
|
const onExchangeError = deps.onExchangeError;
|
|
121
|
+
// Default: emit a synthetic `popstate` so URL-driven routers re-sync after a
|
|
122
|
+
// `history.replaceState` (which does NOT emit `popstate` on its own). Feature-
|
|
123
|
+
// detected end to end so it never throws in any environment.
|
|
124
|
+
const dispatchPopState = deps.dispatchPopState ??
|
|
125
|
+
(() => {
|
|
126
|
+
if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function') {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (typeof PopStateEvent !== 'undefined') {
|
|
130
|
+
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
131
|
+
}
|
|
132
|
+
else if (typeof Event !== 'undefined') {
|
|
133
|
+
window.dispatchEvent(new Event('popstate'));
|
|
134
|
+
}
|
|
135
|
+
});
|
|
115
136
|
const ret = parseSsoReturnFragment(location.hash);
|
|
116
137
|
if (!ret) {
|
|
117
138
|
// Not an oxy_sso fragment — nothing to do (do NOT touch any flags).
|
|
@@ -134,16 +155,42 @@ export async function consumeSsoReturn(oxy, deps = {}) {
|
|
|
134
155
|
// even if some consumer path skipped setting it pre-bounce.
|
|
135
156
|
storage.setItem(ssoAttemptedKey(origin), '1');
|
|
136
157
|
};
|
|
158
|
+
// Restore the user's real pre-bounce destination so they are never stranded
|
|
159
|
+
// on the internal callback path — invoked on EVERY consumed outcome, not just
|
|
160
|
+
// success. Same-origin only — never honour a cross-origin/protocol-relative
|
|
161
|
+
// dest that could have been planted to redirect the user. The DEST key is
|
|
162
|
+
// removed unconditionally. After a successful same-origin restore a synthetic
|
|
163
|
+
// `popstate` is dispatched so URL-driven routers re-sync.
|
|
164
|
+
const restoreDest = () => {
|
|
165
|
+
if (location.pathname === SSO_CALLBACK_PATH) {
|
|
166
|
+
const dest = storage.getItem(ssoDestKey(origin));
|
|
167
|
+
if (dest) {
|
|
168
|
+
try {
|
|
169
|
+
const destUrl = new URL(dest, origin);
|
|
170
|
+
if (destUrl.origin === origin) {
|
|
171
|
+
history.replaceState(null, '', destUrl.pathname + destUrl.search + destUrl.hash);
|
|
172
|
+
dispatchPopState();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// Malformed stored destination — leave the URL on the callback path.
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
storage.removeItem(ssoDestKey(origin));
|
|
181
|
+
};
|
|
137
182
|
if (ret.kind === 'none' || ret.kind === 'error') {
|
|
138
183
|
// The central IdP had no session (or the bounce failed). Record it so we do
|
|
139
184
|
// not bounce again this tab — the definitive loop breaker.
|
|
140
185
|
markNoSession();
|
|
186
|
+
restoreDest();
|
|
141
187
|
return null;
|
|
142
188
|
}
|
|
143
189
|
if (!stateOk || !ret.code) {
|
|
144
190
|
// Forged / replayed / stale fragment, or a malformed ok with no code. Treat
|
|
145
191
|
// exactly like "no session": never exchange, never loop.
|
|
146
192
|
markNoSession();
|
|
193
|
+
restoreDest();
|
|
147
194
|
return null;
|
|
148
195
|
}
|
|
149
196
|
let session;
|
|
@@ -153,31 +200,14 @@ export async function consumeSsoReturn(oxy, deps = {}) {
|
|
|
153
200
|
catch (error) {
|
|
154
201
|
onExchangeError?.(error);
|
|
155
202
|
markNoSession();
|
|
203
|
+
restoreDest();
|
|
156
204
|
return null;
|
|
157
205
|
}
|
|
158
206
|
if (!session?.sessionId) {
|
|
159
207
|
markNoSession();
|
|
208
|
+
restoreDest();
|
|
160
209
|
return null;
|
|
161
210
|
}
|
|
162
|
-
|
|
163
|
-
// destination (captured at bounce time). Same-origin only — never honour a
|
|
164
|
-
// cross-origin destination that could have been planted to redirect the
|
|
165
|
-
// freshly signed-in user. `new URL(dest, origin)` tolerates relative dests
|
|
166
|
-
// and is still re-checked against the page origin.
|
|
167
|
-
if (location.pathname === SSO_CALLBACK_PATH) {
|
|
168
|
-
const dest = storage.getItem(ssoDestKey(origin));
|
|
169
|
-
if (dest) {
|
|
170
|
-
try {
|
|
171
|
-
const destUrl = new URL(dest, origin);
|
|
172
|
-
if (destUrl.origin === origin) {
|
|
173
|
-
history.replaceState(null, '', destUrl.pathname + destUrl.search + destUrl.hash);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
catch {
|
|
177
|
-
// Malformed stored destination — leave the URL on the callback path.
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
storage.removeItem(ssoDestKey(origin));
|
|
211
|
+
restoreDest();
|
|
182
212
|
return session;
|
|
183
213
|
}
|