@oxyhq/core 5.2.1 → 5.3.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 (59) 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 +24 -0
  6. package/dist/cjs/server/index.js +13 -1
  7. package/dist/cjs/session/SessionClient.js +142 -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 +24 -0
  19. package/dist/esm/server/index.js +10 -0
  20. package/dist/esm/session/SessionClient.js +138 -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 +14 -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 +26 -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 +175 -0
  49. package/src/session/__tests__/SessionClient.rest.test.ts +79 -0
  50. package/src/session/__tests__/SessionClient.socket.test.ts +132 -0
  51. package/src/session/__tests__/SessionClient.state.test.ts +64 -0
  52. package/src/session/__tests__/sessionIntegration.test.ts +202 -0
  53. package/src/session/createSessionClient.ts +31 -0
  54. package/src/session/projectSessionState.ts +83 -0
  55. package/src/session/sessionClientHost.ts +32 -0
  56. package/src/session/socketLoader.ts +28 -0
  57. package/src/utils/__tests__/ssoEstablish.test.ts +204 -0
  58. package/src/utils/ssoBounce.ts +9 -9
  59. package/src/utils/ssoEstablish.ts +174 -0
@@ -0,0 +1,64 @@
1
+ import type { DeviceSessionState } from '@oxyhq/contracts';
2
+ import { SessionClient, type SessionClientHost, type TokenTransport } from '../SessionClient';
3
+
4
+ function makeHost(): SessionClientHost {
5
+ return {
6
+ makeRequest: jest.fn(),
7
+ getBaseURL: () => 'http://test.invalid',
8
+ getAccessToken: () => 't',
9
+ onTokensChanged: () => () => undefined,
10
+ setTokens: jest.fn(),
11
+ getCurrentAccountId: () => null,
12
+ };
13
+ }
14
+ const STATE = (rev: number, active: string | null = 'a1'): DeviceSessionState => ({
15
+ deviceId: 'd1', accounts: active ? [{ accountId: 'a1', sessionId: 's1', authuser: 0 }] : [], activeAccountId: active, revision: rev, updatedAt: 1720000000000,
16
+ });
17
+
18
+ // SessionClient.applyState is protected; a tiny subclass exposes it for the unit test.
19
+ class TestClient extends SessionClient { public apply(raw: unknown): boolean { return this.applyState(raw); } }
20
+
21
+ describe('SessionClient state', () => {
22
+ it('starts with null state', () => {
23
+ expect(new SessionClient(makeHost()).getState()).toBeNull();
24
+ });
25
+
26
+ it('applies a valid state and notifies subscribers', () => {
27
+ const c = new TestClient(makeHost());
28
+ const seen: (DeviceSessionState | null)[] = [];
29
+ c.subscribe((s) => seen.push(s));
30
+ expect(c.apply(STATE(1))).toBe(true);
31
+ expect(c.getState()?.revision).toBe(1);
32
+ expect(seen.at(-1)?.revision).toBe(1);
33
+ });
34
+
35
+ it('ignores a stale or equal revision (last-writer-wins)', () => {
36
+ const c = new TestClient(makeHost());
37
+ c.apply(STATE(5));
38
+ expect(c.apply(STATE(5))).toBe(false);
39
+ expect(c.apply(STATE(4))).toBe(false);
40
+ expect(c.getState()?.revision).toBe(5);
41
+ });
42
+
43
+ it('rejects an invalid (unvalidated) state without applying', () => {
44
+ const c = new TestClient(makeHost());
45
+ expect(c.apply({ deviceId: 'd1', accounts: 'nope', revision: 1 })).toBe(false);
46
+ expect(c.getState()).toBeNull();
47
+ });
48
+
49
+ it('calls transport.ensureActiveToken when a state is applied', () => {
50
+ const transport: TokenTransport = { ensureActiveToken: jest.fn().mockResolvedValue(undefined) };
51
+ const c = new TestClient(makeHost(), { transport });
52
+ c.apply(STATE(1));
53
+ expect(transport.ensureActiveToken).toHaveBeenCalledWith(expect.objectContaining({ revision: 1 }));
54
+ });
55
+
56
+ it('unsubscribe stops notifications', () => {
57
+ const c = new TestClient(makeHost());
58
+ const seen: unknown[] = [];
59
+ const off = c.subscribe((s) => seen.push(s));
60
+ off();
61
+ c.apply(STATE(1));
62
+ expect(seen).toHaveLength(0);
63
+ });
64
+ });
@@ -0,0 +1,202 @@
1
+ import type { DeviceSessionState } from '@oxyhq/contracts';
2
+ import type { User } from '../../models/interfaces';
3
+ import { SessionClient, type TokenTransport } from '../SessionClient';
4
+ import { createSessionClientHost } from '../sessionClientHost';
5
+ import { createSessionClient } from '../createSessionClient';
6
+ import {
7
+ accountIdsOf,
8
+ activeSessionIdOf,
9
+ activeUserOf,
10
+ deviceStateToClientSessions,
11
+ } from '../projectSessionState';
12
+
13
+ function fakeOxy() {
14
+ const listeners = new Set<(t: string | null) => void>();
15
+ return {
16
+ makeRequest: jest.fn().mockResolvedValue({ ok: true }),
17
+ getBaseURL: jest.fn().mockReturnValue('https://api.oxy.so'),
18
+ getAccessToken: jest.fn().mockReturnValue('tok'),
19
+ setTokens: jest.fn(),
20
+ onTokensChanged: jest.fn((l: (t: string | null) => void) => {
21
+ listeners.add(l);
22
+ return () => listeners.delete(l);
23
+ }),
24
+ _emit: (t: string | null) => listeners.forEach((l) => l(t)),
25
+ };
26
+ }
27
+
28
+ describe('createSessionClientHost', () => {
29
+ test('delegates REST + token methods to oxyServices', async () => {
30
+ const oxy = fakeOxy();
31
+ const host = createSessionClientHost(oxy as never);
32
+ await host.makeRequest('GET', '/session/device/state', undefined, { cache: false });
33
+ expect(oxy.makeRequest).toHaveBeenCalledWith('GET', '/session/device/state', undefined, { cache: false });
34
+ expect(host.getBaseURL()).toBe('https://api.oxy.so');
35
+ expect(host.getAccessToken()).toBe('tok');
36
+ host.setTokens('new');
37
+ expect(oxy.setTokens).toHaveBeenCalledWith('new');
38
+ });
39
+
40
+ test('getCurrentAccountId reflects setCurrentAccountId', () => {
41
+ const host = createSessionClientHost(fakeOxy() as never);
42
+ expect(host.getCurrentAccountId()).toBeNull();
43
+ host.setCurrentAccountId('u1');
44
+ expect(host.getCurrentAccountId()).toBe('u1');
45
+ });
46
+
47
+ test('onTokensChanged forwards to oxyServices and unsubscribes', () => {
48
+ const oxy = fakeOxy();
49
+ const host = createSessionClientHost(oxy as never);
50
+ const cb = jest.fn();
51
+ const unsub = host.onTokensChanged(cb);
52
+ oxy._emit(null);
53
+ expect(cb).toHaveBeenCalledWith(null);
54
+ unsub();
55
+ oxy._emit('x');
56
+ expect(cb).toHaveBeenCalledTimes(1);
57
+ });
58
+ });
59
+
60
+ function makeUser(id: string): User {
61
+ return {
62
+ id,
63
+ publicKey: `pk-${id}`,
64
+ username: `user-${id}`,
65
+ name: {},
66
+ };
67
+ }
68
+
69
+ // DeviceSessionState.updatedAt is an epoch-ms number on the wire (see
70
+ // packages/contracts/src/deviceSession.ts: `updatedAt: z.number()`), not an
71
+ // ISO string.
72
+ const UPDATED_AT_MS = Date.UTC(2026, 6, 1, 0, 0, 0, 0);
73
+ const UPDATED_AT_ISO = new Date(UPDATED_AT_MS).toISOString();
74
+
75
+ const state: DeviceSessionState = {
76
+ deviceId: 'device-1',
77
+ accounts: [
78
+ { accountId: 'a1', sessionId: 'sess-a1', authuser: 0 },
79
+ { accountId: 'a2', sessionId: 'sess-a2', authuser: 1 },
80
+ ],
81
+ activeAccountId: 'a2',
82
+ revision: 1,
83
+ updatedAt: UPDATED_AT_MS,
84
+ };
85
+
86
+ const usersById = new Map<string, User>([
87
+ ['a1', makeUser('a1')],
88
+ ['a2', makeUser('a2')],
89
+ ]);
90
+
91
+ describe('projectSessionState', () => {
92
+ describe('activeSessionIdOf', () => {
93
+ test('returns the active account sessionId', () => {
94
+ expect(activeSessionIdOf(state)).toBe('sess-a2');
95
+ });
96
+
97
+ test('returns null for null state', () => {
98
+ expect(activeSessionIdOf(null)).toBeNull();
99
+ });
100
+
101
+ test('returns null when activeAccountId is null', () => {
102
+ expect(activeSessionIdOf({ ...state, activeAccountId: null })).toBeNull();
103
+ });
104
+ });
105
+
106
+ describe('deviceStateToClientSessions', () => {
107
+ test('maps every account in order with isCurrent + authuser', () => {
108
+ const sessions = deviceStateToClientSessions(state, usersById);
109
+ expect(sessions).toHaveLength(2);
110
+ expect(sessions[0]).toEqual({
111
+ sessionId: 'sess-a1',
112
+ deviceId: 'device-1',
113
+ expiresAt: UPDATED_AT_ISO,
114
+ lastActive: UPDATED_AT_ISO,
115
+ userId: 'a1',
116
+ isCurrent: false,
117
+ authuser: 0,
118
+ });
119
+ expect(sessions[1]).toEqual({
120
+ sessionId: 'sess-a2',
121
+ deviceId: 'device-1',
122
+ expiresAt: UPDATED_AT_ISO,
123
+ lastActive: UPDATED_AT_ISO,
124
+ userId: 'a2',
125
+ isCurrent: true,
126
+ authuser: 1,
127
+ });
128
+ });
129
+
130
+ test('still projects a session for an account absent from usersById', () => {
131
+ const sessions = deviceStateToClientSessions(state, new Map());
132
+ expect(sessions).toHaveLength(2);
133
+ expect(sessions.map((session) => session.userId)).toEqual(['a1', 'a2']);
134
+ });
135
+ });
136
+
137
+ describe('activeUserOf', () => {
138
+ test('returns the active account user', () => {
139
+ expect(activeUserOf(state, usersById)).toEqual(makeUser('a2'));
140
+ });
141
+
142
+ test('returns null for null state', () => {
143
+ expect(activeUserOf(null, usersById)).toBeNull();
144
+ });
145
+
146
+ test('returns null when activeAccountId is null', () => {
147
+ expect(activeUserOf({ ...state, activeAccountId: null }, usersById)).toBeNull();
148
+ });
149
+
150
+ test('returns null when the active account id is absent from usersById', () => {
151
+ expect(activeUserOf(state, new Map())).toBeNull();
152
+ });
153
+ });
154
+
155
+ describe('accountIdsOf', () => {
156
+ test('returns every account id in order', () => {
157
+ expect(accountIdsOf(state)).toEqual(['a1', 'a2']);
158
+ });
159
+
160
+ test('returns [] for null state', () => {
161
+ expect(accountIdsOf(null)).toEqual([]);
162
+ });
163
+ });
164
+ });
165
+
166
+ describe('createSessionClient', () => {
167
+ function fakeTransport(): TokenTransport {
168
+ return { ensureActiveToken: jest.fn().mockResolvedValue(undefined) };
169
+ }
170
+
171
+ test('wires a SessionClient instance backed by the host + injected transport', () => {
172
+ const oxy = fakeOxy();
173
+
174
+ const { client, host } = createSessionClient(oxy as never, fakeTransport());
175
+
176
+ expect(client).toBeInstanceOf(SessionClient);
177
+ expect(typeof client.bootstrap).toBe('function');
178
+ expect(client.getState()).toBeNull();
179
+ expect(typeof host.setCurrentAccountId).toBe('function');
180
+ });
181
+
182
+ test('the returned host reflects setCurrentAccountId', () => {
183
+ const oxy = fakeOxy();
184
+
185
+ const { host } = createSessionClient(oxy as never, fakeTransport());
186
+
187
+ expect(host.getCurrentAccountId()).toBeNull();
188
+ host.setCurrentAccountId('u1');
189
+ expect(host.getCurrentAccountId()).toBe('u1');
190
+ });
191
+
192
+ test('uses the injected transport (not a hard-coded one) when the client bootstraps', async () => {
193
+ const oxy = fakeOxy();
194
+ oxy.makeRequest.mockResolvedValue({ data: { state, activeToken: null } });
195
+ const transport = fakeTransport();
196
+
197
+ const { client } = createSessionClient(oxy as never, transport);
198
+ await client.bootstrap();
199
+
200
+ expect(transport.ensureActiveToken).toHaveBeenCalledWith(expect.objectContaining({ revision: state.revision }));
201
+ });
202
+ });
@@ -0,0 +1,31 @@
1
+ import type { OxyServices } from '../OxyServices';
2
+ import { SessionClient, type TokenTransport } from './SessionClient';
3
+ import { createSessionClientHost } from './sessionClientHost';
4
+
5
+ /**
6
+ * Wires a `SessionClient` over the given `OxyServices` instance: builds the
7
+ * `SessionClientHost` adapter and passes it through together with a
8
+ * caller-supplied `TokenTransport`.
9
+ *
10
+ * The transport is a required parameter (not constructed here) because it is
11
+ * the one piece of this integration that is NOT platform-agnostic: `services`
12
+ * branches native (shared-keychain sign-in) vs. web (silent sign-in), while
13
+ * `auth-sdk` is web-only. Each consumer builds its own transport and passes
14
+ * it in; this factory only wires the platform-agnostic parts (host + client)
15
+ * so neither consumer re-implements them.
16
+ *
17
+ * The host is returned alongside the client (not just the client) so the
18
+ * caller can call `host.setCurrentAccountId(...)` as the active account
19
+ * changes.
20
+ */
21
+ export function createSessionClient(
22
+ oxyServices: OxyServices,
23
+ transport: TokenTransport,
24
+ ): {
25
+ client: SessionClient;
26
+ host: ReturnType<typeof createSessionClientHost>;
27
+ } {
28
+ const host = createSessionClientHost(oxyServices);
29
+ const client = new SessionClient(host, { transport });
30
+ return { client, host };
31
+ }
@@ -0,0 +1,83 @@
1
+ import type { DeviceSessionState } from '@oxyhq/contracts';
2
+ import type { ClientSession } from '../models/session';
3
+ import type { User } from '../models/interfaces';
4
+
5
+ /**
6
+ * Pure projection helpers: `DeviceSessionState` (the device-scoped
7
+ * multi-account session-sync state produced by `SessionClient`) -> the
8
+ * shapes consumers (`@oxyhq/services`, `@oxyhq/auth`) render today
9
+ * (`ClientSession[]`, an active session id, an active `User`).
10
+ *
11
+ * No I/O. The caller fetches profiles via
12
+ * `oxyServices.getUsersByIds(accountIdsOf(state))` and builds `usersById`
13
+ * from the result before calling `deviceStateToClientSessions` /
14
+ * `activeUserOf`.
15
+ */
16
+
17
+ /**
18
+ * Maps every `SessionAccount` in `state.accounts` to a `ClientSession`.
19
+ *
20
+ * `DeviceSessionState` carries no per-account `expiresAt` / `lastActive` —
21
+ * both are set to `state.updatedAt` (converted to an ISO-8601 string; the
22
+ * wire value is an epoch-ms number) as a provisional value.
23
+ *
24
+ * `usersById` is accepted for signature symmetry with `activeUserOf` even
25
+ * though `ClientSession` only stores `userId` — a session is still
26
+ * projected for an account whose id is absent from `usersById` (no
27
+ * placeholder user is fabricated).
28
+ */
29
+ export function deviceStateToClientSessions(
30
+ state: DeviceSessionState,
31
+ usersById: Map<string, User>,
32
+ ): ClientSession[] {
33
+ const provisionalTimestamp = new Date(state.updatedAt).toISOString();
34
+ return state.accounts.map((account) => ({
35
+ sessionId: account.sessionId,
36
+ deviceId: state.deviceId,
37
+ // provisional: expiresAt/lastActive are not carried on DeviceSessionState
38
+ expiresAt: provisionalTimestamp,
39
+ lastActive: provisionalTimestamp,
40
+ userId: account.accountId,
41
+ isCurrent: account.accountId === state.activeAccountId,
42
+ authuser: account.authuser,
43
+ }));
44
+ }
45
+
46
+ /**
47
+ * The active account's `sessionId`, or `null` when there is no state or no
48
+ * active account is set.
49
+ */
50
+ export function activeSessionIdOf(state: DeviceSessionState | null): string | null {
51
+ if (state === null || state.activeAccountId === null) {
52
+ return null;
53
+ }
54
+ const activeAccountId = state.activeAccountId;
55
+ const activeAccount = state.accounts.find((account) => account.accountId === activeAccountId);
56
+ return activeAccount?.sessionId ?? null;
57
+ }
58
+
59
+ /**
60
+ * The active account's `User`, resolved from `usersById`. `null` when there
61
+ * is no state, no active account is set, or the active account id is absent
62
+ * from `usersById`.
63
+ */
64
+ export function activeUserOf(
65
+ state: DeviceSessionState | null,
66
+ usersById: Map<string, User>,
67
+ ): User | null {
68
+ if (state === null || state.activeAccountId === null) {
69
+ return null;
70
+ }
71
+ return usersById.get(state.activeAccountId) ?? null;
72
+ }
73
+
74
+ /**
75
+ * All account ids in `state`, suitable for an `oxyServices.getUsersByIds(...)`
76
+ * fetch. `[]` for `null` state.
77
+ */
78
+ export function accountIdsOf(state: DeviceSessionState | null): string[] {
79
+ if (state === null) {
80
+ return [];
81
+ }
82
+ return state.accounts.map((account) => account.accountId);
83
+ }
@@ -0,0 +1,32 @@
1
+ import type { OxyServices } from '../OxyServices';
2
+ import type { SessionClientHost } from './SessionClient';
3
+
4
+ /**
5
+ * Thin `SessionClientHost` adapter over an `OxyServices` instance.
6
+ *
7
+ * `SessionClient` is host-agnostic: it only needs a REST + token surface.
8
+ * `OxyServices` already exposes all of that except `getCurrentAccountId`,
9
+ * which has no direct equivalent — the adapter holds a mutable ref set by
10
+ * the caller (`OxyContext` in `@oxyhq/services`, `WebOxyProvider` in
11
+ * `@oxyhq/auth`) via `setCurrentAccountId`.
12
+ *
13
+ * Shared here (rather than duplicated per consumer) because it is entirely
14
+ * platform-agnostic: every method it calls exists identically on
15
+ * `OxyServices` regardless of host (web, Expo/RN, Node).
16
+ */
17
+ export function createSessionClientHost(
18
+ oxyServices: OxyServices,
19
+ ): SessionClientHost & { setCurrentAccountId(id: string | null): void } {
20
+ let currentAccountId: string | null = null;
21
+ return {
22
+ makeRequest: (method, url, data, options) => oxyServices.makeRequest(method, url, data, options),
23
+ getBaseURL: () => oxyServices.getBaseURL(),
24
+ getAccessToken: () => oxyServices.getAccessToken(),
25
+ onTokensChanged: (listener) => oxyServices.onTokensChanged(listener),
26
+ setTokens: (accessToken) => oxyServices.setTokens(accessToken),
27
+ getCurrentAccountId: () => currentAccountId,
28
+ setCurrentAccountId: (id) => {
29
+ currentAccountId = id;
30
+ },
31
+ };
32
+ }
@@ -0,0 +1,28 @@
1
+ import { logger } from '../utils/loggerUtils';
2
+
3
+ export interface MinimalSocket {
4
+ connected: boolean;
5
+ on(event: string, handler: (...args: unknown[]) => void): void;
6
+ off(event: string, handler?: (...args: unknown[]) => void): void;
7
+ connect(): void;
8
+ disconnect(): void;
9
+ }
10
+
11
+ export type SocketIOFactory = (uri: string, opts?: Record<string, unknown>) => MinimalSocket;
12
+
13
+ let cachedFactory: SocketIOFactory | null = null;
14
+ let loadAttempted = false;
15
+
16
+ export async function getSocketIO(): Promise<SocketIOFactory | null> {
17
+ if (cachedFactory) return cachedFactory;
18
+ if (loadAttempted) return null;
19
+ loadAttempted = true;
20
+ try {
21
+ const mod = (await import('socket.io-client')) as { io?: SocketIOFactory; default?: SocketIOFactory };
22
+ cachedFactory = mod.io ?? mod.default ?? null;
23
+ return cachedFactory;
24
+ } catch (error) {
25
+ logger.warn('[SessionClient] socket.io-client import failed; realtime session sync disabled', { component: 'SessionClient' }, error);
26
+ return null;
27
+ }
28
+ }
@@ -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.