@oxyhq/core 3.4.3 → 3.4.5
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 +3 -1
- package/dist/cjs/utils/ssoBounce.js +32 -0
- package/dist/cjs/utils/ssoReturn.js +4 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/utils/ssoBounce.js +30 -0
- package/dist/esm/utils/ssoReturn.js +5 -2
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/utils/ssoBounce.d.ts +23 -0
- package/package.json +8 -1
- package/src/index.ts +2 -0
- package/src/server/index.ts +2 -2
- package/src/utils/__tests__/ssoReturn.test.ts +132 -1
- package/src/utils/ssoBounce.ts +33 -0
- package/src/utils/ssoReturn.ts +5 -1
package/dist/types/index.d.ts
CHANGED
|
@@ -80,7 +80,7 @@ export { CENTRAL_AUTH_URL, CENTRAL_IDP_APEX, resolveCentralAuthUrl } from './uti
|
|
|
80
80
|
export { parseSsoReturnFragment, consumeSsoReturn } from './utils/ssoReturn';
|
|
81
81
|
export type { SsoReturnKind, SsoReturnResult, ConsumeSsoReturnDeps } from './utils/ssoReturn';
|
|
82
82
|
export { generateSsoState } from './mixins/OxyServices.sso';
|
|
83
|
-
export { SSO_CALLBACK_PATH, SSO_GUARD_TTL_MS, ssoStateKey, ssoGuardKey, ssoDestKey, ssoNoSessionKey, ssoAttemptedKey, ssoNavigate, buildSsoBounceUrl, isCentralIdPOrigin, guardActive, } from './utils/ssoBounce';
|
|
83
|
+
export { SSO_CALLBACK_PATH, SSO_GUARD_TTL_MS, ssoStateKey, ssoGuardKey, ssoDestKey, ssoNoSessionKey, ssoAttemptedKey, ssoCallbackBootstrapKey, ssoNavigate, getSsoCallbackBootstrapScript, buildSsoBounceUrl, isCentralIdPOrigin, guardActive, } from './utils/ssoBounce';
|
|
84
84
|
export { runColdBoot } from './utils/coldBoot';
|
|
85
85
|
export type { ColdBootStep, ColdBootStepResult, ColdBootSession, ColdBootSkip, ColdBootOutcome, RunColdBootOptions, } from './utils/coldBoot';
|
|
86
86
|
export { packageInfo } from './constants/version';
|
|
@@ -79,6 +79,29 @@ export declare function ssoNoSessionKey(origin: string): string;
|
|
|
79
79
|
* centrally) can probe again.
|
|
80
80
|
*/
|
|
81
81
|
export declare function ssoAttemptedKey(origin: string): string;
|
|
82
|
+
/**
|
|
83
|
+
* Per-origin marker written by the pre-hydration callback bootstrap.
|
|
84
|
+
*
|
|
85
|
+
* Static Expo exports render unknown paths as `+not-found`; on
|
|
86
|
+
* `/__oxy/sso-callback` that can fail hydration before the React provider has a
|
|
87
|
+
* chance to run `consumeSsoReturn`. The bootstrap runs in the HTML head, moves
|
|
88
|
+
* the URL to a hydratable route while preserving the SSO fragment, and writes
|
|
89
|
+
* this marker so `consumeSsoReturn` still restores the original destination as
|
|
90
|
+
* if the page were physically on the callback path.
|
|
91
|
+
*/
|
|
92
|
+
export declare function ssoCallbackBootstrapKey(origin: string): string;
|
|
93
|
+
/**
|
|
94
|
+
* Inline script for Expo/static web apps.
|
|
95
|
+
*
|
|
96
|
+
* Must run before the app bundle hydrates. It is intentionally tiny and
|
|
97
|
+
* dependency-free: if the browser lands on the internal callback route with an
|
|
98
|
+
* Oxy SSO fragment, it marks the handoff and rewrites the path to `/` while
|
|
99
|
+
* preserving `#oxy_sso=...`. The normal SDK cold-boot `sso-return` step then
|
|
100
|
+
* consumes the fragment from a route that can hydrate. If the internal route is
|
|
101
|
+
* reached without a valid SSO fragment, it leaves the route via a hard root
|
|
102
|
+
* navigation because there is no session material to preserve.
|
|
103
|
+
*/
|
|
104
|
+
export declare function getSsoCallbackBootstrapScript(): string;
|
|
82
105
|
/**
|
|
83
106
|
* Perform the terminal top-level SSO bounce navigation.
|
|
84
107
|
*
|
package/package.json
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxyhq/core",
|
|
3
|
-
"version": "3.4.
|
|
3
|
+
"version": "3.4.5",
|
|
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",
|
|
7
7
|
"types": "dist/types/index.d.ts",
|
|
8
|
+
"typesVersions": {
|
|
9
|
+
"*": {
|
|
10
|
+
"server": [
|
|
11
|
+
"dist/types/server/index.d.ts"
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
},
|
|
8
15
|
"source": "src/index.ts",
|
|
9
16
|
"sideEffects": false,
|
|
10
17
|
"publishConfig": {
|
package/src/index.ts
CHANGED
package/src/server/index.ts
CHANGED
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
* import { oxyClient } from '@oxyhq/core';
|
|
11
11
|
*
|
|
12
12
|
* const oxy = oxyClient({ apiUrl: 'https://api.oxy.so' });
|
|
13
|
-
*
|
|
13
|
+
*
|
|
14
14
|
* app.use(createOxyRateLimit(oxy, { store: redisStore }));
|
|
15
15
|
* ```
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
export { createOxyRateLimit } from './rateLimit';
|
|
19
|
-
export type { OxyRateLimitOptions } from './rateLimit';
|
|
19
|
+
export type { OxyRateLimitOptions } from './rateLimit';
|
|
@@ -7,7 +7,42 @@
|
|
|
7
7
|
* `null` for anything that is not an oxy_sso fragment.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import type { SessionLoginResponse } from '../../models/session';
|
|
11
|
+
import { consumeSsoReturn, parseSsoReturnFragment } from '../ssoReturn';
|
|
12
|
+
import {
|
|
13
|
+
getSsoCallbackBootstrapScript,
|
|
14
|
+
ssoAttemptedKey,
|
|
15
|
+
ssoCallbackBootstrapKey,
|
|
16
|
+
ssoDestKey,
|
|
17
|
+
ssoNoSessionKey,
|
|
18
|
+
ssoStateKey,
|
|
19
|
+
} from '../ssoBounce';
|
|
20
|
+
|
|
21
|
+
class MemorySsoStorage implements Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> {
|
|
22
|
+
private readonly values = new Map<string, string>();
|
|
23
|
+
|
|
24
|
+
getItem(key: string): string | null {
|
|
25
|
+
return this.values.get(key) ?? null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
setItem(key: string, value: string): void {
|
|
29
|
+
this.values.set(key, value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
removeItem(key: string): void {
|
|
33
|
+
this.values.delete(key);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ORIGIN = 'https://app.mention.earth';
|
|
38
|
+
|
|
39
|
+
const exchangedSession: SessionLoginResponse = {
|
|
40
|
+
sessionId: 'sess_sso',
|
|
41
|
+
deviceId: 'device_sso',
|
|
42
|
+
accessToken: 'access_sso',
|
|
43
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
44
|
+
user: { id: 'user_sso', username: 'sso-user' },
|
|
45
|
+
};
|
|
11
46
|
|
|
12
47
|
describe('parseSsoReturnFragment', () => {
|
|
13
48
|
describe('ok', () => {
|
|
@@ -118,3 +153,99 @@ describe('parseSsoReturnFragment', () => {
|
|
|
118
153
|
});
|
|
119
154
|
});
|
|
120
155
|
});
|
|
156
|
+
|
|
157
|
+
describe('consumeSsoReturn pre-hydration callback bootstrap', () => {
|
|
158
|
+
it('continues an ok callback after the HTML bootstrap moved the URL to a hydratable route', async () => {
|
|
159
|
+
const storage = new MemorySsoStorage();
|
|
160
|
+
const replaceStateCalls: string[] = [];
|
|
161
|
+
const dispatchPopState = jest.fn();
|
|
162
|
+
const hardRedirect = jest.fn();
|
|
163
|
+
const exchangeSsoCode = jest.fn(async (): Promise<SessionLoginResponse> => exchangedSession);
|
|
164
|
+
|
|
165
|
+
storage.setItem(ssoStateKey(ORIGIN), 'state-ok');
|
|
166
|
+
storage.setItem(ssoDestKey(ORIGIN), `${ORIGIN}/explore?tab=home#top`);
|
|
167
|
+
storage.setItem(ssoCallbackBootstrapKey(ORIGIN), '1');
|
|
168
|
+
|
|
169
|
+
const session = await consumeSsoReturn(
|
|
170
|
+
{ exchangeSsoCode },
|
|
171
|
+
{
|
|
172
|
+
isWeb: () => true,
|
|
173
|
+
storage,
|
|
174
|
+
location: {
|
|
175
|
+
hash: '#oxy_sso=ok&code=opaque-code&state=state-ok',
|
|
176
|
+
origin: ORIGIN,
|
|
177
|
+
pathname: '/',
|
|
178
|
+
search: '',
|
|
179
|
+
},
|
|
180
|
+
history: {
|
|
181
|
+
replaceState: (_data: unknown, _unused: string, url?: string | URL | null): void => {
|
|
182
|
+
replaceStateCalls.push(String(url ?? ''));
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
dispatchPopState,
|
|
186
|
+
hardRedirect,
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
expect(session).toBe(exchangedSession);
|
|
191
|
+
expect(exchangeSsoCode).toHaveBeenCalledWith('opaque-code');
|
|
192
|
+
expect(replaceStateCalls).toEqual(['/', '/explore?tab=home#top']);
|
|
193
|
+
expect(dispatchPopState).toHaveBeenCalledTimes(1);
|
|
194
|
+
expect(hardRedirect).not.toHaveBeenCalled();
|
|
195
|
+
expect(storage.getItem(ssoCallbackBootstrapKey(ORIGIN))).toBeNull();
|
|
196
|
+
expect(storage.getItem(ssoDestKey(ORIGIN))).toBeNull();
|
|
197
|
+
expect(storage.getItem(ssoNoSessionKey(ORIGIN))).toBeNull();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('leaves a bootstrapped none callback with loop breakers set and no exchange', async () => {
|
|
201
|
+
const storage = new MemorySsoStorage();
|
|
202
|
+
const replaceStateCalls: string[] = [];
|
|
203
|
+
const dispatchPopState = jest.fn();
|
|
204
|
+
const hardRedirect = jest.fn();
|
|
205
|
+
const exchangeSsoCode = jest.fn(async (): Promise<SessionLoginResponse> => exchangedSession);
|
|
206
|
+
|
|
207
|
+
storage.setItem(ssoStateKey(ORIGIN), 'state-none');
|
|
208
|
+
storage.setItem(ssoDestKey(ORIGIN), `${ORIGIN}/library`);
|
|
209
|
+
storage.setItem(ssoCallbackBootstrapKey(ORIGIN), '1');
|
|
210
|
+
|
|
211
|
+
const session = await consumeSsoReturn(
|
|
212
|
+
{ exchangeSsoCode },
|
|
213
|
+
{
|
|
214
|
+
isWeb: () => true,
|
|
215
|
+
storage,
|
|
216
|
+
location: {
|
|
217
|
+
hash: '#oxy_sso=none&state=state-none',
|
|
218
|
+
origin: ORIGIN,
|
|
219
|
+
pathname: '/',
|
|
220
|
+
search: '',
|
|
221
|
+
},
|
|
222
|
+
history: {
|
|
223
|
+
replaceState: (_data: unknown, _unused: string, url?: string | URL | null): void => {
|
|
224
|
+
replaceStateCalls.push(String(url ?? ''));
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
dispatchPopState,
|
|
228
|
+
hardRedirect,
|
|
229
|
+
},
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
expect(session).toBeNull();
|
|
233
|
+
expect(exchangeSsoCode).not.toHaveBeenCalled();
|
|
234
|
+
expect(replaceStateCalls).toEqual(['/']);
|
|
235
|
+
expect(dispatchPopState).not.toHaveBeenCalled();
|
|
236
|
+
expect(hardRedirect).toHaveBeenCalledWith(`${ORIGIN}/library`);
|
|
237
|
+
expect(storage.getItem(ssoCallbackBootstrapKey(ORIGIN))).toBeNull();
|
|
238
|
+
expect(storage.getItem(ssoDestKey(ORIGIN))).toBeNull();
|
|
239
|
+
expect(storage.getItem(ssoNoSessionKey(ORIGIN))).toBe('1');
|
|
240
|
+
expect(storage.getItem(ssoAttemptedKey(ORIGIN))).toBe('1');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('exposes a pre-hydration script that preserves the SSO fragment', () => {
|
|
244
|
+
const script = getSsoCallbackBootstrapScript();
|
|
245
|
+
|
|
246
|
+
expect(script).toContain('/__oxy/sso-callback');
|
|
247
|
+
expect(script).toContain('oxy_sso=');
|
|
248
|
+
expect(script).toContain('window.history.replaceState');
|
|
249
|
+
expect(script).toContain('window.location.hash');
|
|
250
|
+
});
|
|
251
|
+
});
|
package/src/utils/ssoBounce.ts
CHANGED
|
@@ -67,6 +67,7 @@ const GUARD_KEY_PREFIX = 'oxy_sso_guard:';
|
|
|
67
67
|
const DEST_KEY_PREFIX = 'oxy_sso_dest:';
|
|
68
68
|
const NO_SESSION_KEY_PREFIX = 'oxy_sso_no_session:';
|
|
69
69
|
const ATTEMPTED_KEY_PREFIX = 'oxy_sso_attempted:';
|
|
70
|
+
const CALLBACK_BOOTSTRAP_KEY_PREFIX = 'oxy_sso_callback_bootstrap:';
|
|
70
71
|
|
|
71
72
|
/** Per-origin CSRF state key (matched on return to defeat fragment forgery). */
|
|
72
73
|
export function ssoStateKey(origin: string): string {
|
|
@@ -105,6 +106,38 @@ export function ssoAttemptedKey(origin: string): string {
|
|
|
105
106
|
return `${ATTEMPTED_KEY_PREFIX}${origin}`;
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Per-origin marker written by the pre-hydration callback bootstrap.
|
|
111
|
+
*
|
|
112
|
+
* Static Expo exports render unknown paths as `+not-found`; on
|
|
113
|
+
* `/__oxy/sso-callback` that can fail hydration before the React provider has a
|
|
114
|
+
* chance to run `consumeSsoReturn`. The bootstrap runs in the HTML head, moves
|
|
115
|
+
* the URL to a hydratable route while preserving the SSO fragment, and writes
|
|
116
|
+
* this marker so `consumeSsoReturn` still restores the original destination as
|
|
117
|
+
* if the page were physically on the callback path.
|
|
118
|
+
*/
|
|
119
|
+
export function ssoCallbackBootstrapKey(origin: string): string {
|
|
120
|
+
return `${CALLBACK_BOOTSTRAP_KEY_PREFIX}${origin}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Inline script for Expo/static web apps.
|
|
125
|
+
*
|
|
126
|
+
* Must run before the app bundle hydrates. It is intentionally tiny and
|
|
127
|
+
* dependency-free: if the browser lands on the internal callback route with an
|
|
128
|
+
* Oxy SSO fragment, it marks the handoff and rewrites the path to `/` while
|
|
129
|
+
* preserving `#oxy_sso=...`. The normal SDK cold-boot `sso-return` step then
|
|
130
|
+
* consumes the fragment from a route that can hydrate. If the internal route is
|
|
131
|
+
* reached without a valid SSO fragment, it leaves the route via a hard root
|
|
132
|
+
* navigation because there is no session material to preserve.
|
|
133
|
+
*/
|
|
134
|
+
export function getSsoCallbackBootstrapScript(): string {
|
|
135
|
+
const callbackPath = JSON.stringify(SSO_CALLBACK_PATH);
|
|
136
|
+
const bootstrapPrefix = JSON.stringify(CALLBACK_BOOTSTRAP_KEY_PREFIX);
|
|
137
|
+
|
|
138
|
+
return `(function(){var p=${callbackPath};if(window.location.pathname!==p)return;var h=window.location.hash||"";if(!/(?:^#|&)oxy_sso=(?:ok|none|error)(?:&|$)/.test(h)){window.location.replace("/");return;}try{window.sessionStorage.setItem(${bootstrapPrefix}+window.location.origin,"1");}catch(e){window.__oxySsoCallbackBootstrapError=e instanceof Error?e.message:String(e);}try{window.history.replaceState(null,"","/"+h);}catch(e){window.__oxySsoCallbackBootstrapError=e instanceof Error?e.message:String(e);window.location.replace("/"+h);}})();`;
|
|
139
|
+
}
|
|
140
|
+
|
|
108
141
|
/**
|
|
109
142
|
* Perform the terminal top-level SSO bounce navigation.
|
|
110
143
|
*
|
package/src/utils/ssoReturn.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
ssoDestKey,
|
|
30
30
|
ssoNoSessionKey,
|
|
31
31
|
ssoAttemptedKey,
|
|
32
|
+
ssoCallbackBootstrapKey,
|
|
32
33
|
} from './ssoBounce';
|
|
33
34
|
|
|
34
35
|
/**
|
|
@@ -257,6 +258,8 @@ export async function consumeSsoReturn(
|
|
|
257
258
|
}
|
|
258
259
|
|
|
259
260
|
const origin = location.origin;
|
|
261
|
+
const callbackBootstrapKey = ssoCallbackBootstrapKey(origin);
|
|
262
|
+
const wasCallbackBootstrapped = storage.getItem(callbackBootstrapKey) === '1';
|
|
260
263
|
const expectedState = storage.getItem(ssoStateKey(origin));
|
|
261
264
|
const stateOk = !!ret.state && !!expectedState && ret.state === expectedState;
|
|
262
265
|
|
|
@@ -286,7 +289,8 @@ export async function consumeSsoReturn(
|
|
|
286
289
|
// (so it can be fed to either `history.replaceState` or a `hardRedirect`),
|
|
287
290
|
// or `null` when the page is not on the callback path (nothing to leave).
|
|
288
291
|
const consumeCallbackTarget = (): string | null => {
|
|
289
|
-
|
|
292
|
+
storage.removeItem(callbackBootstrapKey);
|
|
293
|
+
if (location.pathname !== SSO_CALLBACK_PATH && !wasCallbackBootstrapped) {
|
|
290
294
|
// Not on the callback path — still drop the dest key (consumed) but there
|
|
291
295
|
// is nothing to navigate away from.
|
|
292
296
|
storage.removeItem(ssoDestKey(origin));
|