@oxyhq/core 2.2.0 → 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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +17 -1
- package/dist/cjs/mixins/OxyServices.auth.js +45 -0
- package/dist/cjs/mixins/OxyServices.popup.js +15 -4
- package/dist/cjs/mixins/OxyServices.user.js +15 -5
- package/dist/cjs/utils/authWebUrl.js +14 -3
- package/dist/cjs/utils/fapiAutoDetect.js +47 -6
- package/dist/cjs/utils/ssoBounce.js +192 -0
- package/dist/cjs/utils/ssoReturn.js +111 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/index.js +5 -3
- package/dist/esm/mixins/OxyServices.auth.js +45 -0
- package/dist/esm/mixins/OxyServices.popup.js +15 -4
- package/dist/esm/mixins/OxyServices.user.js +15 -5
- package/dist/esm/utils/authWebUrl.js +13 -2
- package/dist/esm/utils/fapiAutoDetect.js +45 -6
- package/dist/esm/utils/ssoBounce.js +181 -0
- package/dist/esm/utils/ssoReturn.js +110 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +5 -4
- package/dist/types/mixins/OxyServices.auth.d.ts +35 -0
- package/dist/types/mixins/OxyServices.popup.d.ts +19 -0
- package/dist/types/mixins/OxyServices.user.d.ts +7 -0
- package/dist/types/utils/authWebUrl.d.ts +12 -1
- package/dist/types/utils/fapiAutoDetect.d.ts +36 -0
- package/dist/types/utils/ssoBounce.d.ts +124 -0
- package/dist/types/utils/ssoReturn.d.ts +65 -0
- package/package.json +1 -1
- package/src/index.ts +18 -4
- package/src/mixins/OxyServices.auth.ts +54 -0
- package/src/mixins/OxyServices.popup.ts +36 -4
- package/src/mixins/OxyServices.user.ts +14 -5
- package/src/mixins/__tests__/serviceAuth.test.ts +92 -0
- package/src/utils/__tests__/authWebUrl.test.ts +11 -1
- package/src/utils/__tests__/consumeSsoReturn.test.ts +401 -0
- package/src/utils/__tests__/fapiAutoDetect.test.ts +62 -1
- package/src/utils/__tests__/ssoBounce.test.ts +148 -0
- package/src/utils/authWebUrl.ts +14 -2
- package/src/utils/fapiAutoDetect.ts +41 -5
- package/src/utils/ssoBounce.ts +198 -0
- package/src/utils/ssoReturn.ts +168 -0
|
@@ -332,6 +332,98 @@ describe('H1: getServiceToken per-credential cache + secret verification', () =>
|
|
|
332
332
|
});
|
|
333
333
|
});
|
|
334
334
|
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// invalidateServiceToken — clears the cached service token so the next
|
|
337
|
+
// getServiceToken() mints anew, enabling recovery from a mid-run 401 (e.g.
|
|
338
|
+
// credential revocation) without waiting for natural token expiry.
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
describe('invalidateServiceToken: forces a fresh mint after a same-run 401', () => {
|
|
342
|
+
let oxy: OxyServices;
|
|
343
|
+
let makeRequestSpy: jest.SpyInstance;
|
|
344
|
+
|
|
345
|
+
beforeEach(() => {
|
|
346
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
347
|
+
makeRequestSpy = jest.spyOn(oxy as unknown as { makeRequest: jest.Mock }, 'makeRequest');
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
afterEach(() => {
|
|
351
|
+
makeRequestSpy.mockRestore();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('re-mints on the next getServiceToken() after invalidation (configured credential)', async () => {
|
|
355
|
+
makeRequestSpy
|
|
356
|
+
.mockResolvedValueOnce({ token: 'token-first', expiresIn: 3600, appName: 'tenant-A' })
|
|
357
|
+
.mockResolvedValueOnce({ token: 'token-second', expiresIn: 3600, appName: 'tenant-A' });
|
|
358
|
+
|
|
359
|
+
oxy.configureServiceAuth('key-A', 'secret-A');
|
|
360
|
+
|
|
361
|
+
const first = await oxy.getServiceToken();
|
|
362
|
+
// Cached — would normally be returned again without re-minting.
|
|
363
|
+
const cached = await oxy.getServiceToken();
|
|
364
|
+
expect(first).toBe('token-first');
|
|
365
|
+
expect(cached).toBe('token-first');
|
|
366
|
+
expect(makeRequestSpy).toHaveBeenCalledTimes(1);
|
|
367
|
+
|
|
368
|
+
// Simulate a 401: invalidate, then the very next call must mint anew.
|
|
369
|
+
oxy.invalidateServiceToken();
|
|
370
|
+
|
|
371
|
+
const fresh = await oxy.getServiceToken();
|
|
372
|
+
expect(fresh).toBe('token-second');
|
|
373
|
+
expect(makeRequestSpy).toHaveBeenCalledTimes(2);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('clears only the targeted apiKey entry, leaving other tenants cached', async () => {
|
|
377
|
+
makeRequestSpy
|
|
378
|
+
.mockResolvedValueOnce({ token: 'token-A1', expiresIn: 3600, appName: 'tenant-A' })
|
|
379
|
+
.mockResolvedValueOnce({ token: 'token-B1', expiresIn: 3600, appName: 'tenant-B' })
|
|
380
|
+
.mockResolvedValueOnce({ token: 'token-A2', expiresIn: 3600, appName: 'tenant-A' });
|
|
381
|
+
|
|
382
|
+
await oxy.getServiceToken('key-A', 'secret-A');
|
|
383
|
+
await oxy.getServiceToken('key-B', 'secret-B');
|
|
384
|
+
expect(makeRequestSpy).toHaveBeenCalledTimes(2);
|
|
385
|
+
|
|
386
|
+
// Invalidate only tenant A.
|
|
387
|
+
oxy.invalidateServiceToken('key-A');
|
|
388
|
+
|
|
389
|
+
// Tenant A re-mints...
|
|
390
|
+
const a2 = await oxy.getServiceToken('key-A', 'secret-A');
|
|
391
|
+
expect(a2).toBe('token-A2');
|
|
392
|
+
expect(makeRequestSpy).toHaveBeenCalledTimes(3);
|
|
393
|
+
|
|
394
|
+
// ...tenant B is still cached (no extra mint).
|
|
395
|
+
const b1 = await oxy.getServiceToken('key-B', 'secret-B');
|
|
396
|
+
expect(b1).toBe('token-B1');
|
|
397
|
+
expect(makeRequestSpy).toHaveBeenCalledTimes(3);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('clears every entry when no key is configured and none is passed', async () => {
|
|
401
|
+
makeRequestSpy
|
|
402
|
+
.mockResolvedValueOnce({ token: 'token-A1', expiresIn: 3600, appName: 'tenant-A' })
|
|
403
|
+
.mockResolvedValueOnce({ token: 'token-B1', expiresIn: 3600, appName: 'tenant-B' })
|
|
404
|
+
.mockResolvedValueOnce({ token: 'token-A2', expiresIn: 3600, appName: 'tenant-A' })
|
|
405
|
+
.mockResolvedValueOnce({ token: 'token-B2', expiresIn: 3600, appName: 'tenant-B' });
|
|
406
|
+
|
|
407
|
+
await oxy.getServiceToken('key-A', 'secret-A');
|
|
408
|
+
await oxy.getServiceToken('key-B', 'secret-B');
|
|
409
|
+
expect(makeRequestSpy).toHaveBeenCalledTimes(2);
|
|
410
|
+
|
|
411
|
+
// No configureServiceAuth() and no argument → clear all.
|
|
412
|
+
oxy.invalidateServiceToken();
|
|
413
|
+
|
|
414
|
+
const a2 = await oxy.getServiceToken('key-A', 'secret-A');
|
|
415
|
+
const b2 = await oxy.getServiceToken('key-B', 'secret-B');
|
|
416
|
+
expect(a2).toBe('token-A2');
|
|
417
|
+
expect(b2).toBe('token-B2');
|
|
418
|
+
expect(makeRequestSpy).toHaveBeenCalledTimes(4);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('is a no-op safe to call when nothing is cached', () => {
|
|
422
|
+
expect(() => oxy.invalidateServiceToken()).not.toThrow();
|
|
423
|
+
expect(() => oxy.invalidateServiceToken('key-unknown')).not.toThrow();
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
|
|
335
427
|
// ---------------------------------------------------------------------------
|
|
336
428
|
// H2 — malformed tokens must yield 401, not 500. Uses class-based error
|
|
337
429
|
// detection so future failure modes can't silently fall through.
|
|
@@ -5,13 +5,23 @@
|
|
|
5
5
|
* `auth.oxy.so` origin is returned. No DOM, no side effects.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { CENTRAL_AUTH_URL, resolveCentralAuthUrl } from '../authWebUrl';
|
|
8
|
+
import { CENTRAL_AUTH_URL, CENTRAL_IDP_APEX, resolveCentralAuthUrl } from '../authWebUrl';
|
|
9
|
+
|
|
10
|
+
describe('CENTRAL_IDP_APEX', () => {
|
|
11
|
+
it('is the central IdP registrable apex', () => {
|
|
12
|
+
expect(CENTRAL_IDP_APEX).toBe('oxy.so');
|
|
13
|
+
});
|
|
14
|
+
});
|
|
9
15
|
|
|
10
16
|
describe('CENTRAL_AUTH_URL', () => {
|
|
11
17
|
it('is the central IdP origin with no trailing slash', () => {
|
|
12
18
|
expect(CENTRAL_AUTH_URL).toBe('https://auth.oxy.so');
|
|
13
19
|
expect(CENTRAL_AUTH_URL.endsWith('/')).toBe(false);
|
|
14
20
|
});
|
|
21
|
+
|
|
22
|
+
it('is derived from CENTRAL_IDP_APEX (apex and origin never drift)', () => {
|
|
23
|
+
expect(CENTRAL_AUTH_URL).toBe(`https://auth.${CENTRAL_IDP_APEX}`);
|
|
24
|
+
});
|
|
15
25
|
});
|
|
16
26
|
|
|
17
27
|
describe('resolveCentralAuthUrl', () => {
|
|
@@ -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
|
});
|