@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +2 -1
- package/dist/cjs/utils/ssoBounce.js +19 -1
- package/dist/cjs/utils/ssoReturn.js +6 -2
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/utils/ssoBounce.js +18 -1
- package/dist/esm/utils/ssoReturn.js +7 -3
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/utils/ssoBounce.d.ts +15 -1
- package/dist/types/utils/ssoReturn.d.ts +2 -2
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/utils/__tests__/consumeSsoReturn.test.ts +30 -0
- package/src/utils/__tests__/ssoBounce.test.ts +2 -0
- package/src/utils/ssoBounce.ts +19 -1
- package/src/utils/ssoReturn.ts +7 -2
package/dist/types/index.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
package/src/index.ts
CHANGED
|
@@ -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', () => {
|
package/src/utils/ssoBounce.ts
CHANGED
|
@@ -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
|
|
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
|
*
|
package/src/utils/ssoReturn.ts
CHANGED
|
@@ -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
|
|
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') {
|