@oxyhq/core 2.1.1 → 2.2.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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/OxyServices.base.js +15 -3
- package/dist/cjs/index.js +9 -1
- package/dist/cjs/mixins/OxyServices.sso.js +142 -0
- package/dist/cjs/mixins/index.js +4 -0
- package/dist/cjs/utils/authWebUrl.js +37 -0
- package/dist/cjs/utils/ssoReturn.js +72 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/OxyServices.base.js +15 -3
- package/dist/esm/index.js +4 -0
- package/dist/esm/mixins/OxyServices.sso.js +138 -0
- package/dist/esm/mixins/index.js +4 -0
- package/dist/esm/utils/authWebUrl.js +33 -0
- package/dist/esm/utils/ssoReturn.js +69 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/OxyServices.d.ts +2 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/mixins/OxyServices.assets.d.ts +1 -3
- package/dist/types/mixins/OxyServices.sso.d.ts +111 -0
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/utils/authWebUrl.d.ts +31 -0
- package/dist/types/utils/ssoReturn.d.ts +46 -0
- package/package.json +1 -1
- package/src/OxyServices.base.ts +17 -3
- package/src/OxyServices.ts +4 -0
- package/src/index.ts +7 -0
- package/src/mixins/OxyServices.sso.ts +172 -0
- package/src/mixins/__tests__/constructorAuthWebUrl.test.ts +85 -0
- package/src/mixins/__tests__/sso.test.ts +146 -0
- package/src/mixins/index.ts +6 -0
- package/src/utils/__tests__/authWebUrl.test.ts +40 -0
- package/src/utils/__tests__/ssoReturn.test.ts +120 -0
- package/src/utils/authWebUrl.ts +35 -0
- package/src/utils/ssoReturn.ts +94 -0
|
@@ -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
|
+
});
|
package/src/mixins/index.ts
CHANGED
|
@@ -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
|
+
}
|