@oxyhq/core 5.5.0 → 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 -44
- 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 +40 -1
- package/dist/cjs/session/authStateStore.js +284 -0
- 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 -362
- 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 +40 -1
- package/dist/esm/session/authStateStore.js +278 -0
- 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 -345
- 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 +15 -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 +25 -0
- package/dist/types/session/authStateStore.d.ts +119 -0
- 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 -280
- 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 +74 -65
- 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 +52 -1
- 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.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/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 -393
- 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 -275
- 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 -271
- 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 -156
- 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 -276
- package/src/utils/ssoEstablish.ts +0 -174
- package/src/utils/ssoReturn.ts +0 -389
|
@@ -1,219 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Central SSO bounce helpers — per-origin key builders, the bounce URL builder,
|
|
3
|
-
* the central-IdP predicate, and the self-healing guard.
|
|
4
|
-
*
|
|
5
|
-
* These are a wire/storage contract shared with the IdP and every consumer, so
|
|
6
|
-
* the exact key strings, the 30s TTL, and the same-origin / parse-failure
|
|
7
|
-
* behaviour are all asserted explicitly.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import {
|
|
11
|
-
SSO_CALLBACK_PATH,
|
|
12
|
-
SSO_GUARD_TTL_MS,
|
|
13
|
-
ssoStateKey,
|
|
14
|
-
ssoGuardKey,
|
|
15
|
-
ssoDestKey,
|
|
16
|
-
ssoNoSessionKey,
|
|
17
|
-
ssoAttemptedKey,
|
|
18
|
-
ssoPriorSessionKey,
|
|
19
|
-
ssoSignedOutKey,
|
|
20
|
-
buildSsoBounceUrl,
|
|
21
|
-
isCentralIdPOrigin,
|
|
22
|
-
guardActive,
|
|
23
|
-
silentRestoreSuppressed,
|
|
24
|
-
allowSsoBounce,
|
|
25
|
-
} from '../ssoBounce';
|
|
26
|
-
import { CENTRAL_AUTH_URL } from '../authWebUrl';
|
|
27
|
-
|
|
28
|
-
describe('SSO bounce constants', () => {
|
|
29
|
-
it('pins the callback path and guard TTL (wire/storage contract)', () => {
|
|
30
|
-
expect(SSO_CALLBACK_PATH).toBe('/__oxy/sso-callback');
|
|
31
|
-
expect(SSO_GUARD_TTL_MS).toBe(30_000);
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
describe('per-origin key builders', () => {
|
|
36
|
-
const origin = 'https://mention.earth';
|
|
37
|
-
|
|
38
|
-
it('builds the exact contracted key strings', () => {
|
|
39
|
-
expect(ssoStateKey(origin)).toBe('oxy_sso_state:https://mention.earth');
|
|
40
|
-
expect(ssoGuardKey(origin)).toBe('oxy_sso_guard:https://mention.earth');
|
|
41
|
-
expect(ssoDestKey(origin)).toBe('oxy_sso_dest:https://mention.earth');
|
|
42
|
-
expect(ssoNoSessionKey(origin)).toBe('oxy_sso_no_session:https://mention.earth');
|
|
43
|
-
expect(ssoAttemptedKey(origin)).toBe('oxy_sso_attempted:https://mention.earth');
|
|
44
|
-
expect(ssoPriorSessionKey(origin)).toBe('oxy_sso_prior_session:https://mention.earth');
|
|
45
|
-
expect(ssoSignedOutKey(origin)).toBe('oxy_signed_out:https://mention.earth');
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('namespaces keys per origin so two RPs never collide', () => {
|
|
49
|
-
expect(ssoStateKey('https://a.test')).not.toBe(ssoStateKey('https://b.test'));
|
|
50
|
-
expect(ssoPriorSessionKey('https://a.test')).not.toBe(ssoPriorSessionKey('https://b.test'));
|
|
51
|
-
expect(ssoSignedOutKey('https://a.test')).not.toBe(ssoSignedOutKey('https://b.test'));
|
|
52
|
-
});
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
describe('silentRestoreSuppressed (deliberately-signed-out gate)', () => {
|
|
56
|
-
const origin = 'https://accounts.oxy.so';
|
|
57
|
-
|
|
58
|
-
function storageWith(value: string | null): Pick<Storage, 'getItem'> {
|
|
59
|
-
return { getItem: (key: string) => (key === ssoSignedOutKey(origin) ? value : null) };
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
it('is SUPPRESSED when the signed-out flag is set to "1" for this origin', () => {
|
|
63
|
-
expect(silentRestoreSuppressed(storageWith('1'), origin)).toBe(true);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('is NOT suppressed when the flag is absent (normal restore)', () => {
|
|
67
|
-
expect(silentRestoreSuppressed(storageWith(null), origin)).toBe(false);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it('is NOT suppressed for any non-"1" value (only the exact set value gates)', () => {
|
|
71
|
-
expect(silentRestoreSuppressed(storageWith('0'), origin)).toBe(false);
|
|
72
|
-
expect(silentRestoreSuppressed(storageWith(''), origin)).toBe(false);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('is per-origin: another origin\'s flag does not suppress this one', () => {
|
|
76
|
-
const otherOriginFlag: Pick<Storage, 'getItem'> = {
|
|
77
|
-
getItem: (key: string) => (key === ssoSignedOutKey('https://other.test') ? '1' : null),
|
|
78
|
-
};
|
|
79
|
-
expect(silentRestoreSuppressed(otherOriginFlag, origin)).toBe(false);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('fails safe (NOT suppressed, never throws) when getItem throws', () => {
|
|
83
|
-
const throwing: Pick<Storage, 'getItem'> = {
|
|
84
|
-
getItem: () => {
|
|
85
|
-
throw new Error('storage locked');
|
|
86
|
-
},
|
|
87
|
-
};
|
|
88
|
-
expect(silentRestoreSuppressed(throwing, origin)).toBe(false);
|
|
89
|
-
});
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
describe('allowSsoBounce (smart returning-visitor gate)', () => {
|
|
93
|
-
it('ALLOWS a returning visitor (prior-session hint) with no local session', () => {
|
|
94
|
-
// The core of fix B: a returning user whose local session has expired still
|
|
95
|
-
// gets ONE establish bounce so a central-only cross-domain session recovers.
|
|
96
|
-
expect(
|
|
97
|
-
allowSsoBounce({ hasPriorSession: true, hasLocalSession: false }),
|
|
98
|
-
).toBe(true);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it('SUPPRESSES a truly first-time anonymous visitor (no hint, no local session)', () => {
|
|
102
|
-
// The smart default (the ONLY behaviour): a first-time visitor browses
|
|
103
|
-
// anonymously instead of being force-redirected to the central IdP.
|
|
104
|
-
expect(
|
|
105
|
-
allowSsoBounce({ hasPriorSession: false, hasLocalSession: false }),
|
|
106
|
-
).toBe(false);
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it('ALLOWS when a local session was recovered this boot (spec fidelity)', () => {
|
|
110
|
-
expect(
|
|
111
|
-
allowSsoBounce({ hasPriorSession: false, hasLocalSession: true }),
|
|
112
|
-
).toBe(true);
|
|
113
|
-
});
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
describe('buildSsoBounceUrl', () => {
|
|
117
|
-
const origin = 'https://mention.earth';
|
|
118
|
-
|
|
119
|
-
it('targets the central IdP /sso with all required params (default base)', () => {
|
|
120
|
-
const url = new URL(buildSsoBounceUrl(origin, 'state-123'));
|
|
121
|
-
|
|
122
|
-
expect(url.origin).toBe('https://auth.oxy.so');
|
|
123
|
-
expect(url.pathname).toBe('/sso');
|
|
124
|
-
expect(url.searchParams.get('prompt')).toBe('none');
|
|
125
|
-
expect(url.searchParams.get('client_id')).toBe(origin);
|
|
126
|
-
expect(url.searchParams.get('return_to')).toBe(origin + SSO_CALLBACK_PATH);
|
|
127
|
-
expect(url.searchParams.get('state')).toBe('state-123');
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it('honours an explicit authWebUrl override (staging IdP)', () => {
|
|
131
|
-
const url = new URL(
|
|
132
|
-
buildSsoBounceUrl(origin, 'state-xyz', 'https://auth.mention.earth'),
|
|
133
|
-
);
|
|
134
|
-
|
|
135
|
-
expect(url.origin).toBe('https://auth.mention.earth');
|
|
136
|
-
expect(url.pathname).toBe('/sso');
|
|
137
|
-
expect(url.searchParams.get('client_id')).toBe(origin);
|
|
138
|
-
expect(url.searchParams.get('state')).toBe('state-xyz');
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it('falls back to the central default for an empty override', () => {
|
|
142
|
-
const url = new URL(buildSsoBounceUrl(origin, 's', undefined));
|
|
143
|
-
expect(url.origin).toBe('https://auth.oxy.so');
|
|
144
|
-
});
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
describe('isCentralIdPOrigin', () => {
|
|
148
|
-
it('matches the central IdP origin', () => {
|
|
149
|
-
expect(isCentralIdPOrigin('https://auth.oxy.so')).toBe(true);
|
|
150
|
-
expect(isCentralIdPOrigin(CENTRAL_AUTH_URL)).toBe(true);
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it('normalises a trailing-slash / path candidate via URL origin', () => {
|
|
154
|
-
expect(isCentralIdPOrigin('https://auth.oxy.so/')).toBe(true);
|
|
155
|
-
expect(isCentralIdPOrigin('https://auth.oxy.so/sso')).toBe(true);
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it('rejects a non-central origin', () => {
|
|
159
|
-
expect(isCentralIdPOrigin('https://mention.earth')).toBe(false);
|
|
160
|
-
expect(isCentralIdPOrigin('https://auth.mention.earth')).toBe(false);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it('returns false for an unparseable candidate', () => {
|
|
164
|
-
expect(isCentralIdPOrigin('not a url')).toBe(false);
|
|
165
|
-
expect(isCentralIdPOrigin('')).toBe(false);
|
|
166
|
-
});
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
describe('guardActive', () => {
|
|
170
|
-
const origin = 'https://mention.earth';
|
|
171
|
-
|
|
172
|
-
function storageWith(value: string | null): Pick<Storage, 'getItem'> {
|
|
173
|
-
return { getItem: (key: string) => (key === ssoGuardKey(origin) ? value : null) };
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
it('is active for a present, fresh guard', () => {
|
|
177
|
-
const now = 1_000_000;
|
|
178
|
-
const storage = storageWith(String(now - 1_000)); // 1s old, well under TTL
|
|
179
|
-
expect(guardActive(storage, origin, now)).toBe(true);
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it('is inactive for a present but stale guard (older than TTL)', () => {
|
|
183
|
-
const now = 1_000_000;
|
|
184
|
-
const storage = storageWith(String(now - SSO_GUARD_TTL_MS - 1));
|
|
185
|
-
expect(guardActive(storage, origin, now)).toBe(false);
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it('is inactive for a guard exactly at the TTL boundary (strict <)', () => {
|
|
189
|
-
const now = 1_000_000;
|
|
190
|
-
const storage = storageWith(String(now - SSO_GUARD_TTL_MS));
|
|
191
|
-
expect(guardActive(storage, origin, now)).toBe(false);
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
it('is inactive when the guard is missing', () => {
|
|
195
|
-
expect(guardActive(storageWith(null), origin, 1_000_000)).toBe(false);
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it('is inactive for an empty-string guard value', () => {
|
|
199
|
-
expect(guardActive(storageWith(''), origin, 1_000_000)).toBe(false);
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
it('is inactive for a malformed (non-numeric) guard value', () => {
|
|
203
|
-
expect(guardActive(storageWith('not-a-number'), origin, 1_000_000)).toBe(false);
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
it('is inactive (never throws) when getItem itself throws', () => {
|
|
207
|
-
const throwing: Pick<Storage, 'getItem'> = {
|
|
208
|
-
getItem: () => {
|
|
209
|
-
throw new Error('storage locked');
|
|
210
|
-
},
|
|
211
|
-
};
|
|
212
|
-
expect(guardActive(throwing, origin, 1_000_000)).toBe(false);
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
it('defaults now to Date.now() when omitted', () => {
|
|
216
|
-
const storage = storageWith(String(Date.now() - 500));
|
|
217
|
-
expect(guardActive(storage, origin)).toBe(true);
|
|
218
|
-
});
|
|
219
|
-
});
|
|
@@ -1,204 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `establishIdpSessionAfterClaim` — the post-claim durable-session hop.
|
|
3
|
-
*
|
|
4
|
-
* After a WEB device-flow ("Sign in with Oxy" QR) claim commits, the app holds
|
|
5
|
-
* only in-memory tokens: no `fedcm_session` cookie was ever planted at the IdP,
|
|
6
|
-
* so a reload cannot re-mint a token. This primitive performs ONE top-level
|
|
7
|
-
* establish hop (via the RP-minted establish-URL) so the per-apex IdP cookie is
|
|
8
|
-
* planted and a reload restores via the existing `sso-return` / silent-iframe
|
|
9
|
-
* paths.
|
|
10
|
-
*
|
|
11
|
-
* Invariants pinned here:
|
|
12
|
-
* - web only (no-op off-web / native);
|
|
13
|
-
* - never fires while sitting on the central IdP origin (would loop);
|
|
14
|
-
* - persists the SAME bounce state (`ssoStateKey`/`ssoGuardKey`/`ssoDestKey`)
|
|
15
|
-
* the `buildSsoBounceUrl` machinery primes, so the post-bounce `sso-return`
|
|
16
|
-
* step validates the state, exchanges the code, and restores the dest;
|
|
17
|
-
* - persists ONLY after the establish-URL request succeeds (no stale state on
|
|
18
|
-
* failure);
|
|
19
|
-
* - navigates exactly once, to the server-returned establish URL;
|
|
20
|
-
* - total: an establish-request failure leaves the committed in-memory session
|
|
21
|
-
* untouched, does NOT navigate, and returns `false` (user no worse off).
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import { establishIdpSessionAfterClaim } from '../ssoEstablish';
|
|
25
|
-
import {
|
|
26
|
-
ssoStateKey,
|
|
27
|
-
ssoGuardKey,
|
|
28
|
-
ssoDestKey,
|
|
29
|
-
} from '../ssoBounce';
|
|
30
|
-
import { CENTRAL_AUTH_URL } from '../authWebUrl';
|
|
31
|
-
|
|
32
|
-
const RP_ORIGIN = 'https://accounts.oxy.so';
|
|
33
|
-
const RP_HREF = 'https://accounts.oxy.so/settings';
|
|
34
|
-
const ESTABLISH_URL =
|
|
35
|
-
'https://auth.oxy.so/sso/establish?et=jwt.abc.def&return_to=https%3A%2F%2Faccounts.oxy.so%2F__oxy%2Fsso-callback&state=state-xyz';
|
|
36
|
-
|
|
37
|
-
function makeStorage(): {
|
|
38
|
-
storage: Pick<Storage, 'getItem' | 'setItem'>;
|
|
39
|
-
map: Map<string, string>;
|
|
40
|
-
} {
|
|
41
|
-
const map = new Map<string, string>();
|
|
42
|
-
return {
|
|
43
|
-
map,
|
|
44
|
-
storage: {
|
|
45
|
-
getItem: (k: string) => (map.has(k) ? (map.get(k) as string) : null),
|
|
46
|
-
setItem: (k: string, v: string) => {
|
|
47
|
-
map.set(k, v);
|
|
48
|
-
},
|
|
49
|
-
},
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
describe('establishIdpSessionAfterClaim', () => {
|
|
54
|
-
it('requests the establish URL with the RP origin + generated state and navigates once (web)', async () => {
|
|
55
|
-
const { storage, map } = makeStorage();
|
|
56
|
-
const navigated: string[] = [];
|
|
57
|
-
const requestSsoEstablishUrl = jest.fn(async (origin: string, state: string) => {
|
|
58
|
-
expect(origin).toBe(RP_ORIGIN);
|
|
59
|
-
expect(state).toBe('state-xyz');
|
|
60
|
-
return { establishUrl: ESTABLISH_URL };
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
const result = await establishIdpSessionAfterClaim(
|
|
64
|
-
{ requestSsoEstablishUrl },
|
|
65
|
-
{
|
|
66
|
-
isWeb: () => true,
|
|
67
|
-
storage,
|
|
68
|
-
location: { origin: RP_ORIGIN, href: RP_HREF },
|
|
69
|
-
navigate: (url) => navigated.push(url),
|
|
70
|
-
generateState: () => 'state-xyz',
|
|
71
|
-
now: () => 1_700_000_000_000,
|
|
72
|
-
},
|
|
73
|
-
);
|
|
74
|
-
|
|
75
|
-
expect(result).toBe(true);
|
|
76
|
-
expect(requestSsoEstablishUrl).toHaveBeenCalledTimes(1);
|
|
77
|
-
expect(navigated).toEqual([ESTABLISH_URL]);
|
|
78
|
-
|
|
79
|
-
// Bounce state persisted so the post-bounce `sso-return` step validates it.
|
|
80
|
-
expect(map.get(ssoStateKey(RP_ORIGIN))).toBe('state-xyz');
|
|
81
|
-
expect(map.get(ssoGuardKey(RP_ORIGIN))).toBe(String(1_700_000_000_000));
|
|
82
|
-
expect(map.get(ssoDestKey(RP_ORIGIN))).toBe(RP_HREF);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it('is a no-op off-web (native): no request, no navigation, no state', async () => {
|
|
86
|
-
const { storage, map } = makeStorage();
|
|
87
|
-
const navigated: string[] = [];
|
|
88
|
-
const requestSsoEstablishUrl = jest.fn(async () => ({ establishUrl: ESTABLISH_URL }));
|
|
89
|
-
|
|
90
|
-
const result = await establishIdpSessionAfterClaim(
|
|
91
|
-
{ requestSsoEstablishUrl },
|
|
92
|
-
{
|
|
93
|
-
isWeb: () => false,
|
|
94
|
-
storage,
|
|
95
|
-
location: { origin: RP_ORIGIN, href: RP_HREF },
|
|
96
|
-
navigate: (url) => navigated.push(url),
|
|
97
|
-
},
|
|
98
|
-
);
|
|
99
|
-
|
|
100
|
-
expect(result).toBe(false);
|
|
101
|
-
expect(requestSsoEstablishUrl).not.toHaveBeenCalled();
|
|
102
|
-
expect(navigated).toEqual([]);
|
|
103
|
-
expect(map.size).toBe(0);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it('never fires while sitting on the central IdP origin (loop guard)', async () => {
|
|
107
|
-
const { storage, map } = makeStorage();
|
|
108
|
-
const navigated: string[] = [];
|
|
109
|
-
const requestSsoEstablishUrl = jest.fn(async () => ({ establishUrl: ESTABLISH_URL }));
|
|
110
|
-
|
|
111
|
-
const result = await establishIdpSessionAfterClaim(
|
|
112
|
-
{ requestSsoEstablishUrl },
|
|
113
|
-
{
|
|
114
|
-
isWeb: () => true,
|
|
115
|
-
storage,
|
|
116
|
-
location: { origin: new URL(CENTRAL_AUTH_URL).origin, href: `${CENTRAL_AUTH_URL}/` },
|
|
117
|
-
navigate: (url) => navigated.push(url),
|
|
118
|
-
},
|
|
119
|
-
);
|
|
120
|
-
|
|
121
|
-
expect(result).toBe(false);
|
|
122
|
-
expect(requestSsoEstablishUrl).not.toHaveBeenCalled();
|
|
123
|
-
expect(navigated).toEqual([]);
|
|
124
|
-
expect(map.size).toBe(0);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it('on an establish-request failure: no navigation, no persisted state, returns false (single attempt)', async () => {
|
|
128
|
-
const { storage, map } = makeStorage();
|
|
129
|
-
const navigated: string[] = [];
|
|
130
|
-
const onError = jest.fn();
|
|
131
|
-
const requestSsoEstablishUrl = jest.fn(async () => {
|
|
132
|
-
throw new Error('403 unapproved origin');
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
const result = await establishIdpSessionAfterClaim(
|
|
136
|
-
{ requestSsoEstablishUrl },
|
|
137
|
-
{
|
|
138
|
-
isWeb: () => true,
|
|
139
|
-
storage,
|
|
140
|
-
location: { origin: RP_ORIGIN, href: RP_HREF },
|
|
141
|
-
navigate: (url) => navigated.push(url),
|
|
142
|
-
generateState: () => 'state-xyz',
|
|
143
|
-
onError,
|
|
144
|
-
},
|
|
145
|
-
);
|
|
146
|
-
|
|
147
|
-
expect(result).toBe(false);
|
|
148
|
-
expect(requestSsoEstablishUrl).toHaveBeenCalledTimes(1);
|
|
149
|
-
expect(navigated).toEqual([]);
|
|
150
|
-
// No stale bounce state left behind on failure.
|
|
151
|
-
expect(map.size).toBe(0);
|
|
152
|
-
expect(onError).toHaveBeenCalledTimes(1);
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it('does not navigate when the server returns no establish URL', async () => {
|
|
156
|
-
const { storage, map } = makeStorage();
|
|
157
|
-
const navigated: string[] = [];
|
|
158
|
-
const requestSsoEstablishUrl = jest.fn(async () => ({ establishUrl: '' }));
|
|
159
|
-
|
|
160
|
-
const result = await establishIdpSessionAfterClaim(
|
|
161
|
-
{ requestSsoEstablishUrl },
|
|
162
|
-
{
|
|
163
|
-
isWeb: () => true,
|
|
164
|
-
storage,
|
|
165
|
-
location: { origin: RP_ORIGIN, href: RP_HREF },
|
|
166
|
-
navigate: (url) => navigated.push(url),
|
|
167
|
-
generateState: () => 'state-xyz',
|
|
168
|
-
},
|
|
169
|
-
);
|
|
170
|
-
|
|
171
|
-
expect(result).toBe(false);
|
|
172
|
-
expect(navigated).toEqual([]);
|
|
173
|
-
expect(map.size).toBe(0);
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it.each([
|
|
177
|
-
['unparseable', 'not a url'],
|
|
178
|
-
['non-https (http)', 'http://auth.oxy.so/sso/establish?et=x&state=s'],
|
|
179
|
-
['wrong path', 'https://auth.oxy.so/evil?et=x&state=s'],
|
|
180
|
-
['non-auth host', 'https://evil.oxy.so/sso/establish?et=x&state=s'],
|
|
181
|
-
])(
|
|
182
|
-
'aborts silently (no navigation, no state) for a %s establish URL',
|
|
183
|
-
async (_label, badUrl) => {
|
|
184
|
-
const { storage, map } = makeStorage();
|
|
185
|
-
const navigated: string[] = [];
|
|
186
|
-
const requestSsoEstablishUrl = jest.fn(async () => ({ establishUrl: badUrl }));
|
|
187
|
-
|
|
188
|
-
const result = await establishIdpSessionAfterClaim(
|
|
189
|
-
{ requestSsoEstablishUrl },
|
|
190
|
-
{
|
|
191
|
-
isWeb: () => true,
|
|
192
|
-
storage,
|
|
193
|
-
location: { origin: RP_ORIGIN, href: RP_HREF },
|
|
194
|
-
navigate: (url) => navigated.push(url),
|
|
195
|
-
generateState: () => 'state-xyz',
|
|
196
|
-
},
|
|
197
|
-
);
|
|
198
|
-
|
|
199
|
-
expect(result).toBe(false);
|
|
200
|
-
expect(navigated).toEqual([]);
|
|
201
|
-
expect(map.size).toBe(0);
|
|
202
|
-
},
|
|
203
|
-
);
|
|
204
|
-
});
|
|
@@ -1,276 +0,0 @@
|
|
|
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 type { SessionLoginResponse } from '../../models/session';
|
|
11
|
-
import { consumeSsoReturn, parseSsoReturnFragment } from '../ssoReturn';
|
|
12
|
-
import {
|
|
13
|
-
getSsoCallbackBootstrapScript,
|
|
14
|
-
ssoAttemptedKey,
|
|
15
|
-
ssoCallbackBootstrapKey,
|
|
16
|
-
ssoDestKey,
|
|
17
|
-
ssoNoSessionKey,
|
|
18
|
-
ssoStateKey,
|
|
19
|
-
} from '../ssoBounce';
|
|
20
|
-
|
|
21
|
-
class MemorySsoStorage implements Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> {
|
|
22
|
-
private readonly values = new Map<string, string>();
|
|
23
|
-
|
|
24
|
-
getItem(key: string): string | null {
|
|
25
|
-
return this.values.get(key) ?? null;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
setItem(key: string, value: string): void {
|
|
29
|
-
this.values.set(key, value);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
removeItem(key: string): void {
|
|
33
|
-
this.values.delete(key);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const ORIGIN = 'https://app.mention.earth';
|
|
38
|
-
|
|
39
|
-
const exchangedSession: SessionLoginResponse = {
|
|
40
|
-
sessionId: 'sess_sso',
|
|
41
|
-
deviceId: 'device_sso',
|
|
42
|
-
accessToken: 'access_sso',
|
|
43
|
-
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
44
|
-
user: { id: 'user_sso', username: 'sso-user' },
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
describe('parseSsoReturnFragment', () => {
|
|
48
|
-
describe('ok', () => {
|
|
49
|
-
it('parses a success fragment with code and state', () => {
|
|
50
|
-
const result = parseSsoReturnFragment('#oxy_sso=ok&code=abc123&state=xyz');
|
|
51
|
-
|
|
52
|
-
expect(result).toEqual({ kind: 'ok', code: 'abc123', state: 'xyz' });
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('parses a success fragment without a leading #', () => {
|
|
56
|
-
const result = parseSsoReturnFragment('oxy_sso=ok&code=abc123&state=xyz');
|
|
57
|
-
|
|
58
|
-
expect(result).toEqual({ kind: 'ok', code: 'abc123', state: 'xyz' });
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('omits code when ok carries no code', () => {
|
|
62
|
-
const result = parseSsoReturnFragment('#oxy_sso=ok&state=xyz');
|
|
63
|
-
|
|
64
|
-
expect(result).toEqual({ kind: 'ok', state: 'xyz' });
|
|
65
|
-
expect(result?.code).toBeUndefined();
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('URL-decodes percent-encoded values', () => {
|
|
69
|
-
const result = parseSsoReturnFragment('#oxy_sso=ok&code=a%2Bb%2Fc&state=s%20t');
|
|
70
|
-
|
|
71
|
-
expect(result).toEqual({ kind: 'ok', code: 'a+b/c', state: 's t' });
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it('ignores a stray reason on an ok outcome', () => {
|
|
75
|
-
const result = parseSsoReturnFragment('#oxy_sso=ok&code=abc123&state=xyz&reason=no_cookie');
|
|
76
|
-
|
|
77
|
-
expect(result).toEqual({ kind: 'ok', code: 'abc123', state: 'xyz' });
|
|
78
|
-
expect(result?.reason).toBeUndefined();
|
|
79
|
-
});
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
describe('none', () => {
|
|
83
|
-
it('parses a none fragment and carries state but never a code', () => {
|
|
84
|
-
const result = parseSsoReturnFragment('#oxy_sso=none&state=xyz');
|
|
85
|
-
|
|
86
|
-
expect(result).toEqual({ kind: 'none', state: 'xyz' });
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('ignores a stray code on a none outcome', () => {
|
|
90
|
-
const result = parseSsoReturnFragment('#oxy_sso=none&code=leaked&state=xyz');
|
|
91
|
-
|
|
92
|
-
expect(result).toEqual({ kind: 'none', state: 'xyz' });
|
|
93
|
-
expect(result?.code).toBeUndefined();
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('carries a machine-readable reason on a none outcome', () => {
|
|
97
|
-
const result = parseSsoReturnFragment('#oxy_sso=none&reason=no_cookie&state=xyz');
|
|
98
|
-
|
|
99
|
-
expect(result).toEqual({ kind: 'none', state: 'xyz', reason: 'no_cookie' });
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it('omits reason when the none outcome carries none', () => {
|
|
103
|
-
const result = parseSsoReturnFragment('#oxy_sso=none&state=xyz');
|
|
104
|
-
|
|
105
|
-
expect(result?.reason).toBeUndefined();
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
describe('error', () => {
|
|
110
|
-
it('parses an error fragment', () => {
|
|
111
|
-
const result = parseSsoReturnFragment('#oxy_sso=error&state=xyz');
|
|
112
|
-
|
|
113
|
-
expect(result).toEqual({ kind: 'error', state: 'xyz' });
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('ignores a stray code on an error outcome', () => {
|
|
117
|
-
const result = parseSsoReturnFragment('#oxy_sso=error&code=leaked');
|
|
118
|
-
|
|
119
|
-
expect(result).toEqual({ kind: 'error' });
|
|
120
|
-
expect(result?.code).toBeUndefined();
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it('carries a machine-readable reason on an error outcome', () => {
|
|
124
|
-
const result = parseSsoReturnFragment('#oxy_sso=error&reason=no_grant_establish');
|
|
125
|
-
|
|
126
|
-
expect(result).toEqual({ kind: 'error', reason: 'no_grant_establish' });
|
|
127
|
-
});
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
describe('null (not an oxy_sso fragment)', () => {
|
|
131
|
-
it('returns null for an empty string', () => {
|
|
132
|
-
expect(parseSsoReturnFragment('')).toBeNull();
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('returns null for a bare #', () => {
|
|
136
|
-
expect(parseSsoReturnFragment('#')).toBeNull();
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it('returns null for undefined', () => {
|
|
140
|
-
expect(parseSsoReturnFragment(undefined)).toBeNull();
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it('returns null for null', () => {
|
|
144
|
-
expect(parseSsoReturnFragment(null)).toBeNull();
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it('returns null for a fragment without oxy_sso', () => {
|
|
148
|
-
expect(parseSsoReturnFragment('#access_token=foo&state=bar')).toBeNull();
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it('returns null for an unrecognised oxy_sso value', () => {
|
|
152
|
-
expect(parseSsoReturnFragment('#oxy_sso=bogus&code=x')).toBeNull();
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it('returns null for an empty oxy_sso value', () => {
|
|
156
|
-
expect(parseSsoReturnFragment('#oxy_sso=&code=x')).toBeNull();
|
|
157
|
-
});
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
describe('malformed / defensive', () => {
|
|
161
|
-
it('never throws and returns a valid kind for junk after the marker', () => {
|
|
162
|
-
const result = parseSsoReturnFragment('#oxy_sso=ok&=&&&code=c&&');
|
|
163
|
-
|
|
164
|
-
expect(result?.kind).toBe('ok');
|
|
165
|
-
expect(result?.code).toBe('c');
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it('always reports a kind in the strict union', () => {
|
|
169
|
-
for (const input of [
|
|
170
|
-
'#oxy_sso=ok',
|
|
171
|
-
'#oxy_sso=none',
|
|
172
|
-
'#oxy_sso=error',
|
|
173
|
-
]) {
|
|
174
|
-
const result = parseSsoReturnFragment(input);
|
|
175
|
-
expect(result).not.toBeNull();
|
|
176
|
-
expect(['ok', 'none', 'error']).toContain(result?.kind);
|
|
177
|
-
}
|
|
178
|
-
});
|
|
179
|
-
});
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
describe('consumeSsoReturn pre-hydration callback bootstrap', () => {
|
|
183
|
-
it('continues an ok callback after the HTML bootstrap moved the URL to a hydratable route', async () => {
|
|
184
|
-
const storage = new MemorySsoStorage();
|
|
185
|
-
const replaceStateCalls: string[] = [];
|
|
186
|
-
const dispatchPopState = jest.fn();
|
|
187
|
-
const hardRedirect = jest.fn();
|
|
188
|
-
const exchangeSsoCode = jest.fn(async (): Promise<SessionLoginResponse> => exchangedSession);
|
|
189
|
-
|
|
190
|
-
storage.setItem(ssoStateKey(ORIGIN), 'state-ok');
|
|
191
|
-
storage.setItem(ssoDestKey(ORIGIN), `${ORIGIN}/explore?tab=home#top`);
|
|
192
|
-
storage.setItem(ssoCallbackBootstrapKey(ORIGIN), '1');
|
|
193
|
-
|
|
194
|
-
const session = await consumeSsoReturn(
|
|
195
|
-
{ exchangeSsoCode },
|
|
196
|
-
{
|
|
197
|
-
isWeb: () => true,
|
|
198
|
-
storage,
|
|
199
|
-
location: {
|
|
200
|
-
hash: '#oxy_sso=ok&code=opaque-code&state=state-ok',
|
|
201
|
-
origin: ORIGIN,
|
|
202
|
-
pathname: '/',
|
|
203
|
-
search: '',
|
|
204
|
-
},
|
|
205
|
-
history: {
|
|
206
|
-
replaceState: (_data: unknown, _unused: string, url?: string | URL | null): void => {
|
|
207
|
-
replaceStateCalls.push(String(url ?? ''));
|
|
208
|
-
},
|
|
209
|
-
},
|
|
210
|
-
dispatchPopState,
|
|
211
|
-
hardRedirect,
|
|
212
|
-
},
|
|
213
|
-
);
|
|
214
|
-
|
|
215
|
-
expect(session).toBe(exchangedSession);
|
|
216
|
-
expect(exchangeSsoCode).toHaveBeenCalledWith('opaque-code', 'state-ok');
|
|
217
|
-
expect(replaceStateCalls).toEqual(['/', '/explore?tab=home#top']);
|
|
218
|
-
expect(dispatchPopState).toHaveBeenCalledTimes(1);
|
|
219
|
-
expect(hardRedirect).not.toHaveBeenCalled();
|
|
220
|
-
expect(storage.getItem(ssoCallbackBootstrapKey(ORIGIN))).toBeNull();
|
|
221
|
-
expect(storage.getItem(ssoDestKey(ORIGIN))).toBeNull();
|
|
222
|
-
expect(storage.getItem(ssoNoSessionKey(ORIGIN))).toBeNull();
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
it('leaves a bootstrapped none callback with loop breakers set and no exchange', async () => {
|
|
226
|
-
const storage = new MemorySsoStorage();
|
|
227
|
-
const replaceStateCalls: string[] = [];
|
|
228
|
-
const dispatchPopState = jest.fn();
|
|
229
|
-
const hardRedirect = jest.fn();
|
|
230
|
-
const exchangeSsoCode = jest.fn(async (): Promise<SessionLoginResponse> => exchangedSession);
|
|
231
|
-
|
|
232
|
-
storage.setItem(ssoStateKey(ORIGIN), 'state-none');
|
|
233
|
-
storage.setItem(ssoDestKey(ORIGIN), `${ORIGIN}/library`);
|
|
234
|
-
storage.setItem(ssoCallbackBootstrapKey(ORIGIN), '1');
|
|
235
|
-
|
|
236
|
-
const session = await consumeSsoReturn(
|
|
237
|
-
{ exchangeSsoCode },
|
|
238
|
-
{
|
|
239
|
-
isWeb: () => true,
|
|
240
|
-
storage,
|
|
241
|
-
location: {
|
|
242
|
-
hash: '#oxy_sso=none&state=state-none',
|
|
243
|
-
origin: ORIGIN,
|
|
244
|
-
pathname: '/',
|
|
245
|
-
search: '',
|
|
246
|
-
},
|
|
247
|
-
history: {
|
|
248
|
-
replaceState: (_data: unknown, _unused: string, url?: string | URL | null): void => {
|
|
249
|
-
replaceStateCalls.push(String(url ?? ''));
|
|
250
|
-
},
|
|
251
|
-
},
|
|
252
|
-
dispatchPopState,
|
|
253
|
-
hardRedirect,
|
|
254
|
-
},
|
|
255
|
-
);
|
|
256
|
-
|
|
257
|
-
expect(session).toBeNull();
|
|
258
|
-
expect(exchangeSsoCode).not.toHaveBeenCalled();
|
|
259
|
-
expect(replaceStateCalls).toEqual(['/']);
|
|
260
|
-
expect(dispatchPopState).not.toHaveBeenCalled();
|
|
261
|
-
expect(hardRedirect).toHaveBeenCalledWith(`${ORIGIN}/library`);
|
|
262
|
-
expect(storage.getItem(ssoCallbackBootstrapKey(ORIGIN))).toBeNull();
|
|
263
|
-
expect(storage.getItem(ssoDestKey(ORIGIN))).toBeNull();
|
|
264
|
-
expect(storage.getItem(ssoNoSessionKey(ORIGIN))).toBe('1');
|
|
265
|
-
expect(storage.getItem(ssoAttemptedKey(ORIGIN))).toBe('1');
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
it('exposes a pre-hydration script that preserves the SSO fragment', () => {
|
|
269
|
-
const script = getSsoCallbackBootstrapScript();
|
|
270
|
-
|
|
271
|
-
expect(script).toContain('/__oxy/sso-callback');
|
|
272
|
-
expect(script).toContain('oxy_sso=');
|
|
273
|
-
expect(script).toContain('window.history.replaceState');
|
|
274
|
-
expect(script).toContain('window.location.hash');
|
|
275
|
-
});
|
|
276
|
-
});
|