@oxyhq/core 5.4.3 → 6.0.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/HttpService.js +6 -3
- package/dist/cjs/OxyServices.base.js +7 -102
- package/dist/cjs/boot/coldBootV2.js +350 -0
- package/dist/cjs/boot/deviceBootReturn.js +152 -0
- package/dist/cjs/crypto/keyManager.js +95 -0
- package/dist/cjs/i18n/locales/en-US.json +13 -1
- package/dist/cjs/i18n/locales/es-ES.json +13 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +13 -1
- package/dist/cjs/i18n/locales/locales/es-ES.json +13 -1
- package/dist/cjs/index.js +47 -43
- package/dist/cjs/mixins/OxyServices.accounts.js +6 -13
- package/dist/cjs/mixins/OxyServices.auth.js +66 -201
- package/dist/cjs/mixins/OxyServices.authorizedApps.js +38 -0
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +119 -0
- package/dist/cjs/mixins/index.js +13 -17
- package/dist/cjs/session/SessionClient.js +44 -2
- package/dist/cjs/session/authStateStore.js +284 -0
- package/dist/cjs/session/createSessionClient.js +8 -2
- package/dist/cjs/session/refresh.js +264 -0
- package/dist/cjs/utils/accountUtils.js +1 -55
- package/dist/cjs/utils/authWebUrl.js +6 -15
- package/dist/cjs/utils/fapiAutoDetect.js +16 -64
- package/dist/cjs/utils/platform.js +19 -0
- package/dist/cjs/utils/ssoBounce.js +15 -340
- package/dist/cjs/utils/validationUtils.js +57 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +6 -3
- package/dist/esm/OxyServices.base.js +7 -102
- package/dist/esm/boot/coldBootV2.js +344 -0
- package/dist/esm/boot/deviceBootReturn.js +146 -0
- package/dist/esm/crypto/keyManager.js +95 -0
- package/dist/esm/i18n/locales/en-US.json +13 -1
- package/dist/esm/i18n/locales/es-ES.json +13 -1
- package/dist/esm/i18n/locales/locales/en-US.json +13 -1
- package/dist/esm/i18n/locales/locales/es-ES.json +13 -1
- package/dist/esm/index.js +28 -18
- package/dist/esm/mixins/OxyServices.accounts.js +6 -13
- package/dist/esm/mixins/OxyServices.auth.js +66 -201
- package/dist/esm/mixins/OxyServices.authorizedApps.js +35 -0
- package/dist/esm/mixins/OxyServices.deviceBoot.js +116 -0
- package/dist/esm/mixins/index.js +13 -17
- package/dist/esm/session/SessionClient.js +44 -2
- package/dist/esm/session/authStateStore.js +278 -0
- package/dist/esm/session/createSessionClient.js +8 -2
- package/dist/esm/session/refresh.js +257 -0
- package/dist/esm/utils/accountUtils.js +0 -53
- package/dist/esm/utils/authWebUrl.js +6 -14
- package/dist/esm/utils/fapiAutoDetect.js +16 -63
- package/dist/esm/utils/platform.js +18 -0
- package/dist/esm/utils/ssoBounce.js +14 -324
- package/dist/esm/utils/validationUtils.js +56 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +13 -0
- package/dist/types/OxyServices.base.d.ts +0 -52
- package/dist/types/OxyServices.d.ts +0 -25
- package/dist/types/boot/coldBootV2.d.ts +76 -0
- package/dist/types/boot/deviceBootReturn.d.ts +83 -0
- package/dist/types/crypto/keyManager.d.ts +21 -0
- package/dist/types/index.d.ts +16 -21
- package/dist/types/mixins/OxyServices.accounts.d.ts +0 -2
- package/dist/types/mixins/OxyServices.analytics.d.ts +0 -2
- package/dist/types/mixins/OxyServices.appData.d.ts +0 -2
- package/dist/types/mixins/OxyServices.assets.d.ts +0 -2
- package/dist/types/mixins/OxyServices.auth.d.ts +35 -77
- package/dist/types/mixins/{OxyServices.redirect.d.ts → OxyServices.authorizedApps.d.ts} +35 -33
- package/dist/types/mixins/OxyServices.civic.d.ts +0 -2
- package/dist/types/mixins/OxyServices.connectedApps.d.ts +0 -2
- package/dist/types/mixins/OxyServices.contacts.d.ts +0 -2
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +110 -0
- package/dist/types/mixins/OxyServices.devices.d.ts +0 -2
- package/dist/types/mixins/OxyServices.features.d.ts +0 -2
- package/dist/types/mixins/OxyServices.identity.d.ts +0 -2
- package/dist/types/mixins/OxyServices.language.d.ts +0 -2
- package/dist/types/mixins/OxyServices.links.d.ts +0 -2
- package/dist/types/mixins/OxyServices.location.d.ts +0 -2
- package/dist/types/mixins/OxyServices.nodes.d.ts +0 -2
- package/dist/types/mixins/OxyServices.payment.d.ts +0 -2
- package/dist/types/mixins/OxyServices.privacy.d.ts +0 -2
- package/dist/types/mixins/OxyServices.reputation.d.ts +0 -2
- package/dist/types/mixins/OxyServices.security.d.ts +0 -2
- package/dist/types/mixins/OxyServices.topics.d.ts +0 -2
- package/dist/types/mixins/OxyServices.user.d.ts +0 -2
- package/dist/types/mixins/OxyServices.utility.d.ts +0 -2
- package/dist/types/mixins/index.d.ts +6 -9
- package/dist/types/models/interfaces.d.ts +0 -67
- package/dist/types/session/SessionClient.d.ts +38 -1
- package/dist/types/session/authStateStore.d.ts +119 -0
- package/dist/types/session/createSessionClient.d.ts +8 -1
- package/dist/types/session/refresh.d.ts +93 -0
- package/dist/types/utils/accountUtils.d.ts +0 -14
- package/dist/types/utils/authWebUrl.d.ts +6 -12
- package/dist/types/utils/fapiAutoDetect.d.ts +15 -38
- package/dist/types/utils/platform.d.ts +14 -0
- package/dist/types/utils/ssoBounce.d.ts +14 -262
- package/dist/types/utils/validationUtils.d.ts +15 -0
- package/package.json +2 -2
- package/src/HttpService.ts +19 -3
- package/src/OxyServices.base.ts +7 -112
- package/src/OxyServices.ts +0 -38
- package/src/boot/__tests__/coldBootV2.test.ts +317 -0
- package/src/boot/__tests__/deviceBootReturn.test.ts +158 -0
- package/src/boot/coldBootV2.ts +426 -0
- package/src/boot/deviceBootReturn.ts +195 -0
- package/src/crypto/__tests__/sharedDeviceToken.test.ts +24 -0
- package/src/crypto/keyManager.ts +101 -0
- package/src/i18n/locales/en-US.json +13 -1
- package/src/i18n/locales/es-ES.json +13 -1
- package/src/index.ts +78 -64
- package/src/mixins/OxyServices.accounts.ts +6 -13
- package/src/mixins/OxyServices.auth.ts +78 -253
- package/src/mixins/OxyServices.authorizedApps.ts +75 -0
- package/src/mixins/OxyServices.deviceBoot.ts +146 -0
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +107 -0
- package/src/mixins/__tests__/accounts.test.ts +17 -44
- package/src/mixins/__tests__/authorizedApps.test.ts +63 -0
- package/src/mixins/__tests__/passwordSignIn.test.ts +91 -0
- package/src/mixins/index.ts +16 -22
- package/src/models/interfaces.ts +0 -79
- package/src/session/SessionClient.ts +69 -3
- package/src/session/__tests__/SessionClient.additive.test.ts +92 -0
- package/src/session/__tests__/SessionClient.rest.test.ts +25 -0
- package/src/session/__tests__/SessionClient.socketFactory.test.ts +79 -0
- package/src/session/__tests__/SessionClient.state.test.ts +18 -5
- package/src/session/__tests__/authStateStore.test.ts +209 -0
- package/src/session/__tests__/refresh.test.ts +256 -0
- package/src/session/authStateStore.ts +335 -0
- package/src/session/createSessionClient.ts +9 -1
- package/src/session/refresh.ts +334 -0
- package/src/utils/__tests__/authWebUrl.test.ts +5 -29
- package/src/utils/__tests__/fapiAutoDetect.test.ts +5 -126
- package/src/utils/__tests__/validationUtils.test.ts +30 -0
- package/src/utils/accountUtils.ts +0 -62
- package/src/utils/authWebUrl.ts +6 -15
- package/src/utils/fapiAutoDetect.ts +16 -60
- package/src/utils/platform.ts +21 -0
- package/src/utils/ssoBounce.ts +14 -371
- package/src/utils/validationUtils.ts +62 -0
- package/dist/cjs/AuthManager.js +0 -1110
- package/dist/cjs/AuthManagerTypes.js +0 -13
- package/dist/cjs/CrossDomainAuth.js +0 -206
- package/dist/cjs/mixins/OxyServices.fedcm.js +0 -823
- package/dist/cjs/mixins/OxyServices.redirect.js +0 -95
- package/dist/cjs/mixins/OxyServices.silent.js +0 -204
- package/dist/cjs/mixins/OxyServices.sso.js +0 -208
- package/dist/cjs/utils/ssoEstablish.js +0 -110
- package/dist/cjs/utils/ssoReturn.js +0 -267
- package/dist/esm/AuthManager.js +0 -1105
- package/dist/esm/AuthManagerTypes.js +0 -12
- package/dist/esm/CrossDomainAuth.js +0 -201
- package/dist/esm/mixins/OxyServices.fedcm.js +0 -821
- package/dist/esm/mixins/OxyServices.redirect.js +0 -92
- package/dist/esm/mixins/OxyServices.silent.js +0 -202
- package/dist/esm/mixins/OxyServices.sso.js +0 -204
- package/dist/esm/utils/ssoEstablish.js +0 -107
- package/dist/esm/utils/ssoReturn.js +0 -263
- package/dist/types/AuthManager.d.ts +0 -380
- package/dist/types/AuthManagerTypes.d.ts +0 -81
- package/dist/types/CrossDomainAuth.d.ts +0 -164
- package/dist/types/mixins/OxyServices.fedcm.d.ts +0 -331
- package/dist/types/mixins/OxyServices.silent.d.ts +0 -132
- package/dist/types/mixins/OxyServices.sso.d.ts +0 -138
- package/dist/types/utils/ssoEstablish.d.ts +0 -85
- package/dist/types/utils/ssoReturn.d.ts +0 -146
- package/src/AuthManager.ts +0 -1269
- package/src/AuthManagerTypes.ts +0 -86
- package/src/CrossDomainAuth.ts +0 -243
- package/src/__tests__/authManager.cookiePath.test.ts +0 -390
- package/src/__tests__/authManager.security.test.ts +0 -377
- package/src/__tests__/crossDomainAuth.test.ts +0 -116
- package/src/__tests__/establishDeviceRefreshSlot.test.ts +0 -221
- package/src/mixins/OxyServices.fedcm.ts +0 -1026
- package/src/mixins/OxyServices.redirect.ts +0 -122
- package/src/mixins/OxyServices.silent.ts +0 -272
- package/src/mixins/OxyServices.sso.ts +0 -261
- package/src/mixins/__tests__/constructorAuthWebUrl.test.ts +0 -85
- package/src/mixins/__tests__/fedcm.test.ts +0 -667
- package/src/mixins/__tests__/sessionBaseUrl.test.ts +0 -61
- package/src/mixins/__tests__/silent.test.ts +0 -102
- package/src/mixins/__tests__/sso.test.ts +0 -228
- package/src/utils/__tests__/consumeSsoReturn.test.ts +0 -816
- package/src/utils/__tests__/ssoBounce.test.ts +0 -219
- package/src/utils/__tests__/ssoEstablish.test.ts +0 -204
- package/src/utils/__tests__/ssoReturn.test.ts +0 -251
- package/src/utils/ssoEstablish.ts +0 -174
- package/src/utils/ssoReturn.ts +0 -372
|
@@ -1,816 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @jest-environment jsdom
|
|
3
|
-
*
|
|
4
|
-
* `consumeSsoReturn` — the commit-free, security-critical kernel of the
|
|
5
|
-
* cross-domain SSO `sso-return` step.
|
|
6
|
-
*
|
|
7
|
-
* Every web seam is injected (storage / location / history / isWeb) so the
|
|
8
|
-
* full CSRF / fragment-strip-order / exchange / dest-restore / loop-breaker
|
|
9
|
-
* sequence is asserted deterministically. The injected-seam tests do not need
|
|
10
|
-
* a DOM; the `default dispatchPopState` suite exercises the real
|
|
11
|
-
* `window.dispatchEvent(new PopStateEvent(...))` path, so this file runs under
|
|
12
|
-
* the jsdom environment.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { consumeSsoReturn } from '../ssoReturn';
|
|
16
|
-
import {
|
|
17
|
-
SSO_CALLBACK_PATH,
|
|
18
|
-
ssoStateKey,
|
|
19
|
-
ssoGuardKey,
|
|
20
|
-
ssoDestKey,
|
|
21
|
-
ssoNoSessionKey,
|
|
22
|
-
ssoAttemptedKey,
|
|
23
|
-
} from '../ssoBounce';
|
|
24
|
-
import type { SessionLoginResponse } from '../../models/session';
|
|
25
|
-
|
|
26
|
-
const ORIGIN = 'https://mention.earth';
|
|
27
|
-
|
|
28
|
-
function makeStorage(seed: Record<string, string> = {}) {
|
|
29
|
-
const map = new Map<string, string>(Object.entries(seed));
|
|
30
|
-
return {
|
|
31
|
-
map,
|
|
32
|
-
getItem: (k: string) => (map.has(k) ? (map.get(k) as string) : null),
|
|
33
|
-
setItem: (k: string, v: string) => {
|
|
34
|
-
map.set(k, v);
|
|
35
|
-
},
|
|
36
|
-
removeItem: (k: string) => {
|
|
37
|
-
map.delete(k);
|
|
38
|
-
},
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
interface LocationParts {
|
|
43
|
-
hash: string;
|
|
44
|
-
origin: string;
|
|
45
|
-
pathname: string;
|
|
46
|
-
search: string;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function makeLocation(parts: Partial<LocationParts> = {}): LocationParts {
|
|
50
|
-
return {
|
|
51
|
-
hash: parts.hash ?? '',
|
|
52
|
-
origin: parts.origin ?? ORIGIN,
|
|
53
|
-
pathname: parts.pathname ?? SSO_CALLBACK_PATH,
|
|
54
|
-
search: parts.search ?? '',
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function makeHistory() {
|
|
59
|
-
const calls: Array<[unknown, string, string | undefined]> = [];
|
|
60
|
-
return {
|
|
61
|
-
calls,
|
|
62
|
-
replaceState: (data: unknown, unused: string, url?: string | URL | null) => {
|
|
63
|
-
calls.push([data, unused, typeof url === 'string' ? url : undefined]);
|
|
64
|
-
},
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const SESSION: SessionLoginResponse = {
|
|
69
|
-
sessionId: 'sess-1',
|
|
70
|
-
deviceId: 'dev-1',
|
|
71
|
-
expiresAt: '2099-01-01T00:00:00.000Z',
|
|
72
|
-
user: { id: 'u1', username: 'alice' },
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
function okExchange() {
|
|
76
|
-
return { exchangeSsoCode: jest.fn(async () => SESSION) };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
describe('consumeSsoReturn', () => {
|
|
80
|
-
it('returns null off-web (isWeb false) and does nothing', async () => {
|
|
81
|
-
const oxy = okExchange();
|
|
82
|
-
const storage = makeStorage();
|
|
83
|
-
const result = await consumeSsoReturn(oxy, {
|
|
84
|
-
isWeb: () => false,
|
|
85
|
-
storage,
|
|
86
|
-
location: makeLocation({ hash: '#oxy_sso=ok&code=c&state=s' }),
|
|
87
|
-
history: makeHistory(),
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
expect(result).toBeNull();
|
|
91
|
-
expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it('returns null for a non-oxy fragment without touching any flags', async () => {
|
|
95
|
-
const oxy = okExchange();
|
|
96
|
-
const storage = makeStorage({ [ssoDestKey(ORIGIN)]: `${ORIGIN}/profile` });
|
|
97
|
-
const history = makeHistory();
|
|
98
|
-
const dispatchPopState = jest.fn();
|
|
99
|
-
const result = await consumeSsoReturn(oxy, {
|
|
100
|
-
isWeb: () => true,
|
|
101
|
-
storage,
|
|
102
|
-
location: makeLocation({ hash: '#section=about' }),
|
|
103
|
-
history,
|
|
104
|
-
dispatchPopState,
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
expect(result).toBeNull();
|
|
108
|
-
expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
|
|
109
|
-
expect(storage.map.has(ssoNoSessionKey(ORIGIN))).toBe(false);
|
|
110
|
-
// No fragment strip for an unrelated fragment.
|
|
111
|
-
expect(history.calls).toHaveLength(0);
|
|
112
|
-
// Nothing was consumed — the dest key must be untouched and no popstate.
|
|
113
|
-
expect(storage.map.get(ssoDestKey(ORIGIN))).toBe(`${ORIGIN}/profile`);
|
|
114
|
-
expect(dispatchPopState).not.toHaveBeenCalled();
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it('sets NO_SESSION and returns null on a "none" outcome', async () => {
|
|
118
|
-
const oxy = okExchange();
|
|
119
|
-
const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
|
|
120
|
-
const result = await consumeSsoReturn(oxy, {
|
|
121
|
-
isWeb: () => true,
|
|
122
|
-
storage,
|
|
123
|
-
location: makeLocation({ hash: '#oxy_sso=none&state=s' }),
|
|
124
|
-
history: makeHistory(),
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
expect(result).toBeNull();
|
|
128
|
-
expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
|
|
129
|
-
expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
|
|
130
|
-
expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
|
|
131
|
-
expect(storage.map.has(ssoStateKey(ORIGIN))).toBe(false);
|
|
132
|
-
expect(storage.map.has(ssoGuardKey(ORIGIN))).toBe(false);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('sets BOTH ssoNoSessionKey AND ssoAttemptedKey on a none outcome', async () => {
|
|
136
|
-
const oxy = okExchange();
|
|
137
|
-
const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
|
|
138
|
-
const history = makeHistory();
|
|
139
|
-
const result = await consumeSsoReturn(oxy, {
|
|
140
|
-
isWeb: () => true,
|
|
141
|
-
storage,
|
|
142
|
-
location: makeLocation({ hash: '#oxy_sso=none&state=s' }),
|
|
143
|
-
history,
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
expect(result).toBeNull();
|
|
147
|
-
expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
|
|
148
|
-
expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
|
|
149
|
-
expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
|
|
150
|
-
// Fragment stripped FIRST (history.replaceState called).
|
|
151
|
-
expect(history.calls.length).toBeGreaterThanOrEqual(1);
|
|
152
|
-
expect(history.calls[0]?.[2]).toBe(SSO_CALLBACK_PATH);
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it('sets NO_SESSION and returns null on an "error" outcome', async () => {
|
|
156
|
-
const oxy = okExchange();
|
|
157
|
-
const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
|
|
158
|
-
const result = await consumeSsoReturn(oxy, {
|
|
159
|
-
isWeb: () => true,
|
|
160
|
-
storage,
|
|
161
|
-
location: makeLocation({ hash: '#oxy_sso=error&state=s' }),
|
|
162
|
-
history: makeHistory(),
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
expect(result).toBeNull();
|
|
166
|
-
expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
|
|
167
|
-
expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it('sets NO_SESSION and returns null on a state mismatch (CSRF)', async () => {
|
|
171
|
-
const oxy = okExchange();
|
|
172
|
-
const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 'expected' });
|
|
173
|
-
const result = await consumeSsoReturn(oxy, {
|
|
174
|
-
isWeb: () => true,
|
|
175
|
-
storage,
|
|
176
|
-
location: makeLocation({ hash: '#oxy_sso=ok&code=c&state=forged' }),
|
|
177
|
-
history: makeHistory(),
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
expect(result).toBeNull();
|
|
181
|
-
expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
|
|
182
|
-
expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
|
|
183
|
-
expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
it('sets NO_SESSION and returns null when ok carries no code', async () => {
|
|
187
|
-
const oxy = okExchange();
|
|
188
|
-
const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
|
|
189
|
-
const result = await consumeSsoReturn(oxy, {
|
|
190
|
-
isWeb: () => true,
|
|
191
|
-
storage,
|
|
192
|
-
location: makeLocation({ hash: '#oxy_sso=ok&state=s' }),
|
|
193
|
-
history: makeHistory(),
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
expect(result).toBeNull();
|
|
197
|
-
expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
|
|
198
|
-
expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
|
|
199
|
-
expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
it('exchanges, returns the session, strips the fragment, and removes state/guard keys on ok', async () => {
|
|
203
|
-
const oxy = okExchange();
|
|
204
|
-
const storage = makeStorage({
|
|
205
|
-
[ssoStateKey(ORIGIN)]: 's',
|
|
206
|
-
[ssoGuardKey(ORIGIN)]: String(Date.now()),
|
|
207
|
-
});
|
|
208
|
-
const history = makeHistory();
|
|
209
|
-
const result = await consumeSsoReturn(oxy, {
|
|
210
|
-
isWeb: () => true,
|
|
211
|
-
storage,
|
|
212
|
-
location: makeLocation({
|
|
213
|
-
hash: '#oxy_sso=ok&code=opaque-code&state=s',
|
|
214
|
-
pathname: '/feed',
|
|
215
|
-
search: '?tab=home',
|
|
216
|
-
}),
|
|
217
|
-
history,
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
expect(result).toEqual(SESSION);
|
|
221
|
-
expect(oxy.exchangeSsoCode).toHaveBeenCalledWith('opaque-code', 's');
|
|
222
|
-
expect(storage.map.has(ssoStateKey(ORIGIN))).toBe(false);
|
|
223
|
-
expect(storage.map.has(ssoGuardKey(ORIGIN))).toBe(false);
|
|
224
|
-
expect(storage.map.has(ssoNoSessionKey(ORIGIN))).toBe(false);
|
|
225
|
-
// The ok happy-path must NOT set the attempted-flag — a future sign-out
|
|
226
|
-
// should be able to re-probe the central IdP.
|
|
227
|
-
expect(storage.map.has(ssoAttemptedKey(ORIGIN))).toBe(false);
|
|
228
|
-
// Fragment stripped to pathname+search (the first replaceState).
|
|
229
|
-
expect(history.calls[0]?.[2]).toBe('/feed?tab=home');
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
it('strips the fragment BEFORE the exchange (opaque code never lingers)', async () => {
|
|
233
|
-
const order: string[] = [];
|
|
234
|
-
const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
|
|
235
|
-
const history = {
|
|
236
|
-
replaceState: () => {
|
|
237
|
-
order.push('replaceState');
|
|
238
|
-
},
|
|
239
|
-
};
|
|
240
|
-
const oxy = {
|
|
241
|
-
exchangeSsoCode: jest.fn(async () => {
|
|
242
|
-
order.push('exchange');
|
|
243
|
-
return SESSION;
|
|
244
|
-
}),
|
|
245
|
-
};
|
|
246
|
-
|
|
247
|
-
await consumeSsoReturn(oxy, {
|
|
248
|
-
isWeb: () => true,
|
|
249
|
-
storage,
|
|
250
|
-
location: makeLocation({
|
|
251
|
-
hash: '#oxy_sso=ok&code=c&state=s',
|
|
252
|
-
pathname: '/somewhere',
|
|
253
|
-
}),
|
|
254
|
-
history,
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
expect(order[0]).toBe('replaceState');
|
|
258
|
-
expect(order).toContain('exchange');
|
|
259
|
-
expect(order.indexOf('replaceState')).toBeLessThan(order.indexOf('exchange'));
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
it('sets NO_SESSION, returns null, and calls onExchangeError when the exchange throws', async () => {
|
|
263
|
-
const boom = new Error('exchange failed');
|
|
264
|
-
const oxy = {
|
|
265
|
-
exchangeSsoCode: jest.fn(async () => {
|
|
266
|
-
throw boom;
|
|
267
|
-
}),
|
|
268
|
-
};
|
|
269
|
-
const onExchangeError = jest.fn();
|
|
270
|
-
const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
|
|
271
|
-
|
|
272
|
-
const result = await consumeSsoReturn(oxy, {
|
|
273
|
-
isWeb: () => true,
|
|
274
|
-
storage,
|
|
275
|
-
location: makeLocation({ hash: '#oxy_sso=ok&code=c&state=s' }),
|
|
276
|
-
history: makeHistory(),
|
|
277
|
-
onExchangeError,
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
expect(result).toBeNull();
|
|
281
|
-
expect(onExchangeError).toHaveBeenCalledWith(boom);
|
|
282
|
-
expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
|
|
283
|
-
expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
it('does not throw when the exchange throws and no onExchangeError hook is given', async () => {
|
|
287
|
-
const oxy = {
|
|
288
|
-
exchangeSsoCode: jest.fn(async () => {
|
|
289
|
-
throw new Error('boom');
|
|
290
|
-
}),
|
|
291
|
-
};
|
|
292
|
-
const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
|
|
293
|
-
|
|
294
|
-
await expect(
|
|
295
|
-
consumeSsoReturn(oxy, {
|
|
296
|
-
isWeb: () => true,
|
|
297
|
-
storage,
|
|
298
|
-
location: makeLocation({ hash: '#oxy_sso=ok&code=c&state=s' }),
|
|
299
|
-
history: makeHistory(),
|
|
300
|
-
}),
|
|
301
|
-
).resolves.toBeNull();
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
it('sets NO_SESSION when the exchange resolves without a sessionId', async () => {
|
|
305
|
-
const oxy = {
|
|
306
|
-
exchangeSsoCode: jest.fn(async () => ({ sessionId: '' }) as SessionLoginResponse),
|
|
307
|
-
};
|
|
308
|
-
const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
|
|
309
|
-
|
|
310
|
-
const result = await consumeSsoReturn(oxy, {
|
|
311
|
-
isWeb: () => true,
|
|
312
|
-
storage,
|
|
313
|
-
location: makeLocation({ hash: '#oxy_sso=ok&code=c&state=s' }),
|
|
314
|
-
history: makeHistory(),
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
expect(result).toBeNull();
|
|
318
|
-
expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
|
|
319
|
-
expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
describe('ok dest restore (soft replaceState + popstate)', () => {
|
|
323
|
-
it('restores a same-origin destination when on the callback path, removes the dest key, dispatches popstate, and does NOT hard-redirect', async () => {
|
|
324
|
-
const oxy = okExchange();
|
|
325
|
-
const storage = makeStorage({
|
|
326
|
-
[ssoStateKey(ORIGIN)]: 's',
|
|
327
|
-
[ssoDestKey(ORIGIN)]: `${ORIGIN}/profile?x=1#frag`,
|
|
328
|
-
});
|
|
329
|
-
const history = makeHistory();
|
|
330
|
-
const dispatchPopState = jest.fn();
|
|
331
|
-
const hardRedirect = jest.fn();
|
|
332
|
-
|
|
333
|
-
const result = await consumeSsoReturn(oxy, {
|
|
334
|
-
isWeb: () => true,
|
|
335
|
-
storage,
|
|
336
|
-
location: makeLocation({
|
|
337
|
-
hash: '#oxy_sso=ok&code=c&state=s',
|
|
338
|
-
pathname: SSO_CALLBACK_PATH,
|
|
339
|
-
}),
|
|
340
|
-
history,
|
|
341
|
-
dispatchPopState,
|
|
342
|
-
hardRedirect,
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
expect(result).toEqual(SESSION);
|
|
346
|
-
// First replaceState = fragment strip; last = dest restore.
|
|
347
|
-
const last = history.calls[history.calls.length - 1];
|
|
348
|
-
expect(last?.[2]).toBe('/profile?x=1#frag');
|
|
349
|
-
expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
|
|
350
|
-
// URL-driven routers must be told the location changed.
|
|
351
|
-
expect(dispatchPopState).toHaveBeenCalledTimes(1);
|
|
352
|
-
// ok preserves the in-memory session — NEVER a hard navigation.
|
|
353
|
-
expect(hardRedirect).not.toHaveBeenCalled();
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
it('restores a relative same-origin destination (new URL(dest, origin))', async () => {
|
|
357
|
-
const oxy = okExchange();
|
|
358
|
-
const storage = makeStorage({
|
|
359
|
-
[ssoStateKey(ORIGIN)]: 's',
|
|
360
|
-
[ssoDestKey(ORIGIN)]: '/settings?tab=privacy',
|
|
361
|
-
});
|
|
362
|
-
const history = makeHistory();
|
|
363
|
-
const hardRedirect = jest.fn();
|
|
364
|
-
|
|
365
|
-
await consumeSsoReturn(oxy, {
|
|
366
|
-
isWeb: () => true,
|
|
367
|
-
storage,
|
|
368
|
-
location: makeLocation({
|
|
369
|
-
hash: '#oxy_sso=ok&code=c&state=s',
|
|
370
|
-
pathname: SSO_CALLBACK_PATH,
|
|
371
|
-
}),
|
|
372
|
-
history,
|
|
373
|
-
hardRedirect,
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
const last = history.calls[history.calls.length - 1];
|
|
377
|
-
expect(last?.[2]).toBe('/settings?tab=privacy');
|
|
378
|
-
expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
|
|
379
|
-
expect(hardRedirect).not.toHaveBeenCalled();
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
it('falls back to the app root on ok when NO dest is stored (soft replaceState to "/")', async () => {
|
|
383
|
-
const oxy = okExchange();
|
|
384
|
-
const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
|
|
385
|
-
const history = makeHistory();
|
|
386
|
-
const dispatchPopState = jest.fn();
|
|
387
|
-
const hardRedirect = jest.fn();
|
|
388
|
-
|
|
389
|
-
const result = await consumeSsoReturn(oxy, {
|
|
390
|
-
isWeb: () => true,
|
|
391
|
-
storage,
|
|
392
|
-
location: makeLocation({
|
|
393
|
-
hash: '#oxy_sso=ok&code=c&state=s',
|
|
394
|
-
pathname: SSO_CALLBACK_PATH,
|
|
395
|
-
}),
|
|
396
|
-
history,
|
|
397
|
-
dispatchPopState,
|
|
398
|
-
hardRedirect,
|
|
399
|
-
});
|
|
400
|
-
|
|
401
|
-
expect(result).toEqual(SESSION);
|
|
402
|
-
// First replaceState = fragment strip; last = root fallback.
|
|
403
|
-
const last = history.calls[history.calls.length - 1];
|
|
404
|
-
expect(last?.[2]).toBe('/');
|
|
405
|
-
expect(dispatchPopState).toHaveBeenCalledTimes(1);
|
|
406
|
-
expect(hardRedirect).not.toHaveBeenCalled();
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
it('falls back to the app root on ok when the stored dest is cross-origin', async () => {
|
|
410
|
-
const oxy = okExchange();
|
|
411
|
-
const storage = makeStorage({
|
|
412
|
-
[ssoStateKey(ORIGIN)]: 's',
|
|
413
|
-
[ssoDestKey(ORIGIN)]: 'https://evil.example/phish',
|
|
414
|
-
});
|
|
415
|
-
const history = makeHistory();
|
|
416
|
-
const hardRedirect = jest.fn();
|
|
417
|
-
|
|
418
|
-
await consumeSsoReturn(oxy, {
|
|
419
|
-
isWeb: () => true,
|
|
420
|
-
storage,
|
|
421
|
-
location: makeLocation({
|
|
422
|
-
hash: '#oxy_sso=ok&code=c&state=s',
|
|
423
|
-
pathname: SSO_CALLBACK_PATH,
|
|
424
|
-
}),
|
|
425
|
-
history,
|
|
426
|
-
hardRedirect,
|
|
427
|
-
});
|
|
428
|
-
|
|
429
|
-
const last = history.calls[history.calls.length - 1];
|
|
430
|
-
expect(last?.[2]).toBe('/');
|
|
431
|
-
expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
|
|
432
|
-
expect(hardRedirect).not.toHaveBeenCalled();
|
|
433
|
-
});
|
|
434
|
-
|
|
435
|
-
it('falls back to the app root on ok when the stored dest is protocol-relative cross-origin', async () => {
|
|
436
|
-
const oxy = okExchange();
|
|
437
|
-
const storage = makeStorage({
|
|
438
|
-
[ssoStateKey(ORIGIN)]: 's',
|
|
439
|
-
[ssoDestKey(ORIGIN)]: '//evil.example/phish',
|
|
440
|
-
});
|
|
441
|
-
const history = makeHistory();
|
|
442
|
-
|
|
443
|
-
await consumeSsoReturn(oxy, {
|
|
444
|
-
isWeb: () => true,
|
|
445
|
-
storage,
|
|
446
|
-
location: makeLocation({
|
|
447
|
-
hash: '#oxy_sso=ok&code=c&state=s',
|
|
448
|
-
pathname: SSO_CALLBACK_PATH,
|
|
449
|
-
}),
|
|
450
|
-
history,
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
const last = history.calls[history.calls.length - 1];
|
|
454
|
-
expect(last?.[2]).toBe('/');
|
|
455
|
-
expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
|
|
456
|
-
});
|
|
457
|
-
|
|
458
|
-
it('does not restore dest when NOT on the callback path, but still removes the dest key', async () => {
|
|
459
|
-
const oxy = okExchange();
|
|
460
|
-
const storage = makeStorage({
|
|
461
|
-
[ssoStateKey(ORIGIN)]: 's',
|
|
462
|
-
[ssoDestKey(ORIGIN)]: `${ORIGIN}/should-not-apply`,
|
|
463
|
-
});
|
|
464
|
-
const history = makeHistory();
|
|
465
|
-
const hardRedirect = jest.fn();
|
|
466
|
-
|
|
467
|
-
await consumeSsoReturn(oxy, {
|
|
468
|
-
isWeb: () => true,
|
|
469
|
-
storage,
|
|
470
|
-
location: makeLocation({
|
|
471
|
-
hash: '#oxy_sso=ok&code=c&state=s',
|
|
472
|
-
pathname: '/already-here',
|
|
473
|
-
search: '?a=1',
|
|
474
|
-
}),
|
|
475
|
-
history,
|
|
476
|
-
hardRedirect,
|
|
477
|
-
});
|
|
478
|
-
|
|
479
|
-
// Only the fragment strip ran.
|
|
480
|
-
expect(history.calls).toHaveLength(1);
|
|
481
|
-
expect(history.calls[0]?.[2]).toBe('/already-here?a=1');
|
|
482
|
-
expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
|
|
483
|
-
expect(hardRedirect).not.toHaveBeenCalled();
|
|
484
|
-
});
|
|
485
|
-
});
|
|
486
|
-
|
|
487
|
-
describe('non-ok hard redirect (never strand on the callback path)', () => {
|
|
488
|
-
it('hard-redirects to the app root on a "none" outcome with NO dest stored', async () => {
|
|
489
|
-
const oxy = okExchange();
|
|
490
|
-
const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
|
|
491
|
-
const history = makeHistory();
|
|
492
|
-
const dispatchPopState = jest.fn();
|
|
493
|
-
const hardRedirect = jest.fn();
|
|
494
|
-
|
|
495
|
-
const result = await consumeSsoReturn(oxy, {
|
|
496
|
-
isWeb: () => true,
|
|
497
|
-
storage,
|
|
498
|
-
location: makeLocation({
|
|
499
|
-
hash: '#oxy_sso=none&state=s',
|
|
500
|
-
pathname: SSO_CALLBACK_PATH,
|
|
501
|
-
}),
|
|
502
|
-
history,
|
|
503
|
-
dispatchPopState,
|
|
504
|
-
hardRedirect,
|
|
505
|
-
});
|
|
506
|
-
|
|
507
|
-
expect(result).toBeNull();
|
|
508
|
-
expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
|
|
509
|
-
expect(hardRedirect).toHaveBeenCalledTimes(1);
|
|
510
|
-
expect(hardRedirect).toHaveBeenCalledWith(`${ORIGIN}/`);
|
|
511
|
-
// Soft restore is NOT used on a non-ok outcome.
|
|
512
|
-
expect(dispatchPopState).not.toHaveBeenCalled();
|
|
513
|
-
// Loop-breaker flags must still be set.
|
|
514
|
-
expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
|
|
515
|
-
expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
|
|
516
|
-
});
|
|
517
|
-
|
|
518
|
-
it('hard-redirects to a same-origin dest on a "none" outcome', async () => {
|
|
519
|
-
const oxy = okExchange();
|
|
520
|
-
const storage = makeStorage({
|
|
521
|
-
[ssoStateKey(ORIGIN)]: 's',
|
|
522
|
-
[ssoDestKey(ORIGIN)]: `${ORIGIN}/explore?x=1#sec`,
|
|
523
|
-
});
|
|
524
|
-
const history = makeHistory();
|
|
525
|
-
const dispatchPopState = jest.fn();
|
|
526
|
-
const hardRedirect = jest.fn();
|
|
527
|
-
|
|
528
|
-
const result = await consumeSsoReturn(oxy, {
|
|
529
|
-
isWeb: () => true,
|
|
530
|
-
storage,
|
|
531
|
-
location: makeLocation({
|
|
532
|
-
hash: '#oxy_sso=none&state=s',
|
|
533
|
-
pathname: SSO_CALLBACK_PATH,
|
|
534
|
-
}),
|
|
535
|
-
history,
|
|
536
|
-
dispatchPopState,
|
|
537
|
-
hardRedirect,
|
|
538
|
-
});
|
|
539
|
-
|
|
540
|
-
expect(result).toBeNull();
|
|
541
|
-
expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
|
|
542
|
-
expect(hardRedirect).toHaveBeenCalledTimes(1);
|
|
543
|
-
expect(hardRedirect).toHaveBeenCalledWith(`${ORIGIN}/explore?x=1#sec`);
|
|
544
|
-
expect(dispatchPopState).not.toHaveBeenCalled();
|
|
545
|
-
expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
|
|
546
|
-
expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
|
|
547
|
-
expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
|
|
548
|
-
});
|
|
549
|
-
|
|
550
|
-
it('hard-redirects to a same-origin dest on an "error" outcome', async () => {
|
|
551
|
-
const oxy = okExchange();
|
|
552
|
-
const storage = makeStorage({
|
|
553
|
-
[ssoStateKey(ORIGIN)]: 's',
|
|
554
|
-
[ssoDestKey(ORIGIN)]: `${ORIGIN}/feed`,
|
|
555
|
-
});
|
|
556
|
-
const history = makeHistory();
|
|
557
|
-
const hardRedirect = jest.fn();
|
|
558
|
-
|
|
559
|
-
const result = await consumeSsoReturn(oxy, {
|
|
560
|
-
isWeb: () => true,
|
|
561
|
-
storage,
|
|
562
|
-
location: makeLocation({
|
|
563
|
-
hash: '#oxy_sso=error&state=s',
|
|
564
|
-
pathname: SSO_CALLBACK_PATH,
|
|
565
|
-
}),
|
|
566
|
-
history,
|
|
567
|
-
hardRedirect,
|
|
568
|
-
});
|
|
569
|
-
|
|
570
|
-
expect(result).toBeNull();
|
|
571
|
-
expect(hardRedirect).toHaveBeenCalledTimes(1);
|
|
572
|
-
expect(hardRedirect).toHaveBeenCalledWith(`${ORIGIN}/feed`);
|
|
573
|
-
expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
|
|
574
|
-
expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
|
|
575
|
-
expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
|
|
576
|
-
});
|
|
577
|
-
|
|
578
|
-
it('hard-redirects to the dest on a state mismatch (CSRF)', async () => {
|
|
579
|
-
const oxy = okExchange();
|
|
580
|
-
const storage = makeStorage({
|
|
581
|
-
[ssoStateKey(ORIGIN)]: 'expected',
|
|
582
|
-
[ssoDestKey(ORIGIN)]: `${ORIGIN}/notifications`,
|
|
583
|
-
});
|
|
584
|
-
const history = makeHistory();
|
|
585
|
-
const hardRedirect = jest.fn();
|
|
586
|
-
|
|
587
|
-
const result = await consumeSsoReturn(oxy, {
|
|
588
|
-
isWeb: () => true,
|
|
589
|
-
storage,
|
|
590
|
-
location: makeLocation({
|
|
591
|
-
hash: '#oxy_sso=ok&code=c&state=forged',
|
|
592
|
-
pathname: SSO_CALLBACK_PATH,
|
|
593
|
-
}),
|
|
594
|
-
history,
|
|
595
|
-
hardRedirect,
|
|
596
|
-
});
|
|
597
|
-
|
|
598
|
-
expect(result).toBeNull();
|
|
599
|
-
expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
|
|
600
|
-
expect(hardRedirect).toHaveBeenCalledTimes(1);
|
|
601
|
-
expect(hardRedirect).toHaveBeenCalledWith(`${ORIGIN}/notifications`);
|
|
602
|
-
expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
|
|
603
|
-
expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
|
|
604
|
-
});
|
|
605
|
-
|
|
606
|
-
it('hard-redirects to the app root when ok carries a code but the exchange fails', async () => {
|
|
607
|
-
const boom = new Error('exchange failed');
|
|
608
|
-
const oxy = {
|
|
609
|
-
exchangeSsoCode: jest.fn(async () => {
|
|
610
|
-
throw boom;
|
|
611
|
-
}),
|
|
612
|
-
};
|
|
613
|
-
const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
|
|
614
|
-
const history = makeHistory();
|
|
615
|
-
const hardRedirect = jest.fn();
|
|
616
|
-
const onExchangeError = jest.fn();
|
|
617
|
-
|
|
618
|
-
const result = await consumeSsoReturn(oxy, {
|
|
619
|
-
isWeb: () => true,
|
|
620
|
-
storage,
|
|
621
|
-
location: makeLocation({
|
|
622
|
-
hash: '#oxy_sso=ok&code=c&state=s',
|
|
623
|
-
pathname: SSO_CALLBACK_PATH,
|
|
624
|
-
}),
|
|
625
|
-
history,
|
|
626
|
-
hardRedirect,
|
|
627
|
-
onExchangeError,
|
|
628
|
-
});
|
|
629
|
-
|
|
630
|
-
expect(result).toBeNull();
|
|
631
|
-
expect(onExchangeError).toHaveBeenCalledWith(boom);
|
|
632
|
-
expect(hardRedirect).toHaveBeenCalledTimes(1);
|
|
633
|
-
expect(hardRedirect).toHaveBeenCalledWith(`${ORIGIN}/`);
|
|
634
|
-
expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
|
|
635
|
-
});
|
|
636
|
-
|
|
637
|
-
it('hard-redirects to the app root when ok carries a code but the exchange returns no sessionId', async () => {
|
|
638
|
-
const oxy = {
|
|
639
|
-
exchangeSsoCode: jest.fn(async () => ({ sessionId: '' }) as SessionLoginResponse),
|
|
640
|
-
};
|
|
641
|
-
const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
|
|
642
|
-
const history = makeHistory();
|
|
643
|
-
const hardRedirect = jest.fn();
|
|
644
|
-
|
|
645
|
-
const result = await consumeSsoReturn(oxy, {
|
|
646
|
-
isWeb: () => true,
|
|
647
|
-
storage,
|
|
648
|
-
location: makeLocation({
|
|
649
|
-
hash: '#oxy_sso=ok&code=c&state=s',
|
|
650
|
-
pathname: SSO_CALLBACK_PATH,
|
|
651
|
-
}),
|
|
652
|
-
history,
|
|
653
|
-
hardRedirect,
|
|
654
|
-
});
|
|
655
|
-
|
|
656
|
-
expect(result).toBeNull();
|
|
657
|
-
expect(hardRedirect).toHaveBeenCalledTimes(1);
|
|
658
|
-
expect(hardRedirect).toHaveBeenCalledWith(`${ORIGIN}/`);
|
|
659
|
-
});
|
|
660
|
-
|
|
661
|
-
it('falls back to the app root on a "none" outcome when the dest is cross-origin', async () => {
|
|
662
|
-
const oxy = okExchange();
|
|
663
|
-
const storage = makeStorage({
|
|
664
|
-
[ssoStateKey(ORIGIN)]: 's',
|
|
665
|
-
[ssoDestKey(ORIGIN)]: 'https://evil.example/phish',
|
|
666
|
-
});
|
|
667
|
-
const history = makeHistory();
|
|
668
|
-
const hardRedirect = jest.fn();
|
|
669
|
-
|
|
670
|
-
const result = await consumeSsoReturn(oxy, {
|
|
671
|
-
isWeb: () => true,
|
|
672
|
-
storage,
|
|
673
|
-
location: makeLocation({
|
|
674
|
-
hash: '#oxy_sso=none&state=s',
|
|
675
|
-
pathname: SSO_CALLBACK_PATH,
|
|
676
|
-
}),
|
|
677
|
-
history,
|
|
678
|
-
hardRedirect,
|
|
679
|
-
});
|
|
680
|
-
|
|
681
|
-
expect(result).toBeNull();
|
|
682
|
-
// Never honour the cross-origin dest — fall back to the same-origin root.
|
|
683
|
-
expect(hardRedirect).toHaveBeenCalledTimes(1);
|
|
684
|
-
expect(hardRedirect).toHaveBeenCalledWith(`${ORIGIN}/`);
|
|
685
|
-
expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
|
|
686
|
-
});
|
|
687
|
-
|
|
688
|
-
it('does NOT hard-redirect on a "none" outcome when NOT on the callback path', async () => {
|
|
689
|
-
const oxy = okExchange();
|
|
690
|
-
const storage = makeStorage({
|
|
691
|
-
[ssoStateKey(ORIGIN)]: 's',
|
|
692
|
-
[ssoDestKey(ORIGIN)]: `${ORIGIN}/should-not-apply`,
|
|
693
|
-
});
|
|
694
|
-
const history = makeHistory();
|
|
695
|
-
const dispatchPopState = jest.fn();
|
|
696
|
-
const hardRedirect = jest.fn();
|
|
697
|
-
|
|
698
|
-
const result = await consumeSsoReturn(oxy, {
|
|
699
|
-
isWeb: () => true,
|
|
700
|
-
storage,
|
|
701
|
-
location: makeLocation({
|
|
702
|
-
hash: '#oxy_sso=none&state=s',
|
|
703
|
-
pathname: '/explore',
|
|
704
|
-
search: '?a=1',
|
|
705
|
-
}),
|
|
706
|
-
history,
|
|
707
|
-
dispatchPopState,
|
|
708
|
-
hardRedirect,
|
|
709
|
-
});
|
|
710
|
-
|
|
711
|
-
expect(result).toBeNull();
|
|
712
|
-
// Only the fragment strip ran — no navigation off the callback path.
|
|
713
|
-
expect(history.calls).toHaveLength(1);
|
|
714
|
-
expect(history.calls[0]?.[2]).toBe('/explore?a=1');
|
|
715
|
-
expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
|
|
716
|
-
expect(hardRedirect).not.toHaveBeenCalled();
|
|
717
|
-
expect(dispatchPopState).not.toHaveBeenCalled();
|
|
718
|
-
});
|
|
719
|
-
|
|
720
|
-
it('does NOT hard-redirect on an "ok" outcome when NOT on the callback path', async () => {
|
|
721
|
-
const oxy = okExchange();
|
|
722
|
-
const storage = makeStorage({
|
|
723
|
-
[ssoStateKey(ORIGIN)]: 's',
|
|
724
|
-
[ssoDestKey(ORIGIN)]: `${ORIGIN}/should-not-apply`,
|
|
725
|
-
});
|
|
726
|
-
const history = makeHistory();
|
|
727
|
-
const dispatchPopState = jest.fn();
|
|
728
|
-
const hardRedirect = jest.fn();
|
|
729
|
-
|
|
730
|
-
const result = await consumeSsoReturn(oxy, {
|
|
731
|
-
isWeb: () => true,
|
|
732
|
-
storage,
|
|
733
|
-
location: makeLocation({
|
|
734
|
-
hash: '#oxy_sso=ok&code=c&state=s',
|
|
735
|
-
pathname: '/feed',
|
|
736
|
-
search: '?tab=home',
|
|
737
|
-
}),
|
|
738
|
-
history,
|
|
739
|
-
dispatchPopState,
|
|
740
|
-
hardRedirect,
|
|
741
|
-
});
|
|
742
|
-
|
|
743
|
-
expect(result).toEqual(SESSION);
|
|
744
|
-
// Only the fragment strip ran — no navigation off the callback path.
|
|
745
|
-
expect(history.calls).toHaveLength(1);
|
|
746
|
-
expect(history.calls[0]?.[2]).toBe('/feed?tab=home');
|
|
747
|
-
expect(hardRedirect).not.toHaveBeenCalled();
|
|
748
|
-
expect(dispatchPopState).not.toHaveBeenCalled();
|
|
749
|
-
});
|
|
750
|
-
});
|
|
751
|
-
|
|
752
|
-
describe('default dispatchPopState (jsdom)', () => {
|
|
753
|
-
it('fires a real popstate listener after an "ok" dest restore on the callback path', async () => {
|
|
754
|
-
const oxy = okExchange();
|
|
755
|
-
const storage = makeStorage({
|
|
756
|
-
[ssoStateKey(ORIGIN)]: 's',
|
|
757
|
-
[ssoDestKey(ORIGIN)]: '/dashboard?tab=home',
|
|
758
|
-
});
|
|
759
|
-
const history = makeHistory();
|
|
760
|
-
const onPopState = jest.fn();
|
|
761
|
-
window.addEventListener('popstate', onPopState);
|
|
762
|
-
|
|
763
|
-
try {
|
|
764
|
-
const result = await consumeSsoReturn(oxy, {
|
|
765
|
-
isWeb: () => true,
|
|
766
|
-
storage,
|
|
767
|
-
location: makeLocation({
|
|
768
|
-
hash: '#oxy_sso=ok&code=c&state=s',
|
|
769
|
-
pathname: SSO_CALLBACK_PATH,
|
|
770
|
-
}),
|
|
771
|
-
history,
|
|
772
|
-
// No injected dispatchPopState — exercise the real default.
|
|
773
|
-
});
|
|
774
|
-
|
|
775
|
-
expect(result).toEqual(SESSION);
|
|
776
|
-
const last = history.calls[history.calls.length - 1];
|
|
777
|
-
expect(last?.[2]).toBe('/dashboard?tab=home');
|
|
778
|
-
expect(onPopState).toHaveBeenCalledTimes(1);
|
|
779
|
-
} finally {
|
|
780
|
-
window.removeEventListener('popstate', onPopState);
|
|
781
|
-
}
|
|
782
|
-
});
|
|
783
|
-
});
|
|
784
|
-
|
|
785
|
-
describe('default hardRedirect (feature-detected, never throws)', () => {
|
|
786
|
-
it('stays total (resolves null, sets loop-breaker flags) on a non-ok outcome with the real default seam', async () => {
|
|
787
|
-
const oxy = okExchange();
|
|
788
|
-
const storage = makeStorage({
|
|
789
|
-
[ssoStateKey(ORIGIN)]: 's',
|
|
790
|
-
[ssoDestKey(ORIGIN)]: '/dashboard',
|
|
791
|
-
});
|
|
792
|
-
const history = makeHistory();
|
|
793
|
-
|
|
794
|
-
// No injected `hardRedirect` — exercise the real default, which reads the
|
|
795
|
-
// jsdom `window.location.replace`. jsdom routes the resulting navigation
|
|
796
|
-
// to its virtual console (a "Not implemented" notice) and does NOT throw,
|
|
797
|
-
// so the function must remain total: resolve null and set the loop-breaker
|
|
798
|
-
// flags. (The injected-`hardRedirect` suite above asserts the precise
|
|
799
|
-
// target argument deterministically.)
|
|
800
|
-
await expect(
|
|
801
|
-
consumeSsoReturn(oxy, {
|
|
802
|
-
isWeb: () => true,
|
|
803
|
-
storage,
|
|
804
|
-
location: makeLocation({
|
|
805
|
-
hash: '#oxy_sso=none&state=s',
|
|
806
|
-
pathname: SSO_CALLBACK_PATH,
|
|
807
|
-
}),
|
|
808
|
-
history,
|
|
809
|
-
}),
|
|
810
|
-
).resolves.toBeNull();
|
|
811
|
-
|
|
812
|
-
expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
|
|
813
|
-
expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
|
|
814
|
-
});
|
|
815
|
-
});
|
|
816
|
-
});
|