@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.
@@ -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
- * - After a successful exchange landing on {@link SSO_CALLBACK_PATH}, the real
94
- * destination is restored from the DEST key same-origin only (an
95
- * attacker-planted cross-origin or relative-evil dest is rejected). The
96
- * DEST key is removed unconditionally.
93
+ * - On EVERY consumed outcome (ok, none, error, state-mismatch, no-code,
94
+ * failed-exchange, no-sessionId) not just okif 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
- // If we landed on the internal callback path, restore the user's real
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
  }