@oxyhq/core 2.3.1 → 2.3.2

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.
@@ -94,10 +94,16 @@ function parseSsoReturnFragment(hash) {
94
94
  * outcome-independent attempted-flag (the load2 half of the loop proof).
95
95
  * - A throwing exchange is caught, reported via `onExchangeError`, and
96
96
  * treated exactly like "no session" (never loops, never rethrows).
97
- * - After a successful exchange landing on {@link SSO_CALLBACK_PATH}, the real
98
- * destination is restored from the DEST key same-origin only (an
99
- * attacker-planted cross-origin or relative-evil dest is rejected). The
100
- * DEST key is removed unconditionally.
97
+ * - On EVERY consumed outcome (ok, none, error, state-mismatch, no-code,
98
+ * failed-exchange, no-sessionId) not just okif the page landed on
99
+ * {@link SSO_CALLBACK_PATH}, the real pre-bounce destination is restored
100
+ * from the DEST key so the user is never stranded on the internal callback
101
+ * path. Same-origin only (an attacker-planted cross-origin or relative-evil
102
+ * dest is rejected). The DEST key is removed unconditionally.
103
+ * - After a same-origin dest restore (which uses `history.replaceState`, that
104
+ * does NOT itself emit `popstate`), a synthetic `popstate` is dispatched so
105
+ * URL-driven routers (Expo Router / React Navigation web) re-sync to the
106
+ * restored route. It is NOT dispatched when the dest is rejected/absent.
101
107
  *
102
108
  * Total: this function NEVER throws. Off-web it is a no-op returning `null`.
103
109
  *
@@ -116,6 +122,21 @@ async function consumeSsoReturn(oxy, deps = {}) {
116
122
  const location = deps.location ?? window.location;
117
123
  const history = deps.history ?? window.history;
118
124
  const onExchangeError = deps.onExchangeError;
125
+ // Default: emit a synthetic `popstate` so URL-driven routers re-sync after a
126
+ // `history.replaceState` (which does NOT emit `popstate` on its own). Feature-
127
+ // detected end to end so it never throws in any environment.
128
+ const dispatchPopState = deps.dispatchPopState ??
129
+ (() => {
130
+ if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function') {
131
+ return;
132
+ }
133
+ if (typeof PopStateEvent !== 'undefined') {
134
+ window.dispatchEvent(new PopStateEvent('popstate'));
135
+ }
136
+ else if (typeof Event !== 'undefined') {
137
+ window.dispatchEvent(new Event('popstate'));
138
+ }
139
+ });
119
140
  const ret = parseSsoReturnFragment(location.hash);
120
141
  if (!ret) {
121
142
  // Not an oxy_sso fragment — nothing to do (do NOT touch any flags).
@@ -138,16 +159,42 @@ async function consumeSsoReturn(oxy, deps = {}) {
138
159
  // even if some consumer path skipped setting it pre-bounce.
139
160
  storage.setItem((0, ssoBounce_1.ssoAttemptedKey)(origin), '1');
140
161
  };
162
+ // Restore the user's real pre-bounce destination so they are never stranded
163
+ // on the internal callback path — invoked on EVERY consumed outcome, not just
164
+ // success. Same-origin only — never honour a cross-origin/protocol-relative
165
+ // dest that could have been planted to redirect the user. The DEST key is
166
+ // removed unconditionally. After a successful same-origin restore a synthetic
167
+ // `popstate` is dispatched so URL-driven routers re-sync.
168
+ const restoreDest = () => {
169
+ if (location.pathname === ssoBounce_1.SSO_CALLBACK_PATH) {
170
+ const dest = storage.getItem((0, ssoBounce_1.ssoDestKey)(origin));
171
+ if (dest) {
172
+ try {
173
+ const destUrl = new URL(dest, origin);
174
+ if (destUrl.origin === origin) {
175
+ history.replaceState(null, '', destUrl.pathname + destUrl.search + destUrl.hash);
176
+ dispatchPopState();
177
+ }
178
+ }
179
+ catch {
180
+ // Malformed stored destination — leave the URL on the callback path.
181
+ }
182
+ }
183
+ }
184
+ storage.removeItem((0, ssoBounce_1.ssoDestKey)(origin));
185
+ };
141
186
  if (ret.kind === 'none' || ret.kind === 'error') {
142
187
  // The central IdP had no session (or the bounce failed). Record it so we do
143
188
  // not bounce again this tab — the definitive loop breaker.
144
189
  markNoSession();
190
+ restoreDest();
145
191
  return null;
146
192
  }
147
193
  if (!stateOk || !ret.code) {
148
194
  // Forged / replayed / stale fragment, or a malformed ok with no code. Treat
149
195
  // exactly like "no session": never exchange, never loop.
150
196
  markNoSession();
197
+ restoreDest();
151
198
  return null;
152
199
  }
153
200
  let session;
@@ -157,31 +204,14 @@ async function consumeSsoReturn(oxy, deps = {}) {
157
204
  catch (error) {
158
205
  onExchangeError?.(error);
159
206
  markNoSession();
207
+ restoreDest();
160
208
  return null;
161
209
  }
162
210
  if (!session?.sessionId) {
163
211
  markNoSession();
212
+ restoreDest();
164
213
  return null;
165
214
  }
166
- // If we landed on the internal callback path, restore the user's real
167
- // destination (captured at bounce time). Same-origin only — never honour a
168
- // cross-origin destination that could have been planted to redirect the
169
- // freshly signed-in user. `new URL(dest, origin)` tolerates relative dests
170
- // and is still re-checked against the page origin.
171
- if (location.pathname === ssoBounce_1.SSO_CALLBACK_PATH) {
172
- const dest = storage.getItem((0, ssoBounce_1.ssoDestKey)(origin));
173
- if (dest) {
174
- try {
175
- const destUrl = new URL(dest, origin);
176
- if (destUrl.origin === origin) {
177
- history.replaceState(null, '', destUrl.pathname + destUrl.search + destUrl.hash);
178
- }
179
- }
180
- catch {
181
- // Malformed stored destination — leave the URL on the callback path.
182
- }
183
- }
184
- }
185
- storage.removeItem((0, ssoBounce_1.ssoDestKey)(origin));
215
+ restoreDest();
186
216
  return session;
187
217
  }