@oxyhq/core 2.1.2 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/OxyServices.base.js +9 -10
  3. package/dist/cjs/index.js +9 -1
  4. package/dist/cjs/mixins/OxyServices.popup.js +15 -4
  5. package/dist/cjs/mixins/OxyServices.sso.js +142 -0
  6. package/dist/cjs/mixins/index.js +4 -0
  7. package/dist/cjs/utils/authWebUrl.js +37 -0
  8. package/dist/cjs/utils/ssoReturn.js +72 -0
  9. package/dist/esm/.tsbuildinfo +1 -1
  10. package/dist/esm/OxyServices.base.js +9 -10
  11. package/dist/esm/index.js +4 -0
  12. package/dist/esm/mixins/OxyServices.popup.js +15 -4
  13. package/dist/esm/mixins/OxyServices.sso.js +138 -0
  14. package/dist/esm/mixins/index.js +4 -0
  15. package/dist/esm/utils/authWebUrl.js +33 -0
  16. package/dist/esm/utils/ssoReturn.js +69 -0
  17. package/dist/types/.tsbuildinfo +1 -1
  18. package/dist/types/OxyServices.d.ts +2 -0
  19. package/dist/types/index.d.ts +4 -0
  20. package/dist/types/mixins/OxyServices.assets.d.ts +1 -3
  21. package/dist/types/mixins/OxyServices.popup.d.ts +19 -0
  22. package/dist/types/mixins/OxyServices.sso.d.ts +111 -0
  23. package/dist/types/mixins/index.d.ts +2 -1
  24. package/dist/types/utils/authWebUrl.d.ts +31 -0
  25. package/dist/types/utils/ssoReturn.d.ts +46 -0
  26. package/package.json +1 -1
  27. package/src/OxyServices.base.ts +9 -10
  28. package/src/OxyServices.ts +4 -0
  29. package/src/index.ts +7 -0
  30. package/src/mixins/OxyServices.popup.ts +36 -4
  31. package/src/mixins/OxyServices.sso.ts +172 -0
  32. package/src/mixins/__tests__/constructorAuthWebUrl.test.ts +32 -55
  33. package/src/mixins/__tests__/sso.test.ts +146 -0
  34. package/src/mixins/index.ts +6 -0
  35. package/src/utils/__tests__/authWebUrl.test.ts +40 -0
  36. package/src/utils/__tests__/ssoReturn.test.ts +120 -0
  37. package/src/utils/authWebUrl.ts +35 -0
  38. package/src/utils/ssoReturn.ts +94 -0
@@ -1,29 +1,26 @@
1
1
  /**
2
- * `OxyServices` constructor first-party IdP auto-detection.
2
+ * `OxyServices` constructor central-IdP defaulting.
3
3
  *
4
- * `@oxyhq/services` 8.2.0 added cross-domain SSO that auto-detects the IdP as
5
- * `https://auth.<rp-apex>` via `autoDetectAuthWebUrl()`. That detection used to
6
- * run ONLY on the provider-`baseURL` branch of OxyContext — apps that construct
7
- * their OWN `OxyServices` instance and pass it to
8
- * `<OxyProvider oxyServices={...} />` never hit it, so an omitted `authWebUrl`
9
- * fell back to the hardcoded `DEFAULT_AUTH_URL` ('https://auth.oxy.so'),
10
- * forcing a third-party IdP and breaking Safari/Firefox cross-domain restore.
4
+ * TRUE central cross-domain SSO (Google/Meta/Clerk style, 2026-06-13) routes
5
+ * every Relying Party through ONE central IdP at `auth.oxy.so` it owns the
6
+ * host-only `fedcm_session` cookie and the central session store. The SDK
7
+ * therefore defaults `authWebUrl` to the central IdP when the caller omits it,
8
+ * via `resolveCentralAuthUrl(config.authWebUrl)`.
11
9
  *
12
- * The constructor now derives `authWebUrl` itself when the caller omits it, so
13
- * BOTH construction paths behave identically:
14
- * - web at `https://mention.earth` -> `https://auth.mention.earth`
15
- * - native/SSR (no `window`) -> undefined (mixins fall back to
16
- * `DEFAULT_AUTH_URL`, exactly as before)
17
- * - explicit `authWebUrl` -> respected verbatim (explicit wins)
10
+ * This replaces the previous behaviour, where the constructor auto-detected a
11
+ * per-apex IdP (`auth.<rp-apex>`) from `window.location`. `autoDetectAuthWebUrl`
12
+ * is still exported for call sites that opt into per-apex resolution, but it is
13
+ * NO LONGER the constructor default.
14
+ *
15
+ * Contract:
16
+ * - authWebUrl omitted, no window (native/SSR) -> 'https://auth.oxy.so'
17
+ * - authWebUrl omitted, window present -> 'https://auth.oxy.so'
18
+ * (the page host is irrelevant — central only)
19
+ * - authWebUrl explicit -> respected verbatim (wins)
18
20
  */
19
21
 
20
22
  import { OxyServices } from '../../OxyServices';
21
-
22
- // The hardcoded fallback the auth mixins resolve to when `authWebUrl` is unset
23
- // (`this.config.authWebUrl || DEFAULT_AUTH_URL`). Mirrors the static
24
- // `DEFAULT_AUTH_URL` on the redirect/popup mixins. Native/SSR must keep
25
- // resolving to this exact value after the constructor auto-detect change.
26
- const DEFAULT_AUTH_URL = 'https://auth.oxy.so';
23
+ import { CENTRAL_AUTH_URL } from '../../utils/authWebUrl';
27
24
 
28
25
  function installWindowLocation(hostname: string, protocol = 'https:'): void {
29
26
  (globalThis as unknown as { window: unknown }).window = {
@@ -35,74 +32,54 @@ function clearWindow(): void {
35
32
  delete (globalThis as Record<string, unknown>).window;
36
33
  }
37
34
 
38
- describe('OxyServices constructor — first-party authWebUrl auto-detection', () => {
35
+ describe('OxyServices constructor — central IdP defaulting', () => {
39
36
  afterEach(() => {
40
37
  clearWindow();
41
38
  });
42
39
 
43
- it('leaves authWebUrl undefined on native/SSR (no window)', () => {
44
- // No `window` is installed -> autoDetectAuthWebUrl() returns undefined.
40
+ it('defaults authWebUrl to the central IdP on native/SSR (no window)', () => {
45
41
  const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
46
42
 
47
- expect(oxy.config.authWebUrl).toBeUndefined();
48
- // Auth flows must still resolve to the hardcoded default on native.
49
- expect(oxy.config.authWebUrl || DEFAULT_AUTH_URL).toBe('https://auth.oxy.so');
43
+ expect(oxy.config.authWebUrl).toBe(CENTRAL_AUTH_URL);
44
+ expect(oxy.config.authWebUrl).toBe('https://auth.oxy.so');
50
45
  });
51
46
 
52
- it('derives auth.<apex> on web when authWebUrl is omitted', () => {
47
+ it('defaults to the central IdP on web regardless of page host', () => {
48
+ // The page is mention.earth, but central SSO never derives a per-apex IdP.
53
49
  installWindowLocation('mention.earth');
54
50
 
55
51
  const oxy = new OxyServices({ baseURL: 'https://api.mention.earth' });
56
52
 
57
- expect(oxy.config.authWebUrl).toBe('https://auth.mention.earth');
53
+ expect(oxy.config.authWebUrl).toBe('https://auth.oxy.so');
58
54
  });
59
55
 
60
- it('strips a leading subdomain down to the apex on web', () => {
56
+ it('defaults to the central IdP even on a subdomain page host', () => {
61
57
  installWindowLocation('www.homiio.com');
62
58
 
63
59
  const oxy = new OxyServices({ baseURL: 'https://api.homiio.com' });
64
60
 
65
- expect(oxy.config.authWebUrl).toBe('https://auth.homiio.com');
66
- });
67
-
68
- it('derives auth.<host> for preview hosts (e.g. *.pages.dev)', () => {
69
- installWindowLocation('foo.pages.dev');
70
-
71
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
72
-
73
- expect(oxy.config.authWebUrl).toBe('https://auth.pages.dev');
61
+ expect(oxy.config.authWebUrl).toBe('https://auth.oxy.so');
74
62
  });
75
63
 
76
- it('respects an explicit authWebUrl even when a window is present (explicit wins)', () => {
64
+ it('respects an explicit authWebUrl (explicit wins)', () => {
77
65
  installWindowLocation('mention.earth');
78
66
 
79
67
  const oxy = new OxyServices({
80
68
  baseURL: 'https://api.mention.earth',
81
- authWebUrl: 'https://auth.oxy.so',
69
+ authWebUrl: 'https://auth.mention.earth',
82
70
  });
83
71
 
84
- // The page is mention.earth, but the caller pinned auth.oxy.so — honour it.
85
- expect(oxy.config.authWebUrl).toBe('https://auth.oxy.so');
72
+ // The caller pinned a per-apex IdP explicitly — honour it verbatim.
73
+ expect(oxy.config.authWebUrl).toBe('https://auth.mention.earth');
86
74
  });
87
75
 
88
76
  it('does not mutate the caller-supplied config object', () => {
89
- installWindowLocation('mention.earth');
90
-
91
77
  const input = { baseURL: 'https://api.mention.earth' };
92
78
  const oxy = new OxyServices(input);
93
79
 
94
- // The stored config carries the detected IdP...
95
- expect(oxy.config.authWebUrl).toBe('https://auth.mention.earth');
80
+ // The stored config carries the central IdP default...
81
+ expect(oxy.config.authWebUrl).toBe('https://auth.oxy.so');
96
82
  // ...but the caller's own object reference is untouched.
97
83
  expect((input as { authWebUrl?: string }).authWebUrl).toBeUndefined();
98
84
  });
99
-
100
- it('falls back to DEFAULT_AUTH_URL on host shapes auto-detect declines (localhost)', () => {
101
- installWindowLocation('localhost', 'http:');
102
-
103
- const oxy = new OxyServices({ baseURL: 'http://localhost:3000' });
104
-
105
- expect(oxy.config.authWebUrl).toBeUndefined();
106
- expect(oxy.config.authWebUrl || DEFAULT_AUTH_URL).toBe('https://auth.oxy.so');
107
- });
108
85
  });
@@ -0,0 +1,146 @@
1
+ /**
2
+ * `OxyServices.exchangeSsoCode` / `generateSsoState` — central SSO client.
3
+ *
4
+ * `exchangeSsoCode(code)` POSTs the opaque single-use code to the session base
5
+ * URL (`getSessionBaseUrl()` — i.e. `api.oxy.so` by default) at `/sso/exchange`
6
+ * with NO credentials, then plants the returned access token via
7
+ * `httpService.setTokens(...)` — mirroring `exchangeIdTokenForSession` /
8
+ * `verifyChallenge`. NO token/JWT ever travels in the URL; the real token only
9
+ * arrives in this exchange response body.
10
+ */
11
+
12
+ import { OxyServices } from '../../OxyServices';
13
+ import { generateSsoState } from '../OxyServices.sso';
14
+
15
+ interface FetchCall {
16
+ url: string;
17
+ init: RequestInit;
18
+ }
19
+
20
+ function mockFetchOnce(body: unknown, ok = true, status = 200): { calls: FetchCall[] } {
21
+ const calls: FetchCall[] = [];
22
+ const fetchMock = jest.fn(async (url: string, init: RequestInit) => {
23
+ calls.push({ url, init });
24
+ return {
25
+ ok,
26
+ status,
27
+ json: async () => body,
28
+ } as unknown as Response;
29
+ });
30
+ (globalThis as unknown as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
31
+ return { calls };
32
+ }
33
+
34
+ const VALID_BODY = {
35
+ accessToken: 'access_sso',
36
+ sessionId: 'sess_sso',
37
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
38
+ authuser: 0,
39
+ user: { id: 'user_sso', username: 'ssouser', avatar: 'file_1' },
40
+ };
41
+
42
+ describe('OxyServices.exchangeSsoCode', () => {
43
+ const originalFetch = globalThis.fetch;
44
+
45
+ afterEach(() => {
46
+ (globalThis as unknown as { fetch: typeof fetch }).fetch = originalFetch;
47
+ jest.restoreAllMocks();
48
+ });
49
+
50
+ it('POSTs the code to the API base /sso/exchange with no credentials', async () => {
51
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
52
+ const { calls } = mockFetchOnce(VALID_BODY);
53
+
54
+ await oxy.exchangeSsoCode('opaque-code-123');
55
+
56
+ expect(calls).toHaveLength(1);
57
+ expect(calls[0].url).toBe('https://api.oxy.so/sso/exchange');
58
+ expect(calls[0].init.method).toBe('POST');
59
+ expect(calls[0].init.credentials).toBe('omit');
60
+ expect(JSON.parse(String(calls[0].init.body))).toEqual({ code: 'opaque-code-123' });
61
+ });
62
+
63
+ it('targets the configured sessionBaseUrl when set', async () => {
64
+ const oxy = new OxyServices({
65
+ baseURL: 'https://api.oxy.so',
66
+ sessionBaseUrl: 'https://api.mention.earth',
67
+ });
68
+ const { calls } = mockFetchOnce(VALID_BODY);
69
+
70
+ await oxy.exchangeSsoCode('opaque-code-123');
71
+
72
+ expect(calls[0].url).toBe('https://api.mention.earth/sso/exchange');
73
+ });
74
+
75
+ it('plants the access token via setTokens and returns the session', async () => {
76
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
77
+ expect(oxy.hasValidToken()).toBe(false);
78
+ mockFetchOnce(VALID_BODY);
79
+
80
+ const session = await oxy.exchangeSsoCode('opaque-code-123');
81
+
82
+ expect(oxy.hasValidToken()).toBe(true);
83
+ expect(oxy.getAccessToken()).toBe('access_sso');
84
+ expect(session.sessionId).toBe('sess_sso');
85
+ expect(session.accessToken).toBe('access_sso');
86
+ expect(session.user).toEqual({ id: 'user_sso', username: 'ssouser', avatar: 'file_1' });
87
+ expect(session.expiresAt).toBe(VALID_BODY.expiresAt);
88
+ });
89
+
90
+ it('maps a user delivered as _id', async () => {
91
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
92
+ mockFetchOnce({
93
+ accessToken: 'access_sso',
94
+ sessionId: 'sess_sso',
95
+ user: { _id: 'mongo_id', username: 'ssouser' },
96
+ });
97
+
98
+ const session = await oxy.exchangeSsoCode('opaque-code-123');
99
+
100
+ expect(session.user.id).toBe('mongo_id');
101
+ });
102
+
103
+ it('rejects an empty code without calling fetch', async () => {
104
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
105
+ const { calls } = mockFetchOnce(VALID_BODY);
106
+
107
+ await expect(oxy.exchangeSsoCode('')).rejects.toThrow();
108
+ expect(calls).toHaveLength(0);
109
+ });
110
+
111
+ it('throws and does not plant a token on a non-2xx response', async () => {
112
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
113
+ mockFetchOnce({ error: 'invalid_code' }, false, 400);
114
+
115
+ await expect(oxy.exchangeSsoCode('bad-code')).rejects.toThrow();
116
+ expect(oxy.hasValidToken()).toBe(false);
117
+ });
118
+
119
+ it('throws when the response carries no access token', async () => {
120
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
121
+ mockFetchOnce({ sessionId: 'sess_sso', user: { id: 'u', username: 'x' } });
122
+
123
+ await expect(oxy.exchangeSsoCode('opaque-code-123')).rejects.toThrow();
124
+ expect(oxy.hasValidToken()).toBe(false);
125
+ });
126
+ });
127
+
128
+ describe('generateSsoState', () => {
129
+ it('returns a non-empty unique string (module-level helper)', () => {
130
+ const a = generateSsoState();
131
+ const b = generateSsoState();
132
+
133
+ expect(typeof a).toBe('string');
134
+ expect(a.length).toBeGreaterThan(0);
135
+ expect(a).not.toBe(b);
136
+ });
137
+
138
+ it('is also reachable as an instance method delegating to generateState', () => {
139
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
140
+
141
+ const state = oxy.generateSsoState();
142
+
143
+ expect(typeof state).toBe('string');
144
+ expect(state.length).toBeGreaterThan(0);
145
+ });
146
+ });
@@ -10,6 +10,7 @@ import { OxyServicesAuthMixin } from './OxyServices.auth';
10
10
  import { OxyServicesFedCMMixin } from './OxyServices.fedcm';
11
11
  import { OxyServicesPopupAuthMixin } from './OxyServices.popup';
12
12
  import { OxyServicesRedirectAuthMixin } from './OxyServices.redirect';
13
+ import { OxyServicesSsoMixin } from './OxyServices.sso';
13
14
  import { OxyServicesUserMixin } from './OxyServices.user';
14
15
  import { OxyServicesPrivacyMixin } from './OxyServices.privacy';
15
16
  import { OxyServicesLanguageMixin } from './OxyServices.language';
@@ -42,6 +43,7 @@ type AllMixinInstances =
42
43
  & InstanceType<ReturnType<typeof OxyServicesFedCMMixin<typeof OxyServicesBase>>>
43
44
  & InstanceType<ReturnType<typeof OxyServicesPopupAuthMixin<typeof OxyServicesBase>>>
44
45
  & InstanceType<ReturnType<typeof OxyServicesRedirectAuthMixin<typeof OxyServicesBase>>>
46
+ & InstanceType<ReturnType<typeof OxyServicesSsoMixin<typeof OxyServicesBase>>>
45
47
  & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>>
46
48
  & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>>
47
49
  & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>>
@@ -99,6 +101,10 @@ const MIXIN_PIPELINE: MixinFunction[] = [
99
101
  OxyServicesPopupAuthMixin,
100
102
  OxyServicesRedirectAuthMixin,
101
103
 
104
+ // Central cross-domain SSO (opaque-code exchange). After Popup so it can
105
+ // reuse the popup mixin's secure-random `generateState()`.
106
+ OxyServicesSsoMixin,
107
+
102
108
  // User management (requires auth)
103
109
  OxyServicesUserMixin,
104
110
  OxyServicesPrivacyMixin,
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `resolveCentralAuthUrl` / `CENTRAL_AUTH_URL` — central IdP resolution.
3
+ *
4
+ * Pure helper: an explicit non-empty value always wins; otherwise the central
5
+ * `auth.oxy.so` origin is returned. No DOM, no side effects.
6
+ */
7
+
8
+ import { CENTRAL_AUTH_URL, resolveCentralAuthUrl } from '../authWebUrl';
9
+
10
+ describe('CENTRAL_AUTH_URL', () => {
11
+ it('is the central IdP origin with no trailing slash', () => {
12
+ expect(CENTRAL_AUTH_URL).toBe('https://auth.oxy.so');
13
+ expect(CENTRAL_AUTH_URL.endsWith('/')).toBe(false);
14
+ });
15
+ });
16
+
17
+ describe('resolveCentralAuthUrl', () => {
18
+ it('returns the central default when no explicit value is given', () => {
19
+ expect(resolveCentralAuthUrl()).toBe(CENTRAL_AUTH_URL);
20
+ expect(resolveCentralAuthUrl(undefined)).toBe('https://auth.oxy.so');
21
+ });
22
+
23
+ it('returns the explicit value when provided (explicit wins)', () => {
24
+ expect(resolveCentralAuthUrl('https://auth.mention.earth')).toBe(
25
+ 'https://auth.mention.earth',
26
+ );
27
+ });
28
+
29
+ it('does not read any ambient DOM/window state', () => {
30
+ // Even with a window installed, the result is purely a function of the arg.
31
+ (globalThis as unknown as { window: unknown }).window = {
32
+ location: { hostname: 'mention.earth', protocol: 'https:' },
33
+ };
34
+ try {
35
+ expect(resolveCentralAuthUrl()).toBe('https://auth.oxy.so');
36
+ } finally {
37
+ delete (globalThis as Record<string, unknown>).window;
38
+ }
39
+ });
40
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * `parseSsoReturnFragment` — SSO return fragment parsing.
3
+ *
4
+ * The central IdP returns the RP via a top-level redirect with the bounce
5
+ * result in the URL fragment. The parser must be pure, total (never throws),
6
+ * and report `kind` strictly as one of `'ok' | 'none' | 'error'`, returning
7
+ * `null` for anything that is not an oxy_sso fragment.
8
+ */
9
+
10
+ import { parseSsoReturnFragment } from '../ssoReturn';
11
+
12
+ describe('parseSsoReturnFragment', () => {
13
+ describe('ok', () => {
14
+ it('parses a success fragment with code and state', () => {
15
+ const result = parseSsoReturnFragment('#oxy_sso=ok&code=abc123&state=xyz');
16
+
17
+ expect(result).toEqual({ kind: 'ok', code: 'abc123', state: 'xyz' });
18
+ });
19
+
20
+ it('parses a success fragment without a leading #', () => {
21
+ const result = parseSsoReturnFragment('oxy_sso=ok&code=abc123&state=xyz');
22
+
23
+ expect(result).toEqual({ kind: 'ok', code: 'abc123', state: 'xyz' });
24
+ });
25
+
26
+ it('omits code when ok carries no code', () => {
27
+ const result = parseSsoReturnFragment('#oxy_sso=ok&state=xyz');
28
+
29
+ expect(result).toEqual({ kind: 'ok', state: 'xyz' });
30
+ expect(result?.code).toBeUndefined();
31
+ });
32
+
33
+ it('URL-decodes percent-encoded values', () => {
34
+ const result = parseSsoReturnFragment('#oxy_sso=ok&code=a%2Bb%2Fc&state=s%20t');
35
+
36
+ expect(result).toEqual({ kind: 'ok', code: 'a+b/c', state: 's t' });
37
+ });
38
+ });
39
+
40
+ describe('none', () => {
41
+ it('parses a none fragment and carries state but never a code', () => {
42
+ const result = parseSsoReturnFragment('#oxy_sso=none&state=xyz');
43
+
44
+ expect(result).toEqual({ kind: 'none', state: 'xyz' });
45
+ });
46
+
47
+ it('ignores a stray code on a none outcome', () => {
48
+ const result = parseSsoReturnFragment('#oxy_sso=none&code=leaked&state=xyz');
49
+
50
+ expect(result).toEqual({ kind: 'none', state: 'xyz' });
51
+ expect(result?.code).toBeUndefined();
52
+ });
53
+ });
54
+
55
+ describe('error', () => {
56
+ it('parses an error fragment', () => {
57
+ const result = parseSsoReturnFragment('#oxy_sso=error&state=xyz');
58
+
59
+ expect(result).toEqual({ kind: 'error', state: 'xyz' });
60
+ });
61
+
62
+ it('ignores a stray code on an error outcome', () => {
63
+ const result = parseSsoReturnFragment('#oxy_sso=error&code=leaked');
64
+
65
+ expect(result).toEqual({ kind: 'error' });
66
+ expect(result?.code).toBeUndefined();
67
+ });
68
+ });
69
+
70
+ describe('null (not an oxy_sso fragment)', () => {
71
+ it('returns null for an empty string', () => {
72
+ expect(parseSsoReturnFragment('')).toBeNull();
73
+ });
74
+
75
+ it('returns null for a bare #', () => {
76
+ expect(parseSsoReturnFragment('#')).toBeNull();
77
+ });
78
+
79
+ it('returns null for undefined', () => {
80
+ expect(parseSsoReturnFragment(undefined)).toBeNull();
81
+ });
82
+
83
+ it('returns null for null', () => {
84
+ expect(parseSsoReturnFragment(null)).toBeNull();
85
+ });
86
+
87
+ it('returns null for a fragment without oxy_sso', () => {
88
+ expect(parseSsoReturnFragment('#access_token=foo&state=bar')).toBeNull();
89
+ });
90
+
91
+ it('returns null for an unrecognised oxy_sso value', () => {
92
+ expect(parseSsoReturnFragment('#oxy_sso=bogus&code=x')).toBeNull();
93
+ });
94
+
95
+ it('returns null for an empty oxy_sso value', () => {
96
+ expect(parseSsoReturnFragment('#oxy_sso=&code=x')).toBeNull();
97
+ });
98
+ });
99
+
100
+ describe('malformed / defensive', () => {
101
+ it('never throws and returns a valid kind for junk after the marker', () => {
102
+ const result = parseSsoReturnFragment('#oxy_sso=ok&=&&&code=c&&');
103
+
104
+ expect(result?.kind).toBe('ok');
105
+ expect(result?.code).toBe('c');
106
+ });
107
+
108
+ it('always reports a kind in the strict union', () => {
109
+ for (const input of [
110
+ '#oxy_sso=ok',
111
+ '#oxy_sso=none',
112
+ '#oxy_sso=error',
113
+ ]) {
114
+ const result = parseSsoReturnFragment(input);
115
+ expect(result).not.toBeNull();
116
+ expect(['ok', 'none', 'error']).toContain(result?.kind);
117
+ }
118
+ });
119
+ });
120
+ });
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Central IdP (auth web) URL resolution for cross-domain SSO.
3
+ *
4
+ * The Oxy ecosystem runs a single, central Identity Provider at
5
+ * `auth.oxy.so`. For TRUE central cross-domain SSO (Google/Meta/Clerk style),
6
+ * FedCM and the opaque-code SSO bounce always target this one origin — it owns
7
+ * the host-only `fedcm_session` cookie and the central session store reachable
8
+ * via `api.oxy.so`. Relying Parties (mention.earth, homiio.com, alia.onl, …)
9
+ * delegate to it rather than standing up a per-apex IdP.
10
+ *
11
+ * This module is intentionally pure: it performs no DOM access, reads no
12
+ * `window`/`location`, and has no side effects. It is the single source of
13
+ * truth for the central IdP origin so call sites never hardcode the literal.
14
+ *
15
+ * Note: this is distinct from `autoDetectAuthWebUrl` (per-apex `auth.<rp-apex>`
16
+ * derivation). The central-SSO path deliberately does NOT auto-detect per-apex
17
+ * IdPs — it is central only. An explicitly-configured `authWebUrl` still wins.
18
+ */
19
+
20
+ /**
21
+ * The canonical central Identity Provider origin for the Oxy ecosystem.
22
+ * No trailing slash.
23
+ */
24
+ export const CENTRAL_AUTH_URL = 'https://auth.oxy.so';
25
+
26
+ /**
27
+ * Resolve the central IdP origin, honouring an explicit override.
28
+ *
29
+ * @param explicit - A caller-supplied auth web URL, or `undefined`/empty to use
30
+ * the central default. An explicit non-empty value always wins.
31
+ * @returns The explicit value when provided, otherwise {@link CENTRAL_AUTH_URL}.
32
+ */
33
+ export function resolveCentralAuthUrl(explicit?: string): string {
34
+ return explicit ?? CENTRAL_AUTH_URL;
35
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Parse the SSO return fragment delivered by the central IdP.
3
+ *
4
+ * After a top-level redirect bounce to `auth.oxy.so/sso` (prompt=none), the
5
+ * central IdP returns the Relying Party to its `redirect_uri` with the result
6
+ * encoded in the URL fragment (the `#…` part). The fragment is used — not a
7
+ * query string — so the opaque single-use code never reaches a server access
8
+ * log, a `Referer` header, or browser history in a recoverable form.
9
+ *
10
+ * Three outcomes are possible:
11
+ * - `#oxy_sso=ok&code=<opaque>&state=<state>` — the IdP had a session; the RP
12
+ * exchanges `code` (via `oxy.exchangeSsoCode`) for the real session. NO
13
+ * token/JWT ever appears in the URL — only the opaque code.
14
+ * - `#oxy_sso=none&state=<state>` — the IdP had no session (prompt=none, user
15
+ * not signed in centrally). The RP shows its own signed-out UI.
16
+ * - `#oxy_sso=error&state=<state>` — the bounce failed. The RP recovers.
17
+ *
18
+ * This parser is pure and defensive: it never throws, and `kind` is strictly
19
+ * one of `'ok' | 'none' | 'error'`. It returns `null` when the fragment is not
20
+ * an oxy_sso fragment at all (i.e. `oxy_sso` is absent or an unrecognised
21
+ * value), so the caller can ignore unrelated fragments without special-casing.
22
+ */
23
+
24
+ /**
25
+ * The recognised outcomes of an SSO bounce.
26
+ */
27
+ export type SsoReturnKind = 'ok' | 'none' | 'error';
28
+
29
+ /**
30
+ * The parsed result of an SSO return fragment.
31
+ *
32
+ * `code` is present only for `kind: 'ok'`. `state` echoes the CSRF state the RP
33
+ * generated for the bounce (when the IdP round-tripped it).
34
+ */
35
+ export interface SsoReturnResult {
36
+ kind: SsoReturnKind;
37
+ code?: string;
38
+ state?: string;
39
+ }
40
+
41
+ const VALID_KINDS: ReadonlySet<string> = new Set<SsoReturnKind>(['ok', 'none', 'error']);
42
+
43
+ /**
44
+ * Parse an SSO return fragment.
45
+ *
46
+ * @param hash - The URL fragment, with or without the leading `#`
47
+ * (e.g. `location.hash`). May be `undefined`/empty.
48
+ * @returns The parsed result when `hash` is a recognised oxy_sso fragment,
49
+ * otherwise `null`. Never throws.
50
+ */
51
+ export function parseSsoReturnFragment(hash: string | undefined | null): SsoReturnResult | null {
52
+ if (typeof hash !== 'string' || hash.length === 0) {
53
+ return null;
54
+ }
55
+
56
+ // Strip a single leading '#'. A bare '#' (empty fragment) yields no params.
57
+ const raw = hash.startsWith('#') ? hash.slice(1) : hash;
58
+ if (raw.length === 0) {
59
+ return null;
60
+ }
61
+
62
+ let params: URLSearchParams;
63
+ try {
64
+ params = new URLSearchParams(raw);
65
+ } catch {
66
+ // URLSearchParams does not throw for malformed input in practice, but guard
67
+ // against any environment/polyfill that might so this stays total.
68
+ return null;
69
+ }
70
+
71
+ const kind = params.get('oxy_sso');
72
+ if (kind === null || !VALID_KINDS.has(kind)) {
73
+ // Not an oxy_sso fragment (absent or unrecognised value) — ignore it.
74
+ return null;
75
+ }
76
+
77
+ const result: SsoReturnResult = { kind: kind as SsoReturnKind };
78
+
79
+ const state = params.get('state');
80
+ if (state !== null && state.length > 0) {
81
+ result.state = state;
82
+ }
83
+
84
+ // The opaque code is only meaningful on success; ignore any stray `code` on
85
+ // none/error so callers never attempt an exchange for a non-ok outcome.
86
+ if (result.kind === 'ok') {
87
+ const code = params.get('code');
88
+ if (code !== null && code.length > 0) {
89
+ result.code = code;
90
+ }
91
+ }
92
+
93
+ return result;
94
+ }