@oxyhq/auth 2.0.8 → 2.0.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/auth",
3
- "version": "2.0.8",
3
+ "version": "2.0.9",
4
4
  "description": "OxyHQ Web Authentication SDK — headless auth with React hooks for Next.js, Vite, and web apps",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -59,36 +59,20 @@ export interface WebOxyContextValue extends WebAuthState, WebAuthActions {
59
59
  const WebOxyContext = createContext<WebOxyContextValue | null>(null);
60
60
 
61
61
  /**
62
- * Module-level run-once guard for the provider's silent sign-in step.
62
+ * Module-level run-once guard for FedCM silent sign-in.
63
63
  *
64
64
  * The init effect runs again whenever the provider remounts (route change,
65
65
  * StrictMode double-invoke, error-boundary recovery). The redirect-callback
66
- * and local-session-restore steps are cheap and idempotent, but
67
- * `crossDomainAuth.silentSignIn()` is NOT: it first tries FedCM silent
68
- * mediation (`navigator.credentials.get`) and, if that yields nothing, falls
69
- * back to an iframe-based silent auth against `/auth/silent`.
70
- *
71
- * The FedCM leg is now centrally memoized inside `@oxyhq/core`'s
72
- * `silentSignInWithFedCM` (at-most-once `navigator.credentials.get` per page
73
- * load). The iframe fallback, however, is a SEPARATE mechanism the core guard
74
- * does not cover — without this guard a remount storm would create a hidden
75
- * iframe per remount. So this guard is intentionally retained to keep the
76
- * provider's WHOLE silent step run-once. Keyed on `origin + baseURL` to match
77
- * the core guard's keying (so the two stay in lockstep) and to survive
78
- * instance churn; never cleared because only a fresh page load can change the
79
- * IdP/iframe session state.
66
+ * and local-session-restore steps are cheap and idempotent, but the FedCM
67
+ * `silentSignIn()` step triggers `navigator.credentials.get`, which must fire
68
+ * AT MOST ONCE per page load otherwise a remount storm becomes a credential
69
+ * request storm. Keyed by origin so the guard survives instance churn; never
70
+ * cleared because only a fresh page load can change the IdP session state.
80
71
  */
81
- const silentSignInAttempted = new Set<string>();
82
-
83
- function silentSignInKey(oxyServices: OxyServices): string {
84
- const origin = typeof window !== 'undefined' ? window.location.origin : 'no-origin';
85
- let baseURL = '';
86
- try {
87
- baseURL = oxyServices.getBaseURL();
88
- } catch {
89
- baseURL = '';
90
- }
91
- return `${origin}|${baseURL}`;
72
+ const fedcmSilentSignInAttempted = new Set<string>();
73
+
74
+ function silentSignInKey(): string {
75
+ return typeof window !== 'undefined' ? window.location.origin : 'no-origin';
92
76
  }
93
77
 
94
78
  export interface WebOxyProviderProps {
@@ -224,14 +208,12 @@ export function WebOxyProvider({
224
208
  }
225
209
  }
226
210
 
227
- // Silent sign-in: run AT MOST ONCE per page load. A remount (route
228
- // change / StrictMode / error recovery) must not re-trigger the
229
- // browser credential request OR the iframe fallback. The FedCM leg is
230
- // additionally memoized in core; this guard also covers the iframe
231
- // fallback that core does not.
232
- const ssoKey = silentSignInKey(oxyServices);
233
- if (!silentSignInAttempted.has(ssoKey)) {
234
- silentSignInAttempted.add(ssoKey);
211
+ // FedCM silent sign-in: run AT MOST ONCE per page load. A remount
212
+ // (route change / StrictMode / error recovery) must not re-trigger
213
+ // the browser credential request.
214
+ const ssoKey = silentSignInKey();
215
+ if (!fedcmSilentSignInAttempted.has(ssoKey)) {
216
+ fedcmSilentSignInAttempted.add(ssoKey);
235
217
  try {
236
218
  const session = await crossDomainAuth.silentSignIn();
237
219
  if (mounted && session?.user) {
@@ -38,6 +38,36 @@ interface UseWebSSOResult {
38
38
  isFedCMSupported: boolean;
39
39
  }
40
40
 
41
+ /**
42
+ * Module-level guard tracking which (origin + API) signatures have already
43
+ * had a silent SSO attempt this page load.
44
+ *
45
+ * A per-component `useRef` guard resets whenever the provider remounts (route
46
+ * churn, StrictMode double-invoke, error-boundary recovery), which previously
47
+ * allowed silent SSO to re-fire and — combined with a routing redirect loop —
48
+ * produced an accelerating `navigator.credentials.get` retry storm. Keying the
49
+ * guard on a stable signature instead of the component instance makes silent
50
+ * SSO fire EXACTLY ONCE per page load regardless of how many times the
51
+ * provider mounts. The set is intentionally never cleared: a fresh page load
52
+ * (the only thing that can change the answer) starts a fresh module scope.
53
+ */
54
+ const silentSSOAttempted = new Set<string>();
55
+
56
+ /**
57
+ * Build a stable signature for the silent-SSO run-once guard. Two providers
58
+ * pointed at the same API from the same origin share one attempt.
59
+ */
60
+ function ssoSignature(oxyServices: OxyServices): string {
61
+ const origin = typeof window !== 'undefined' ? window.location.origin : 'no-origin';
62
+ let baseURL = '';
63
+ try {
64
+ baseURL = oxyServices.getBaseURL();
65
+ } catch {
66
+ baseURL = '';
67
+ }
68
+ return `${origin}|${baseURL}`;
69
+ }
70
+
41
71
  /**
42
72
  * Check if we're running in a web browser environment (not React Native)
43
73
  */
@@ -160,12 +190,12 @@ export function useWebSSO({
160
190
 
161
191
  // Auto-check SSO on mount (web only, FedCM only, not on auth domain).
162
192
  //
163
- // `hasCheckedRef` is a cheap per-instance fast-path so effect re-runs (from
164
- // changing deps) within one mount never re-fire. The page-load run-once
165
- // guarantee silent SSO invoking `navigator.credentials.get` AT MOST ONCE
166
- // per page load across remounts / StrictMode / multiple consumers — lives in
167
- // `@oxyhq/core`'s `silentSignInWithFedCM`, which memoizes the first silent
168
- // attempt's result for this origin + API. The hook therefore calls it freely.
193
+ // Run-once is enforced by TWO guards:
194
+ // 1. `hasCheckedRef` cheap per-instance fast-path so effect re-runs
195
+ // (from changing deps) within one mount never re-fire.
196
+ // 2. `silentSSOAttempted` module-level, survives remounts/StrictMode so
197
+ // silent SSO fires exactly once per page load even if the provider
198
+ // unmounts and remounts.
169
199
  useEffect(() => {
170
200
  if (!enabled || !isWebBrowser() || hasCheckedRef.current || isIdentityProvider()) {
171
201
  if (isIdentityProvider()) {
@@ -174,14 +204,23 @@ export function useWebSSO({
174
204
  return;
175
205
  }
176
206
 
207
+ const signature = ssoSignature(oxyServices);
208
+ if (silentSSOAttempted.has(signature)) {
209
+ // Already attempted this page load (e.g. before a remount) — do not
210
+ // re-fire. Mark the local fast-path too so subsequent re-renders skip.
211
+ hasCheckedRef.current = true;
212
+ return;
213
+ }
214
+
177
215
  hasCheckedRef.current = true;
216
+ silentSSOAttempted.add(signature);
178
217
 
179
218
  if (fedCMSupported) {
180
219
  checkSSO();
181
220
  } else {
182
221
  onSSOUnavailable?.();
183
222
  }
184
- }, [enabled, checkSSO, fedCMSupported, onSSOUnavailable]);
223
+ }, [enabled, checkSSO, fedCMSupported, onSSOUnavailable, oxyServices]);
185
224
 
186
225
  return {
187
226
  checkSSO,