@oxyhq/core 2.2.1 → 2.2.2

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 (37) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/index.js +17 -1
  3. package/dist/cjs/mixins/OxyServices.auth.js +45 -0
  4. package/dist/cjs/mixins/OxyServices.user.js +15 -5
  5. package/dist/cjs/utils/authWebUrl.js +14 -3
  6. package/dist/cjs/utils/fapiAutoDetect.js +47 -6
  7. package/dist/cjs/utils/ssoBounce.js +192 -0
  8. package/dist/cjs/utils/ssoReturn.js +111 -0
  9. package/dist/esm/.tsbuildinfo +1 -1
  10. package/dist/esm/index.js +5 -3
  11. package/dist/esm/mixins/OxyServices.auth.js +45 -0
  12. package/dist/esm/mixins/OxyServices.user.js +15 -5
  13. package/dist/esm/utils/authWebUrl.js +13 -2
  14. package/dist/esm/utils/fapiAutoDetect.js +45 -6
  15. package/dist/esm/utils/ssoBounce.js +181 -0
  16. package/dist/esm/utils/ssoReturn.js +110 -0
  17. package/dist/types/.tsbuildinfo +1 -1
  18. package/dist/types/index.d.ts +5 -4
  19. package/dist/types/mixins/OxyServices.auth.d.ts +35 -0
  20. package/dist/types/mixins/OxyServices.user.d.ts +7 -0
  21. package/dist/types/utils/authWebUrl.d.ts +12 -1
  22. package/dist/types/utils/fapiAutoDetect.d.ts +36 -0
  23. package/dist/types/utils/ssoBounce.d.ts +124 -0
  24. package/dist/types/utils/ssoReturn.d.ts +65 -0
  25. package/package.json +1 -1
  26. package/src/index.ts +18 -4
  27. package/src/mixins/OxyServices.auth.ts +54 -0
  28. package/src/mixins/OxyServices.user.ts +14 -5
  29. package/src/mixins/__tests__/serviceAuth.test.ts +92 -0
  30. package/src/utils/__tests__/authWebUrl.test.ts +11 -1
  31. package/src/utils/__tests__/consumeSsoReturn.test.ts +401 -0
  32. package/src/utils/__tests__/fapiAutoDetect.test.ts +62 -1
  33. package/src/utils/__tests__/ssoBounce.test.ts +148 -0
  34. package/src/utils/authWebUrl.ts +14 -2
  35. package/src/utils/fapiAutoDetect.ts +41 -5
  36. package/src/utils/ssoBounce.ts +198 -0
  37. package/src/utils/ssoReturn.ts +168 -0
@@ -0,0 +1,401 @@
1
+ /**
2
+ * `consumeSsoReturn` — the commit-free, security-critical kernel of the
3
+ * cross-domain SSO `sso-return` step.
4
+ *
5
+ * Every web seam is injected (storage / location / history / isWeb) so the
6
+ * full CSRF / fragment-strip-order / exchange / dest-restore / loop-breaker
7
+ * sequence is asserted deterministically without a DOM.
8
+ */
9
+
10
+ import { consumeSsoReturn } from '../ssoReturn';
11
+ import {
12
+ SSO_CALLBACK_PATH,
13
+ ssoStateKey,
14
+ ssoGuardKey,
15
+ ssoDestKey,
16
+ ssoNoSessionKey,
17
+ } from '../ssoBounce';
18
+ import type { SessionLoginResponse } from '../../models/session';
19
+
20
+ const ORIGIN = 'https://mention.earth';
21
+
22
+ function makeStorage(seed: Record<string, string> = {}) {
23
+ const map = new Map<string, string>(Object.entries(seed));
24
+ return {
25
+ map,
26
+ getItem: (k: string) => (map.has(k) ? (map.get(k) as string) : null),
27
+ setItem: (k: string, v: string) => {
28
+ map.set(k, v);
29
+ },
30
+ removeItem: (k: string) => {
31
+ map.delete(k);
32
+ },
33
+ };
34
+ }
35
+
36
+ interface LocationParts {
37
+ hash: string;
38
+ origin: string;
39
+ pathname: string;
40
+ search: string;
41
+ }
42
+
43
+ function makeLocation(parts: Partial<LocationParts> = {}): LocationParts {
44
+ return {
45
+ hash: parts.hash ?? '',
46
+ origin: parts.origin ?? ORIGIN,
47
+ pathname: parts.pathname ?? SSO_CALLBACK_PATH,
48
+ search: parts.search ?? '',
49
+ };
50
+ }
51
+
52
+ function makeHistory() {
53
+ const calls: Array<[unknown, string, string | undefined]> = [];
54
+ return {
55
+ calls,
56
+ replaceState: (data: unknown, unused: string, url?: string | URL | null) => {
57
+ calls.push([data, unused, typeof url === 'string' ? url : undefined]);
58
+ },
59
+ };
60
+ }
61
+
62
+ const SESSION: SessionLoginResponse = {
63
+ sessionId: 'sess-1',
64
+ deviceId: 'dev-1',
65
+ expiresAt: '2099-01-01T00:00:00.000Z',
66
+ user: { id: 'u1', username: 'alice' },
67
+ };
68
+
69
+ function okExchange() {
70
+ return { exchangeSsoCode: jest.fn(async () => SESSION) };
71
+ }
72
+
73
+ describe('consumeSsoReturn', () => {
74
+ it('returns null off-web (isWeb false) and does nothing', async () => {
75
+ const oxy = okExchange();
76
+ const storage = makeStorage();
77
+ const result = await consumeSsoReturn(oxy, {
78
+ isWeb: () => false,
79
+ storage,
80
+ location: makeLocation({ hash: '#oxy_sso=ok&code=c&state=s' }),
81
+ history: makeHistory(),
82
+ });
83
+
84
+ expect(result).toBeNull();
85
+ expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
86
+ });
87
+
88
+ it('returns null for a non-oxy fragment without touching any flags', async () => {
89
+ const oxy = okExchange();
90
+ const storage = makeStorage();
91
+ const history = makeHistory();
92
+ const result = await consumeSsoReturn(oxy, {
93
+ isWeb: () => true,
94
+ storage,
95
+ location: makeLocation({ hash: '#section=about' }),
96
+ history,
97
+ });
98
+
99
+ expect(result).toBeNull();
100
+ expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
101
+ expect(storage.map.has(ssoNoSessionKey(ORIGIN))).toBe(false);
102
+ // No fragment strip for an unrelated fragment.
103
+ expect(history.calls).toHaveLength(0);
104
+ });
105
+
106
+ it('sets NO_SESSION and returns null on a "none" outcome', async () => {
107
+ const oxy = okExchange();
108
+ const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
109
+ const result = await consumeSsoReturn(oxy, {
110
+ isWeb: () => true,
111
+ storage,
112
+ location: makeLocation({ hash: '#oxy_sso=none&state=s' }),
113
+ history: makeHistory(),
114
+ });
115
+
116
+ expect(result).toBeNull();
117
+ expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
118
+ expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
119
+ expect(storage.map.has(ssoStateKey(ORIGIN))).toBe(false);
120
+ expect(storage.map.has(ssoGuardKey(ORIGIN))).toBe(false);
121
+ });
122
+
123
+ it('sets NO_SESSION and returns null on an "error" outcome', async () => {
124
+ const oxy = okExchange();
125
+ const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
126
+ const result = await consumeSsoReturn(oxy, {
127
+ isWeb: () => true,
128
+ storage,
129
+ location: makeLocation({ hash: '#oxy_sso=error&state=s' }),
130
+ history: makeHistory(),
131
+ });
132
+
133
+ expect(result).toBeNull();
134
+ expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
135
+ });
136
+
137
+ it('sets NO_SESSION and returns null on a state mismatch (CSRF)', async () => {
138
+ const oxy = okExchange();
139
+ const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 'expected' });
140
+ const result = await consumeSsoReturn(oxy, {
141
+ isWeb: () => true,
142
+ storage,
143
+ location: makeLocation({ hash: '#oxy_sso=ok&code=c&state=forged' }),
144
+ history: makeHistory(),
145
+ });
146
+
147
+ expect(result).toBeNull();
148
+ expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
149
+ expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
150
+ });
151
+
152
+ it('sets NO_SESSION and returns null when ok carries no code', async () => {
153
+ const oxy = okExchange();
154
+ const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
155
+ const result = await consumeSsoReturn(oxy, {
156
+ isWeb: () => true,
157
+ storage,
158
+ location: makeLocation({ hash: '#oxy_sso=ok&state=s' }),
159
+ history: makeHistory(),
160
+ });
161
+
162
+ expect(result).toBeNull();
163
+ expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
164
+ expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
165
+ });
166
+
167
+ it('exchanges, returns the session, strips the fragment, and removes state/guard keys on ok', async () => {
168
+ const oxy = okExchange();
169
+ const storage = makeStorage({
170
+ [ssoStateKey(ORIGIN)]: 's',
171
+ [ssoGuardKey(ORIGIN)]: String(Date.now()),
172
+ });
173
+ const history = makeHistory();
174
+ const result = await consumeSsoReturn(oxy, {
175
+ isWeb: () => true,
176
+ storage,
177
+ location: makeLocation({
178
+ hash: '#oxy_sso=ok&code=opaque-code&state=s',
179
+ pathname: '/feed',
180
+ search: '?tab=home',
181
+ }),
182
+ history,
183
+ });
184
+
185
+ expect(result).toEqual(SESSION);
186
+ expect(oxy.exchangeSsoCode).toHaveBeenCalledWith('opaque-code');
187
+ expect(storage.map.has(ssoStateKey(ORIGIN))).toBe(false);
188
+ expect(storage.map.has(ssoGuardKey(ORIGIN))).toBe(false);
189
+ expect(storage.map.has(ssoNoSessionKey(ORIGIN))).toBe(false);
190
+ // Fragment stripped to pathname+search (the first replaceState).
191
+ expect(history.calls[0]?.[2]).toBe('/feed?tab=home');
192
+ });
193
+
194
+ it('strips the fragment BEFORE the exchange (opaque code never lingers)', async () => {
195
+ const order: string[] = [];
196
+ const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
197
+ const history = {
198
+ replaceState: () => {
199
+ order.push('replaceState');
200
+ },
201
+ };
202
+ const oxy = {
203
+ exchangeSsoCode: jest.fn(async () => {
204
+ order.push('exchange');
205
+ return SESSION;
206
+ }),
207
+ };
208
+
209
+ await consumeSsoReturn(oxy, {
210
+ isWeb: () => true,
211
+ storage,
212
+ location: makeLocation({
213
+ hash: '#oxy_sso=ok&code=c&state=s',
214
+ pathname: '/somewhere',
215
+ }),
216
+ history,
217
+ });
218
+
219
+ expect(order[0]).toBe('replaceState');
220
+ expect(order).toContain('exchange');
221
+ expect(order.indexOf('replaceState')).toBeLessThan(order.indexOf('exchange'));
222
+ });
223
+
224
+ it('sets NO_SESSION, returns null, and calls onExchangeError when the exchange throws', async () => {
225
+ const boom = new Error('exchange failed');
226
+ const oxy = {
227
+ exchangeSsoCode: jest.fn(async () => {
228
+ throw boom;
229
+ }),
230
+ };
231
+ const onExchangeError = jest.fn();
232
+ const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
233
+
234
+ const result = await consumeSsoReturn(oxy, {
235
+ isWeb: () => true,
236
+ storage,
237
+ location: makeLocation({ hash: '#oxy_sso=ok&code=c&state=s' }),
238
+ history: makeHistory(),
239
+ onExchangeError,
240
+ });
241
+
242
+ expect(result).toBeNull();
243
+ expect(onExchangeError).toHaveBeenCalledWith(boom);
244
+ expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
245
+ });
246
+
247
+ it('does not throw when the exchange throws and no onExchangeError hook is given', async () => {
248
+ const oxy = {
249
+ exchangeSsoCode: jest.fn(async () => {
250
+ throw new Error('boom');
251
+ }),
252
+ };
253
+ const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
254
+
255
+ await expect(
256
+ consumeSsoReturn(oxy, {
257
+ isWeb: () => true,
258
+ storage,
259
+ location: makeLocation({ hash: '#oxy_sso=ok&code=c&state=s' }),
260
+ history: makeHistory(),
261
+ }),
262
+ ).resolves.toBeNull();
263
+ });
264
+
265
+ it('sets NO_SESSION when the exchange resolves without a sessionId', async () => {
266
+ const oxy = {
267
+ exchangeSsoCode: jest.fn(async () => ({ sessionId: '' }) as SessionLoginResponse),
268
+ };
269
+ const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
270
+
271
+ const result = await consumeSsoReturn(oxy, {
272
+ isWeb: () => true,
273
+ storage,
274
+ location: makeLocation({ hash: '#oxy_sso=ok&code=c&state=s' }),
275
+ history: makeHistory(),
276
+ });
277
+
278
+ expect(result).toBeNull();
279
+ expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
280
+ });
281
+
282
+ describe('dest restore', () => {
283
+ it('restores a same-origin destination when on the callback path and removes the dest key', async () => {
284
+ const oxy = okExchange();
285
+ const storage = makeStorage({
286
+ [ssoStateKey(ORIGIN)]: 's',
287
+ [ssoDestKey(ORIGIN)]: `${ORIGIN}/profile?x=1#frag`,
288
+ });
289
+ const history = makeHistory();
290
+
291
+ const result = await consumeSsoReturn(oxy, {
292
+ isWeb: () => true,
293
+ storage,
294
+ location: makeLocation({
295
+ hash: '#oxy_sso=ok&code=c&state=s',
296
+ pathname: SSO_CALLBACK_PATH,
297
+ }),
298
+ history,
299
+ });
300
+
301
+ expect(result).toEqual(SESSION);
302
+ // First replaceState = fragment strip; last = dest restore.
303
+ const last = history.calls[history.calls.length - 1];
304
+ expect(last?.[2]).toBe('/profile?x=1#frag');
305
+ expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
306
+ });
307
+
308
+ it('restores a relative same-origin destination (new URL(dest, origin))', async () => {
309
+ const oxy = okExchange();
310
+ const storage = makeStorage({
311
+ [ssoStateKey(ORIGIN)]: 's',
312
+ [ssoDestKey(ORIGIN)]: '/settings?tab=privacy',
313
+ });
314
+ const history = makeHistory();
315
+
316
+ await consumeSsoReturn(oxy, {
317
+ isWeb: () => true,
318
+ storage,
319
+ location: makeLocation({
320
+ hash: '#oxy_sso=ok&code=c&state=s',
321
+ pathname: SSO_CALLBACK_PATH,
322
+ }),
323
+ history,
324
+ });
325
+
326
+ const last = history.calls[history.calls.length - 1];
327
+ expect(last?.[2]).toBe('/settings?tab=privacy');
328
+ expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
329
+ });
330
+
331
+ it('rejects a cross-origin (attacker-planted) destination but still removes the dest key', async () => {
332
+ const oxy = okExchange();
333
+ const storage = makeStorage({
334
+ [ssoStateKey(ORIGIN)]: 's',
335
+ [ssoDestKey(ORIGIN)]: 'https://evil.example/phish',
336
+ });
337
+ const history = makeHistory();
338
+
339
+ await consumeSsoReturn(oxy, {
340
+ isWeb: () => true,
341
+ storage,
342
+ location: makeLocation({
343
+ hash: '#oxy_sso=ok&code=c&state=s',
344
+ pathname: SSO_CALLBACK_PATH,
345
+ }),
346
+ history,
347
+ });
348
+
349
+ // Only the fragment-strip replaceState should have run — no dest restore.
350
+ expect(history.calls).toHaveLength(1);
351
+ expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
352
+ });
353
+
354
+ it('rejects a protocol-relative cross-origin destination', async () => {
355
+ const oxy = okExchange();
356
+ const storage = makeStorage({
357
+ [ssoStateKey(ORIGIN)]: 's',
358
+ [ssoDestKey(ORIGIN)]: '//evil.example/phish',
359
+ });
360
+ const history = makeHistory();
361
+
362
+ await consumeSsoReturn(oxy, {
363
+ isWeb: () => true,
364
+ storage,
365
+ location: makeLocation({
366
+ hash: '#oxy_sso=ok&code=c&state=s',
367
+ pathname: SSO_CALLBACK_PATH,
368
+ }),
369
+ history,
370
+ });
371
+
372
+ expect(history.calls).toHaveLength(1);
373
+ expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
374
+ });
375
+
376
+ it('does not restore dest when NOT on the callback path, but still removes the dest key', async () => {
377
+ const oxy = okExchange();
378
+ const storage = makeStorage({
379
+ [ssoStateKey(ORIGIN)]: 's',
380
+ [ssoDestKey(ORIGIN)]: `${ORIGIN}/should-not-apply`,
381
+ });
382
+ const history = makeHistory();
383
+
384
+ await consumeSsoReturn(oxy, {
385
+ isWeb: () => true,
386
+ storage,
387
+ location: makeLocation({
388
+ hash: '#oxy_sso=ok&code=c&state=s',
389
+ pathname: '/already-here',
390
+ search: '?a=1',
391
+ }),
392
+ history,
393
+ });
394
+
395
+ // Only the fragment strip ran.
396
+ expect(history.calls).toHaveLength(1);
397
+ expect(history.calls[0]?.[2]).toBe('/already-here?a=1');
398
+ expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
399
+ });
400
+ });
401
+ });
@@ -10,7 +10,7 @@
10
10
  * MULTIPART_TLDS guard.
11
11
  */
12
12
 
13
- import { autoDetectAuthWebUrl } from '../fapiAutoDetect';
13
+ import { autoDetectAuthWebUrl, registrableApex } from '../fapiAutoDetect';
14
14
 
15
15
  function loc(hostname: string, protocol = 'https:'): Pick<Location, 'hostname' | 'protocol'> {
16
16
  return { hostname, protocol };
@@ -90,4 +90,65 @@ describe('autoDetectAuthWebUrl', () => {
90
90
  expect(autoDetectAuthWebUrl(loc('shop.com.au'))).toBeUndefined();
91
91
  });
92
92
  });
93
+
94
+ // Regression guard: the refactor to delegate host handling to `registrableApex`
95
+ // must not change any of the pre-existing return values.
96
+ describe('regression — unchanged values after registrableApex extraction', () => {
97
+ const cases: Array<[string, string | undefined]> = [
98
+ ['mention.earth', 'https://auth.mention.earth'],
99
+ ['www.mention.earth', 'https://auth.mention.earth'],
100
+ ['deep.app.homiio.com', 'https://auth.homiio.com'],
101
+ ['auth.oxy.so', 'https://auth.oxy.so'],
102
+ ['auth.mention.earth', 'https://auth.mention.earth'],
103
+ ['localhost', undefined],
104
+ ['192.168.1.10', undefined],
105
+ ['[::1]', undefined],
106
+ ['intranet', undefined],
107
+ ['', undefined],
108
+ ['foo.co.uk', undefined],
109
+ ];
110
+ it.each(cases)('autoDetectAuthWebUrl(%s) === %s', (hostname, expected) => {
111
+ const protocol = hostname === 'localhost' || hostname === '[::1]' ? 'http:' : 'https:';
112
+ expect(autoDetectAuthWebUrl(loc(hostname, protocol))).toBe(expected);
113
+ });
114
+ });
115
+ });
116
+
117
+ describe('registrableApex', () => {
118
+ it('returns the eTLD+1 for a normal two-label host', () => {
119
+ expect(registrableApex('mention.earth')).toBe('mention.earth');
120
+ });
121
+
122
+ it('strips subdomains down to the trailing two labels', () => {
123
+ expect(registrableApex('www.mention.earth')).toBe('mention.earth');
124
+ expect(registrableApex('deep.app.homiio.com')).toBe('homiio.com');
125
+ });
126
+
127
+ it('lower-cases the input', () => {
128
+ expect(registrableApex('WWW.Mention.EARTH')).toBe('mention.earth');
129
+ });
130
+
131
+ it('returns null for a multi-part public suffix (foo.co.uk -> null)', () => {
132
+ expect(registrableApex('foo.co.uk')).toBeNull();
133
+ expect(registrableApex('shop.com.au')).toBeNull();
134
+ });
135
+
136
+ it('returns null for IPv4 literals', () => {
137
+ expect(registrableApex('192.168.1.10')).toBeNull();
138
+ expect(registrableApex('10.0.0.1')).toBeNull();
139
+ });
140
+
141
+ it('returns null for IPv6 literals and hosts carrying a port', () => {
142
+ expect(registrableApex('[::1]')).toBeNull();
143
+ expect(registrableApex('mention.earth:3000')).toBeNull();
144
+ });
145
+
146
+ it('returns null for single-label hosts', () => {
147
+ expect(registrableApex('intranet')).toBeNull();
148
+ expect(registrableApex('localhost')).toBeNull();
149
+ });
150
+
151
+ it('returns null for empty input', () => {
152
+ expect(registrableApex('')).toBeNull();
153
+ });
93
154
  });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Central SSO bounce helpers — per-origin key builders, the bounce URL builder,
3
+ * the central-IdP predicate, and the self-healing guard.
4
+ *
5
+ * These are a wire/storage contract shared with the IdP and every consumer, so
6
+ * the exact key strings, the 30s TTL, and the same-origin / parse-failure
7
+ * behaviour are all asserted explicitly.
8
+ */
9
+
10
+ import {
11
+ SSO_CALLBACK_PATH,
12
+ SSO_GUARD_TTL_MS,
13
+ ssoStateKey,
14
+ ssoGuardKey,
15
+ ssoDestKey,
16
+ ssoNoSessionKey,
17
+ buildSsoBounceUrl,
18
+ isCentralIdPOrigin,
19
+ guardActive,
20
+ } from '../ssoBounce';
21
+ import { CENTRAL_AUTH_URL } from '../authWebUrl';
22
+
23
+ describe('SSO bounce constants', () => {
24
+ it('pins the callback path and guard TTL (wire/storage contract)', () => {
25
+ expect(SSO_CALLBACK_PATH).toBe('/__oxy/sso-callback');
26
+ expect(SSO_GUARD_TTL_MS).toBe(30_000);
27
+ });
28
+ });
29
+
30
+ describe('per-origin key builders', () => {
31
+ const origin = 'https://mention.earth';
32
+
33
+ it('builds the exact contracted key strings', () => {
34
+ expect(ssoStateKey(origin)).toBe('oxy_sso_state:https://mention.earth');
35
+ expect(ssoGuardKey(origin)).toBe('oxy_sso_guard:https://mention.earth');
36
+ expect(ssoDestKey(origin)).toBe('oxy_sso_dest:https://mention.earth');
37
+ expect(ssoNoSessionKey(origin)).toBe('oxy_sso_no_session:https://mention.earth');
38
+ });
39
+
40
+ it('namespaces keys per origin so two RPs never collide', () => {
41
+ expect(ssoStateKey('https://a.test')).not.toBe(ssoStateKey('https://b.test'));
42
+ });
43
+ });
44
+
45
+ describe('buildSsoBounceUrl', () => {
46
+ const origin = 'https://mention.earth';
47
+
48
+ it('targets the central IdP /sso with all required params (default base)', () => {
49
+ const url = new URL(buildSsoBounceUrl(origin, 'state-123'));
50
+
51
+ expect(url.origin).toBe('https://auth.oxy.so');
52
+ expect(url.pathname).toBe('/sso');
53
+ expect(url.searchParams.get('prompt')).toBe('none');
54
+ expect(url.searchParams.get('client_id')).toBe(origin);
55
+ expect(url.searchParams.get('return_to')).toBe(origin + SSO_CALLBACK_PATH);
56
+ expect(url.searchParams.get('state')).toBe('state-123');
57
+ });
58
+
59
+ it('honours an explicit authWebUrl override (staging IdP)', () => {
60
+ const url = new URL(
61
+ buildSsoBounceUrl(origin, 'state-xyz', 'https://auth.mention.earth'),
62
+ );
63
+
64
+ expect(url.origin).toBe('https://auth.mention.earth');
65
+ expect(url.pathname).toBe('/sso');
66
+ expect(url.searchParams.get('client_id')).toBe(origin);
67
+ expect(url.searchParams.get('state')).toBe('state-xyz');
68
+ });
69
+
70
+ it('falls back to the central default for an empty override', () => {
71
+ const url = new URL(buildSsoBounceUrl(origin, 's', undefined));
72
+ expect(url.origin).toBe('https://auth.oxy.so');
73
+ });
74
+ });
75
+
76
+ describe('isCentralIdPOrigin', () => {
77
+ it('matches the central IdP origin', () => {
78
+ expect(isCentralIdPOrigin('https://auth.oxy.so')).toBe(true);
79
+ expect(isCentralIdPOrigin(CENTRAL_AUTH_URL)).toBe(true);
80
+ });
81
+
82
+ it('normalises a trailing-slash / path candidate via URL origin', () => {
83
+ expect(isCentralIdPOrigin('https://auth.oxy.so/')).toBe(true);
84
+ expect(isCentralIdPOrigin('https://auth.oxy.so/sso')).toBe(true);
85
+ });
86
+
87
+ it('rejects a non-central origin', () => {
88
+ expect(isCentralIdPOrigin('https://mention.earth')).toBe(false);
89
+ expect(isCentralIdPOrigin('https://auth.mention.earth')).toBe(false);
90
+ });
91
+
92
+ it('returns false for an unparseable candidate', () => {
93
+ expect(isCentralIdPOrigin('not a url')).toBe(false);
94
+ expect(isCentralIdPOrigin('')).toBe(false);
95
+ });
96
+ });
97
+
98
+ describe('guardActive', () => {
99
+ const origin = 'https://mention.earth';
100
+
101
+ function storageWith(value: string | null): Pick<Storage, 'getItem'> {
102
+ return { getItem: (key: string) => (key === ssoGuardKey(origin) ? value : null) };
103
+ }
104
+
105
+ it('is active for a present, fresh guard', () => {
106
+ const now = 1_000_000;
107
+ const storage = storageWith(String(now - 1_000)); // 1s old, well under TTL
108
+ expect(guardActive(storage, origin, now)).toBe(true);
109
+ });
110
+
111
+ it('is inactive for a present but stale guard (older than TTL)', () => {
112
+ const now = 1_000_000;
113
+ const storage = storageWith(String(now - SSO_GUARD_TTL_MS - 1));
114
+ expect(guardActive(storage, origin, now)).toBe(false);
115
+ });
116
+
117
+ it('is inactive for a guard exactly at the TTL boundary (strict <)', () => {
118
+ const now = 1_000_000;
119
+ const storage = storageWith(String(now - SSO_GUARD_TTL_MS));
120
+ expect(guardActive(storage, origin, now)).toBe(false);
121
+ });
122
+
123
+ it('is inactive when the guard is missing', () => {
124
+ expect(guardActive(storageWith(null), origin, 1_000_000)).toBe(false);
125
+ });
126
+
127
+ it('is inactive for an empty-string guard value', () => {
128
+ expect(guardActive(storageWith(''), origin, 1_000_000)).toBe(false);
129
+ });
130
+
131
+ it('is inactive for a malformed (non-numeric) guard value', () => {
132
+ expect(guardActive(storageWith('not-a-number'), origin, 1_000_000)).toBe(false);
133
+ });
134
+
135
+ it('is inactive (never throws) when getItem itself throws', () => {
136
+ const throwing: Pick<Storage, 'getItem'> = {
137
+ getItem: () => {
138
+ throw new Error('storage locked');
139
+ },
140
+ };
141
+ expect(guardActive(throwing, origin, 1_000_000)).toBe(false);
142
+ });
143
+
144
+ it('defaults now to Date.now() when omitted', () => {
145
+ const storage = storageWith(String(Date.now() - 500));
146
+ expect(guardActive(storage, origin)).toBe(true);
147
+ });
148
+ });
@@ -17,11 +17,23 @@
17
17
  * IdPs — it is central only. An explicitly-configured `authWebUrl` still wins.
18
18
  */
19
19
 
20
+ /**
21
+ * The registrable apex (eTLD+1) of the Oxy ecosystem's central Identity
22
+ * Provider. The central IdP is reachable at `auth.${CENTRAL_IDP_APEX}` and the
23
+ * ID-token assertion issuer is always `https://auth.${CENTRAL_IDP_APEX}`
24
+ * regardless of which per-apex `auth.<rp>` host served a given request.
25
+ *
26
+ * Kept as a standalone constant so the IdP worker and the SDK derive the same
27
+ * literal from one source of truth (the worker imports it to brand assertions).
28
+ */
29
+ export const CENTRAL_IDP_APEX = 'oxy.so';
30
+
20
31
  /**
21
32
  * The canonical central Identity Provider origin for the Oxy ecosystem.
22
- * No trailing slash.
33
+ * No trailing slash. Derived from {@link CENTRAL_IDP_APEX} so the apex and the
34
+ * full origin never drift apart.
23
35
  */
24
- export const CENTRAL_AUTH_URL = 'https://auth.oxy.so';
36
+ export const CENTRAL_AUTH_URL = `https://auth.${CENTRAL_IDP_APEX}`;
25
37
 
26
38
  /**
27
39
  * Resolve the central IdP origin, honouring an explicit override.