@oxyhq/core 5.2.1 → 5.4.0

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.
Files changed (61) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/CrossDomainAuth.js +32 -42
  3. package/dist/cjs/index.js +24 -2
  4. package/dist/cjs/mixins/OxyServices.sso.js +36 -0
  5. package/dist/cjs/mixins/OxyServices.user.js +50 -0
  6. package/dist/cjs/server/index.js +13 -1
  7. package/dist/cjs/session/SessionClient.js +152 -0
  8. package/dist/cjs/session/createSessionClient.js +26 -0
  9. package/dist/cjs/session/projectSessionState.js +75 -0
  10. package/dist/cjs/session/sessionClientHost.js +30 -0
  11. package/dist/cjs/session/socketLoader.js +55 -0
  12. package/dist/cjs/utils/ssoBounce.js +9 -9
  13. package/dist/cjs/utils/ssoEstablish.js +110 -0
  14. package/dist/esm/.tsbuildinfo +1 -1
  15. package/dist/esm/CrossDomainAuth.js +32 -42
  16. package/dist/esm/index.js +14 -0
  17. package/dist/esm/mixins/OxyServices.sso.js +36 -0
  18. package/dist/esm/mixins/OxyServices.user.js +50 -0
  19. package/dist/esm/server/index.js +10 -0
  20. package/dist/esm/session/SessionClient.js +148 -0
  21. package/dist/esm/session/createSessionClient.js +23 -0
  22. package/dist/esm/session/projectSessionState.js +69 -0
  23. package/dist/esm/session/sessionClientHost.js +27 -0
  24. package/dist/esm/session/socketLoader.js +19 -0
  25. package/dist/esm/utils/ssoBounce.js +9 -9
  26. package/dist/esm/utils/ssoEstablish.js +107 -0
  27. package/dist/types/.tsbuildinfo +1 -1
  28. package/dist/types/CrossDomainAuth.d.ts +33 -13
  29. package/dist/types/index.d.ts +7 -0
  30. package/dist/types/mixins/OxyServices.sso.d.ts +24 -0
  31. package/dist/types/mixins/OxyServices.user.d.ts +30 -0
  32. package/dist/types/server/index.d.ts +2 -0
  33. package/dist/types/session/SessionClient.d.ts +55 -0
  34. package/dist/types/session/createSessionClient.d.ts +23 -0
  35. package/dist/types/session/projectSessionState.d.ts +43 -0
  36. package/dist/types/session/sessionClientHost.d.ts +18 -0
  37. package/dist/types/session/socketLoader.d.ts +9 -0
  38. package/dist/types/utils/ssoBounce.d.ts +9 -9
  39. package/dist/types/utils/ssoEstablish.d.ts +85 -0
  40. package/package.json +1 -1
  41. package/src/CrossDomainAuth.ts +33 -44
  42. package/src/__tests__/crossDomainAuth.test.ts +33 -16
  43. package/src/index.ts +24 -0
  44. package/src/mixins/OxyServices.sso.ts +55 -0
  45. package/src/mixins/OxyServices.user.ts +54 -0
  46. package/src/mixins/__tests__/sso.test.ts +41 -0
  47. package/src/server/index.ts +12 -0
  48. package/src/session/SessionClient.ts +187 -0
  49. package/src/session/__tests__/SessionClient.diagnostics.test.ts +90 -0
  50. package/src/session/__tests__/SessionClient.httpIntegration.test.ts +65 -0
  51. package/src/session/__tests__/SessionClient.rest.test.ts +80 -0
  52. package/src/session/__tests__/SessionClient.socket.test.ts +133 -0
  53. package/src/session/__tests__/SessionClient.state.test.ts +64 -0
  54. package/src/session/__tests__/sessionIntegration.test.ts +202 -0
  55. package/src/session/createSessionClient.ts +31 -0
  56. package/src/session/projectSessionState.ts +83 -0
  57. package/src/session/sessionClientHost.ts +32 -0
  58. package/src/session/socketLoader.ts +28 -0
  59. package/src/utils/__tests__/ssoEstablish.test.ts +204 -0
  60. package/src/utils/ssoBounce.ts +9 -9
  61. package/src/utils/ssoEstablish.ts +174 -0
@@ -0,0 +1,204 @@
1
+ /**
2
+ * `establishIdpSessionAfterClaim` — the post-claim durable-session hop.
3
+ *
4
+ * After a WEB device-flow ("Sign in with Oxy" QR) claim commits, the app holds
5
+ * only in-memory tokens: no `fedcm_session` cookie was ever planted at the IdP,
6
+ * so a reload cannot re-mint a token. This primitive performs ONE top-level
7
+ * establish hop (via the RP-minted establish-URL) so the per-apex IdP cookie is
8
+ * planted and a reload restores via the existing `sso-return` / silent-iframe
9
+ * paths.
10
+ *
11
+ * Invariants pinned here:
12
+ * - web only (no-op off-web / native);
13
+ * - never fires while sitting on the central IdP origin (would loop);
14
+ * - persists the SAME bounce state (`ssoStateKey`/`ssoGuardKey`/`ssoDestKey`)
15
+ * the `buildSsoBounceUrl` machinery primes, so the post-bounce `sso-return`
16
+ * step validates the state, exchanges the code, and restores the dest;
17
+ * - persists ONLY after the establish-URL request succeeds (no stale state on
18
+ * failure);
19
+ * - navigates exactly once, to the server-returned establish URL;
20
+ * - total: an establish-request failure leaves the committed in-memory session
21
+ * untouched, does NOT navigate, and returns `false` (user no worse off).
22
+ */
23
+
24
+ import { establishIdpSessionAfterClaim } from '../ssoEstablish';
25
+ import {
26
+ ssoStateKey,
27
+ ssoGuardKey,
28
+ ssoDestKey,
29
+ } from '../ssoBounce';
30
+ import { CENTRAL_AUTH_URL } from '../authWebUrl';
31
+
32
+ const RP_ORIGIN = 'https://accounts.oxy.so';
33
+ const RP_HREF = 'https://accounts.oxy.so/settings';
34
+ const ESTABLISH_URL =
35
+ 'https://auth.oxy.so/sso/establish?et=jwt.abc.def&return_to=https%3A%2F%2Faccounts.oxy.so%2F__oxy%2Fsso-callback&state=state-xyz';
36
+
37
+ function makeStorage(): {
38
+ storage: Pick<Storage, 'getItem' | 'setItem'>;
39
+ map: Map<string, string>;
40
+ } {
41
+ const map = new Map<string, string>();
42
+ return {
43
+ map,
44
+ storage: {
45
+ getItem: (k: string) => (map.has(k) ? (map.get(k) as string) : null),
46
+ setItem: (k: string, v: string) => {
47
+ map.set(k, v);
48
+ },
49
+ },
50
+ };
51
+ }
52
+
53
+ describe('establishIdpSessionAfterClaim', () => {
54
+ it('requests the establish URL with the RP origin + generated state and navigates once (web)', async () => {
55
+ const { storage, map } = makeStorage();
56
+ const navigated: string[] = [];
57
+ const requestSsoEstablishUrl = jest.fn(async (origin: string, state: string) => {
58
+ expect(origin).toBe(RP_ORIGIN);
59
+ expect(state).toBe('state-xyz');
60
+ return { establishUrl: ESTABLISH_URL };
61
+ });
62
+
63
+ const result = await establishIdpSessionAfterClaim(
64
+ { requestSsoEstablishUrl },
65
+ {
66
+ isWeb: () => true,
67
+ storage,
68
+ location: { origin: RP_ORIGIN, href: RP_HREF },
69
+ navigate: (url) => navigated.push(url),
70
+ generateState: () => 'state-xyz',
71
+ now: () => 1_700_000_000_000,
72
+ },
73
+ );
74
+
75
+ expect(result).toBe(true);
76
+ expect(requestSsoEstablishUrl).toHaveBeenCalledTimes(1);
77
+ expect(navigated).toEqual([ESTABLISH_URL]);
78
+
79
+ // Bounce state persisted so the post-bounce `sso-return` step validates it.
80
+ expect(map.get(ssoStateKey(RP_ORIGIN))).toBe('state-xyz');
81
+ expect(map.get(ssoGuardKey(RP_ORIGIN))).toBe(String(1_700_000_000_000));
82
+ expect(map.get(ssoDestKey(RP_ORIGIN))).toBe(RP_HREF);
83
+ });
84
+
85
+ it('is a no-op off-web (native): no request, no navigation, no state', async () => {
86
+ const { storage, map } = makeStorage();
87
+ const navigated: string[] = [];
88
+ const requestSsoEstablishUrl = jest.fn(async () => ({ establishUrl: ESTABLISH_URL }));
89
+
90
+ const result = await establishIdpSessionAfterClaim(
91
+ { requestSsoEstablishUrl },
92
+ {
93
+ isWeb: () => false,
94
+ storage,
95
+ location: { origin: RP_ORIGIN, href: RP_HREF },
96
+ navigate: (url) => navigated.push(url),
97
+ },
98
+ );
99
+
100
+ expect(result).toBe(false);
101
+ expect(requestSsoEstablishUrl).not.toHaveBeenCalled();
102
+ expect(navigated).toEqual([]);
103
+ expect(map.size).toBe(0);
104
+ });
105
+
106
+ it('never fires while sitting on the central IdP origin (loop guard)', async () => {
107
+ const { storage, map } = makeStorage();
108
+ const navigated: string[] = [];
109
+ const requestSsoEstablishUrl = jest.fn(async () => ({ establishUrl: ESTABLISH_URL }));
110
+
111
+ const result = await establishIdpSessionAfterClaim(
112
+ { requestSsoEstablishUrl },
113
+ {
114
+ isWeb: () => true,
115
+ storage,
116
+ location: { origin: new URL(CENTRAL_AUTH_URL).origin, href: `${CENTRAL_AUTH_URL}/` },
117
+ navigate: (url) => navigated.push(url),
118
+ },
119
+ );
120
+
121
+ expect(result).toBe(false);
122
+ expect(requestSsoEstablishUrl).not.toHaveBeenCalled();
123
+ expect(navigated).toEqual([]);
124
+ expect(map.size).toBe(0);
125
+ });
126
+
127
+ it('on an establish-request failure: no navigation, no persisted state, returns false (single attempt)', async () => {
128
+ const { storage, map } = makeStorage();
129
+ const navigated: string[] = [];
130
+ const onError = jest.fn();
131
+ const requestSsoEstablishUrl = jest.fn(async () => {
132
+ throw new Error('403 unapproved origin');
133
+ });
134
+
135
+ const result = await establishIdpSessionAfterClaim(
136
+ { requestSsoEstablishUrl },
137
+ {
138
+ isWeb: () => true,
139
+ storage,
140
+ location: { origin: RP_ORIGIN, href: RP_HREF },
141
+ navigate: (url) => navigated.push(url),
142
+ generateState: () => 'state-xyz',
143
+ onError,
144
+ },
145
+ );
146
+
147
+ expect(result).toBe(false);
148
+ expect(requestSsoEstablishUrl).toHaveBeenCalledTimes(1);
149
+ expect(navigated).toEqual([]);
150
+ // No stale bounce state left behind on failure.
151
+ expect(map.size).toBe(0);
152
+ expect(onError).toHaveBeenCalledTimes(1);
153
+ });
154
+
155
+ it('does not navigate when the server returns no establish URL', async () => {
156
+ const { storage, map } = makeStorage();
157
+ const navigated: string[] = [];
158
+ const requestSsoEstablishUrl = jest.fn(async () => ({ establishUrl: '' }));
159
+
160
+ const result = await establishIdpSessionAfterClaim(
161
+ { requestSsoEstablishUrl },
162
+ {
163
+ isWeb: () => true,
164
+ storage,
165
+ location: { origin: RP_ORIGIN, href: RP_HREF },
166
+ navigate: (url) => navigated.push(url),
167
+ generateState: () => 'state-xyz',
168
+ },
169
+ );
170
+
171
+ expect(result).toBe(false);
172
+ expect(navigated).toEqual([]);
173
+ expect(map.size).toBe(0);
174
+ });
175
+
176
+ it.each([
177
+ ['unparseable', 'not a url'],
178
+ ['non-https (http)', 'http://auth.oxy.so/sso/establish?et=x&state=s'],
179
+ ['wrong path', 'https://auth.oxy.so/evil?et=x&state=s'],
180
+ ['non-auth host', 'https://evil.oxy.so/sso/establish?et=x&state=s'],
181
+ ])(
182
+ 'aborts silently (no navigation, no state) for a %s establish URL',
183
+ async (_label, badUrl) => {
184
+ const { storage, map } = makeStorage();
185
+ const navigated: string[] = [];
186
+ const requestSsoEstablishUrl = jest.fn(async () => ({ establishUrl: badUrl }));
187
+
188
+ const result = await establishIdpSessionAfterClaim(
189
+ { requestSsoEstablishUrl },
190
+ {
191
+ isWeb: () => true,
192
+ storage,
193
+ location: { origin: RP_ORIGIN, href: RP_HREF },
194
+ navigate: (url) => navigated.push(url),
195
+ generateState: () => 'state-xyz',
196
+ },
197
+ );
198
+
199
+ expect(result).toBe(false);
200
+ expect(navigated).toEqual([]);
201
+ expect(map.size).toBe(0);
202
+ },
203
+ );
204
+ });
@@ -135,18 +135,18 @@ export function ssoPriorSessionKey(origin: string): string {
135
135
  * per-tab `sessionStorage` the loop-breaker keys use — it must survive a reload.
136
136
  *
137
137
  * It exists purely to suppress AUTOMATIC silent restore after a deliberate
138
- * sign-out: a still-live IdP session (the central `fedcm_session` / the FedCM
139
- * credential association) would otherwise let `fedcm-silent` / the per-apex
140
- * `/auth/silent` iframe re-mint a session on the very next cold boot, so a user
141
- * who pressed "Sign out" gets silently signed back in on reload. With this flag
142
- * set, those silent cold-boot steps are skipped while the Gmail-style
143
- * returning-account fast-path is otherwise preserved.
138
+ * sign-out: a still-live IdP session (the central `fedcm_session`) would
139
+ * otherwise let the per-apex `/auth/silent` iframe re-mint a session on the
140
+ * very next cold boot, so a user who pressed "Sign out" gets silently signed
141
+ * back in on reload. With this flag set, that silent cold-boot step is
142
+ * skipped while the Gmail-style returning-account fast-path is otherwise
143
+ * preserved.
144
144
  *
145
145
  * Lifecycle (mirrors the existing gate machinery — set on a definitive event,
146
146
  * cleared on its inverse):
147
147
  * - SET on EXPLICIT full sign-out (alongside clearing the prior-session hint
148
148
  * and the SSO bounce state).
149
- * - CLEARED on ANY deliberate sign-in (password, FedCM, account switch, device
149
+ * - CLEARED on ANY deliberate sign-in (password, account switch, device
150
150
  * claim) so a real sign-in fully re-enables silent restore — there is no
151
151
  * "stuck signed out" state.
152
152
  *
@@ -303,8 +303,8 @@ export function guardActive(
303
303
  * Whether AUTOMATIC silent restore is SUPPRESSED for this origin because the
304
304
  * user deliberately signed out (the durable {@link ssoSignedOutKey} flag).
305
305
  *
306
- * When `true`, the silent cold-boot steps that can re-mint a session from a
307
- * still-live IdP session WITHOUT user intent — `fedcm-silent` and the per-apex
306
+ * When `true`, the silent cold-boot step that can re-mint a session from a
307
+ * still-live IdP session WITHOUT user intent — the per-apex
308
308
  * `/auth/silent` iframe — MUST be skipped, so a user who pressed "Sign out" is
309
309
  * not silently signed back in on the next reload. Interactive sign-in clears the
310
310
  * flag, so this never blocks a deliberate re-sign-in.
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Post-claim durable-session establish hop (web device-flow / "Sign in with
3
+ * Oxy" QR).
4
+ *
5
+ * A WEB device-flow claim (`claimSessionByToken`) plants only IN-MEMORY tokens:
6
+ * unlike a redirect/FedCM/silent sign-in, it never causes the IdP to plant a
7
+ * `fedcm_session` cookie. So a reload has nothing to restore from — the
8
+ * silent-iframe and `/sso` paths find no IdP session and the session is lost.
9
+ *
10
+ * This primitive closes that gap. AFTER the claim has committed and the session
11
+ * has been durably persisted, it performs ONE top-level establish hop through
12
+ * the RP's own per-apex IdP host (`auth.<rp-apex>`), reusing the EXISTING
13
+ * `/sso/establish` endpoint: the server mints a short-lived, host+audience-bound
14
+ * establish-token and returns a fully-formed establish URL; navigating to it
15
+ * plants the durable first-party `fedcm_session` cookie and bounces back to the
16
+ * RP callback with an opaque code the standard `sso-return` cold-boot step
17
+ * exchanges.
18
+ *
19
+ * It reuses the SAME per-origin `sessionStorage` bounce contract
20
+ * (`ssoStateKey` / `ssoGuardKey` / `ssoDestKey`) that {@link buildSsoBounceUrl}
21
+ * primes for the terminal `/sso` bounce, so the post-bounce `sso-return` step
22
+ * (`consumeSsoReturn`) validates the CSRF `state`, exchanges the code, and
23
+ * restores the user's real destination with no extra wiring.
24
+ *
25
+ * Contract:
26
+ * - WEB only — off-web / native it is a no-op returning `false`.
27
+ * - NEVER fires while sitting on the central IdP origin (that would loop the
28
+ * IdP against itself).
29
+ * - Bounce state is persisted ONLY after the establish-URL request succeeds, so
30
+ * a failed request leaves no stale state behind.
31
+ * - SINGLE attempt: the caller invokes this exactly once per successful claim.
32
+ * On ANY failure it does NOT navigate and returns `false`, leaving the
33
+ * committed in-memory session exactly as-is (the user is no worse off than
34
+ * before this hop existed).
35
+ * - Total: never throws. Failures are reported via {@link deps.onError} only.
36
+ */
37
+
38
+ import {
39
+ ssoStateKey,
40
+ ssoGuardKey,
41
+ ssoDestKey,
42
+ ssoNavigate,
43
+ isCentralIdPOrigin,
44
+ } from './ssoBounce';
45
+ import { generateSsoState } from '../mixins/OxyServices.sso';
46
+
47
+ /**
48
+ * The minimal SDK surface this hop needs: mint a server-formed establish URL
49
+ * bound to the caller's own session for an approved RP origin. Structural so the
50
+ * primitive is unit-testable with a stub and never imports the full client.
51
+ */
52
+ export interface SsoEstablishClient {
53
+ requestSsoEstablishUrl(
54
+ origin: string,
55
+ state: string,
56
+ ): Promise<{ establishUrl: string }>;
57
+ }
58
+
59
+ /**
60
+ * Injectable web seams for {@link establishIdpSessionAfterClaim}. Every seam is
61
+ * overridable so the primitive is fully unit-testable with fakes and so native
62
+ * callers rely on the defaults (which resolve to `window.*` only when a browser
63
+ * is present). Defaults are evaluated lazily inside the function so importing
64
+ * this module never touches `window`.
65
+ */
66
+ export interface EstablishAfterClaimDeps {
67
+ /** Per-tab SSO bounce store. Default: `window.sessionStorage`. */
68
+ storage?: Pick<Storage, 'getItem' | 'setItem'>;
69
+ /** The current location. Default: `window.location`. */
70
+ location?: Pick<Location, 'origin' | 'href'>;
71
+ /** Top-level navigation seam. Default: {@link ssoNavigate} (`location.assign`). */
72
+ navigate?: (url: string) => void;
73
+ /**
74
+ * Whether the current environment is a web browser with usable
75
+ * `sessionStorage`. Default: `typeof window !== 'undefined' && typeof
76
+ * window.sessionStorage !== 'undefined'`.
77
+ */
78
+ isWeb?: () => boolean;
79
+ /** CSRF state generator. Default: {@link generateSsoState}. */
80
+ generateState?: () => string;
81
+ /** Epoch-ms clock for the bounce guard. Default: `Date.now`. */
82
+ now?: () => number;
83
+ /**
84
+ * Optional debug hook invoked with the thrown error when the establish
85
+ * request (or state persistence) fails. NEVER rethrown. Default: no-op.
86
+ */
87
+ onError?: (error: unknown) => void;
88
+ }
89
+
90
+ /**
91
+ * Perform the post-claim establish hop. Returns `true` when a navigation to the
92
+ * establish URL was initiated (the document is being torn down and replaced by
93
+ * the IdP), `false` on every no-op / failure path.
94
+ *
95
+ * @param client - The exchange surface (`oxyServices.requestSsoEstablishUrl`).
96
+ * @param deps - Injectable web seams; see {@link EstablishAfterClaimDeps}.
97
+ */
98
+ export async function establishIdpSessionAfterClaim(
99
+ client: SsoEstablishClient,
100
+ deps: EstablishAfterClaimDeps = {},
101
+ ): Promise<boolean> {
102
+ const isWeb =
103
+ deps.isWeb ??
104
+ (() =>
105
+ typeof window !== 'undefined' &&
106
+ typeof window.sessionStorage !== 'undefined');
107
+
108
+ if (!isWeb()) {
109
+ return false;
110
+ }
111
+
112
+ const storage = deps.storage ?? window.sessionStorage;
113
+ const location = deps.location ?? window.location;
114
+ const navigate = deps.navigate ?? ssoNavigate;
115
+ const generateState = deps.generateState ?? generateSsoState;
116
+ const now = deps.now ?? (() => Date.now());
117
+
118
+ const origin = location.origin;
119
+
120
+ // Never establish while sitting on the central IdP itself — that would bounce
121
+ // the IdP against itself. (The device-flow claim can only happen on an RP.)
122
+ if (isCentralIdPOrigin(origin)) {
123
+ return false;
124
+ }
125
+
126
+ const state = generateState();
127
+
128
+ let establishUrl: string;
129
+ try {
130
+ const result = await client.requestSsoEstablishUrl(origin, state);
131
+ if (
132
+ !result ||
133
+ typeof result.establishUrl !== 'string' ||
134
+ result.establishUrl.length === 0
135
+ ) {
136
+ return false;
137
+ }
138
+ establishUrl = result.establishUrl;
139
+
140
+ // Defense-in-depth before a TOP-LEVEL navigation: the establish URL is
141
+ // server-formed, but never follow anything that is not a well-formed https
142
+ // `/sso/establish` on an `auth.` host. A malformed or unexpected URL aborts
143
+ // silently (no navigation, no state persisted) — same total/no-throw
144
+ // contract — rather than navigating the document somewhere unintended.
145
+ let parsedEstablishUrl: URL;
146
+ try {
147
+ parsedEstablishUrl = new URL(establishUrl);
148
+ } catch {
149
+ return false;
150
+ }
151
+ if (
152
+ parsedEstablishUrl.protocol !== 'https:' ||
153
+ parsedEstablishUrl.pathname !== '/sso/establish' ||
154
+ !parsedEstablishUrl.hostname.toLowerCase().startsWith('auth.')
155
+ ) {
156
+ return false;
157
+ }
158
+
159
+ // Persist the bounce state ONLY now that the request has succeeded and the
160
+ // URL is validated — so a failed/rejected request never leaves stale state
161
+ // behind. This is the exact contract the terminal `/sso` bounce primes via
162
+ // `buildSsoBounceUrl`, so the post-bounce `sso-return` step (`consumeSsoReturn`)
163
+ // validates `state`, exchanges the returned code, and restores the dest.
164
+ storage.setItem(ssoStateKey(origin), state);
165
+ storage.setItem(ssoGuardKey(origin), String(now()));
166
+ storage.setItem(ssoDestKey(origin), location.href);
167
+ } catch (error) {
168
+ deps.onError?.(error);
169
+ return false;
170
+ }
171
+
172
+ navigate(establishUrl);
173
+ return true;
174
+ }