@oxyhq/core 2.2.2 → 2.3.1

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.
@@ -77,7 +77,7 @@ export { CENTRAL_AUTH_URL, CENTRAL_IDP_APEX, resolveCentralAuthUrl } from './uti
77
77
  export { parseSsoReturnFragment, consumeSsoReturn } from './utils/ssoReturn';
78
78
  export type { SsoReturnKind, SsoReturnResult, ConsumeSsoReturnDeps } from './utils/ssoReturn';
79
79
  export { generateSsoState } from './mixins/OxyServices.sso';
80
- export { SSO_CALLBACK_PATH, SSO_GUARD_TTL_MS, ssoStateKey, ssoGuardKey, ssoDestKey, ssoNoSessionKey, ssoNavigate, buildSsoBounceUrl, isCentralIdPOrigin, guardActive, } from './utils/ssoBounce';
80
+ export { SSO_CALLBACK_PATH, SSO_GUARD_TTL_MS, ssoStateKey, ssoGuardKey, ssoDestKey, ssoNoSessionKey, ssoAttemptedKey, ssoNavigate, buildSsoBounceUrl, isCentralIdPOrigin, guardActive, } from './utils/ssoBounce';
81
81
  export { runColdBoot } from './utils/coldBoot';
82
82
  export type { ColdBootStep, ColdBootStepResult, ColdBootSession, ColdBootSkip, ColdBootOutcome, RunColdBootOptions, } from './utils/coldBoot';
83
83
  export { packageInfo } from './constants/version';
@@ -26,11 +26,15 @@
26
26
  * the session, then restores the original destination.
27
27
  *
28
28
  * Loop proof (logged-out): first load all steps skip → `sso-bounce` sets
29
- * guard/state/dest and navigates; the IdP (no central session) returns
29
+ * guard/state/dest + the outcome-independent attempted-flag
30
+ * ({@link ssoAttemptedKey}) and navigates; the IdP (no central session) returns
30
31
  * `#oxy_sso=none`; the callback load's `sso-return` sees `none`, sets the
31
32
  * NO_SESSION flag ({@link ssoNoSessionKey}), and `sso-bounce` is then disabled.
32
33
  * Exactly ONE bounce, no loop. An interrupted bounce (user hit back
33
34
  * mid-redirect) self-heals once the {@link SSO_GUARD_TTL_MS} guard TTL lapses.
35
+ * The attempted-flag is the definitive, outcome-INDEPENDENT loop breaker: it is
36
+ * set pre-bounce so even if the return-side NO_SESSION write never lands, the
37
+ * bounce can never re-fire this tab after the self-heal TTL lapses.
34
38
  *
35
39
  * All state lives in `sessionStorage` (per tab, cleared on tab close) and is
36
40
  * keyed per-origin so two RPs hosted in the same browser never collide. The
@@ -65,6 +69,16 @@ export declare function ssoDestKey(origin: string): string;
65
69
  * fire again this tab — the definitive loop breaker.
66
70
  */
67
71
  export declare function ssoNoSessionKey(origin: string): string;
72
+ /**
73
+ * Per-origin, OUTCOME-INDEPENDENT once-guard. Set in `sessionStorage` BEFORE
74
+ * the terminal SSO bounce navigates. Gates the bounce so the silent
75
+ * cross-domain probe fires AT MOST ONCE per tab session — independent of
76
+ * whether the return-side NO_SESSION flag ever lands. The definitive loop
77
+ * breaker; survives the 30s self-heal `ssoGuardKey` TTL. Cleared only on an
78
+ * explicit sign-out/clear so a later cold boot (after the user signs in
79
+ * centrally) can probe again.
80
+ */
81
+ export declare function ssoAttemptedKey(origin: string): string;
68
82
  /**
69
83
  * Perform the terminal top-level SSO bounce navigation.
70
84
  *
@@ -91,8 +91,8 @@ export interface ConsumeSsoReturnDeps {
91
91
  * or a `Referer` header even if a later step throws.
92
92
  * - `state` must match (CSRF). A mismatch or a missing code sets the
93
93
  * NO_SESSION flag so `sso-bounce` is disabled (no rebounce loop).
94
- * - `none`/`error` outcomes set the NO_SESSION flag (the load2 half of the
95
- * loop proof).
94
+ * - `none`/`error` outcomes set BOTH the NO_SESSION flag and the
95
+ * outcome-independent attempted-flag (the load2 half of the loop proof).
96
96
  * - A throwing exchange is caught, reported via `onExchangeError`, and
97
97
  * treated exactly like "no session" (never loops, never rethrows).
98
98
  * - After a successful exchange landing on {@link SSO_CALLBACK_PATH}, the real
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "2.2.2",
3
+ "version": "2.3.1",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
package/src/index.ts CHANGED
@@ -384,6 +384,7 @@ export {
384
384
  ssoGuardKey,
385
385
  ssoDestKey,
386
386
  ssoNoSessionKey,
387
+ ssoAttemptedKey,
387
388
  ssoNavigate,
388
389
  buildSsoBounceUrl,
389
390
  isCentralIdPOrigin,
@@ -14,6 +14,7 @@ import {
14
14
  ssoGuardKey,
15
15
  ssoDestKey,
16
16
  ssoNoSessionKey,
17
+ ssoAttemptedKey,
17
18
  } from '../ssoBounce';
18
19
  import type { SessionLoginResponse } from '../../models/session';
19
20
 
@@ -116,10 +117,31 @@ describe('consumeSsoReturn', () => {
116
117
  expect(result).toBeNull();
117
118
  expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
118
119
  expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
120
+ expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
119
121
  expect(storage.map.has(ssoStateKey(ORIGIN))).toBe(false);
120
122
  expect(storage.map.has(ssoGuardKey(ORIGIN))).toBe(false);
121
123
  });
122
124
 
125
+ it('sets BOTH ssoNoSessionKey AND ssoAttemptedKey on a none outcome', async () => {
126
+ const oxy = okExchange();
127
+ const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
128
+ const history = makeHistory();
129
+ const result = await consumeSsoReturn(oxy, {
130
+ isWeb: () => true,
131
+ storage,
132
+ location: makeLocation({ hash: '#oxy_sso=none&state=s' }),
133
+ history,
134
+ });
135
+
136
+ expect(result).toBeNull();
137
+ expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
138
+ expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
139
+ expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
140
+ // Fragment stripped FIRST (history.replaceState called).
141
+ expect(history.calls.length).toBeGreaterThanOrEqual(1);
142
+ expect(history.calls[0]?.[2]).toBe(SSO_CALLBACK_PATH);
143
+ });
144
+
123
145
  it('sets NO_SESSION and returns null on an "error" outcome', async () => {
124
146
  const oxy = okExchange();
125
147
  const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
@@ -132,6 +154,7 @@ describe('consumeSsoReturn', () => {
132
154
 
133
155
  expect(result).toBeNull();
134
156
  expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
157
+ expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
135
158
  });
136
159
 
137
160
  it('sets NO_SESSION and returns null on a state mismatch (CSRF)', async () => {
@@ -147,6 +170,7 @@ describe('consumeSsoReturn', () => {
147
170
  expect(result).toBeNull();
148
171
  expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
149
172
  expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
173
+ expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
150
174
  });
151
175
 
152
176
  it('sets NO_SESSION and returns null when ok carries no code', async () => {
@@ -162,6 +186,7 @@ describe('consumeSsoReturn', () => {
162
186
  expect(result).toBeNull();
163
187
  expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
164
188
  expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
189
+ expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
165
190
  });
166
191
 
167
192
  it('exchanges, returns the session, strips the fragment, and removes state/guard keys on ok', async () => {
@@ -187,6 +212,9 @@ describe('consumeSsoReturn', () => {
187
212
  expect(storage.map.has(ssoStateKey(ORIGIN))).toBe(false);
188
213
  expect(storage.map.has(ssoGuardKey(ORIGIN))).toBe(false);
189
214
  expect(storage.map.has(ssoNoSessionKey(ORIGIN))).toBe(false);
215
+ // The ok happy-path must NOT set the attempted-flag — a future sign-out
216
+ // should be able to re-probe the central IdP.
217
+ expect(storage.map.has(ssoAttemptedKey(ORIGIN))).toBe(false);
190
218
  // Fragment stripped to pathname+search (the first replaceState).
191
219
  expect(history.calls[0]?.[2]).toBe('/feed?tab=home');
192
220
  });
@@ -242,6 +270,7 @@ describe('consumeSsoReturn', () => {
242
270
  expect(result).toBeNull();
243
271
  expect(onExchangeError).toHaveBeenCalledWith(boom);
244
272
  expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
273
+ expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
245
274
  });
246
275
 
247
276
  it('does not throw when the exchange throws and no onExchangeError hook is given', async () => {
@@ -277,6 +306,7 @@ describe('consumeSsoReturn', () => {
277
306
 
278
307
  expect(result).toBeNull();
279
308
  expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
309
+ expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
280
310
  });
281
311
 
282
312
  describe('dest restore', () => {
@@ -14,6 +14,7 @@ import {
14
14
  ssoGuardKey,
15
15
  ssoDestKey,
16
16
  ssoNoSessionKey,
17
+ ssoAttemptedKey,
17
18
  buildSsoBounceUrl,
18
19
  isCentralIdPOrigin,
19
20
  guardActive,
@@ -35,6 +36,7 @@ describe('per-origin key builders', () => {
35
36
  expect(ssoGuardKey(origin)).toBe('oxy_sso_guard:https://mention.earth');
36
37
  expect(ssoDestKey(origin)).toBe('oxy_sso_dest:https://mention.earth');
37
38
  expect(ssoNoSessionKey(origin)).toBe('oxy_sso_no_session:https://mention.earth');
39
+ expect(ssoAttemptedKey(origin)).toBe('oxy_sso_attempted:https://mention.earth');
38
40
  });
39
41
 
40
42
  it('namespaces keys per origin so two RPs never collide', () => {
@@ -26,11 +26,15 @@
26
26
  * the session, then restores the original destination.
27
27
  *
28
28
  * Loop proof (logged-out): first load all steps skip → `sso-bounce` sets
29
- * guard/state/dest and navigates; the IdP (no central session) returns
29
+ * guard/state/dest + the outcome-independent attempted-flag
30
+ * ({@link ssoAttemptedKey}) and navigates; the IdP (no central session) returns
30
31
  * `#oxy_sso=none`; the callback load's `sso-return` sees `none`, sets the
31
32
  * NO_SESSION flag ({@link ssoNoSessionKey}), and `sso-bounce` is then disabled.
32
33
  * Exactly ONE bounce, no loop. An interrupted bounce (user hit back
33
34
  * mid-redirect) self-heals once the {@link SSO_GUARD_TTL_MS} guard TTL lapses.
35
+ * The attempted-flag is the definitive, outcome-INDEPENDENT loop breaker: it is
36
+ * set pre-bounce so even if the return-side NO_SESSION write never lands, the
37
+ * bounce can never re-fire this tab after the self-heal TTL lapses.
34
38
  *
35
39
  * All state lives in `sessionStorage` (per tab, cleared on tab close) and is
36
40
  * keyed per-origin so two RPs hosted in the same browser never collide. The
@@ -62,6 +66,7 @@ const STATE_KEY_PREFIX = 'oxy_sso_state:';
62
66
  const GUARD_KEY_PREFIX = 'oxy_sso_guard:';
63
67
  const DEST_KEY_PREFIX = 'oxy_sso_dest:';
64
68
  const NO_SESSION_KEY_PREFIX = 'oxy_sso_no_session:';
69
+ const ATTEMPTED_KEY_PREFIX = 'oxy_sso_attempted:';
65
70
 
66
71
  /** Per-origin CSRF state key (matched on return to defeat fragment forgery). */
67
72
  export function ssoStateKey(origin: string): string {
@@ -87,6 +92,19 @@ export function ssoNoSessionKey(origin: string): string {
87
92
  return `${NO_SESSION_KEY_PREFIX}${origin}`;
88
93
  }
89
94
 
95
+ /**
96
+ * Per-origin, OUTCOME-INDEPENDENT once-guard. Set in `sessionStorage` BEFORE
97
+ * the terminal SSO bounce navigates. Gates the bounce so the silent
98
+ * cross-domain probe fires AT MOST ONCE per tab session — independent of
99
+ * whether the return-side NO_SESSION flag ever lands. The definitive loop
100
+ * breaker; survives the 30s self-heal `ssoGuardKey` TTL. Cleared only on an
101
+ * explicit sign-out/clear so a later cold boot (after the user signs in
102
+ * centrally) can probe again.
103
+ */
104
+ export function ssoAttemptedKey(origin: string): string {
105
+ return `${ATTEMPTED_KEY_PREFIX}${origin}`;
106
+ }
107
+
90
108
  /**
91
109
  * Perform the terminal top-level SSO bounce navigation.
92
110
  *
@@ -28,6 +28,7 @@ import {
28
28
  ssoGuardKey,
29
29
  ssoDestKey,
30
30
  ssoNoSessionKey,
31
+ ssoAttemptedKey,
31
32
  } from './ssoBounce';
32
33
 
33
34
  /**
@@ -149,8 +150,8 @@ export interface ConsumeSsoReturnDeps {
149
150
  * or a `Referer` header even if a later step throws.
150
151
  * - `state` must match (CSRF). A mismatch or a missing code sets the
151
152
  * NO_SESSION flag so `sso-bounce` is disabled (no rebounce loop).
152
- * - `none`/`error` outcomes set the NO_SESSION flag (the load2 half of the
153
- * loop proof).
153
+ * - `none`/`error` outcomes set BOTH the NO_SESSION flag and the
154
+ * outcome-independent attempted-flag (the load2 half of the loop proof).
154
155
  * - A throwing exchange is caught, reported via `onExchangeError`, and
155
156
  * treated exactly like "no session" (never loops, never rethrows).
156
157
  * - After a successful exchange landing on {@link SSO_CALLBACK_PATH}, the real
@@ -204,6 +205,10 @@ export async function consumeSsoReturn(
204
205
 
205
206
  const markNoSession = () => {
206
207
  storage.setItem(ssoNoSessionKey(origin), '1');
208
+ // A return was consumed, so the probe definitively happened. Set the
209
+ // outcome-independent attempted-flag too so the bounce can never re-fire
210
+ // even if some consumer path skipped setting it pre-bounce.
211
+ storage.setItem(ssoAttemptedKey(origin), '1');
207
212
  };
208
213
 
209
214
  if (ret.kind === 'none' || ret.kind === 'error') {