@oxyhq/core 5.5.0 → 7.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 +7 -4
- 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 +54 -45
- package/dist/cjs/mixins/OxyServices.accounts.js +13 -26
- package/dist/cjs/mixins/OxyServices.auth.js +66 -201
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +119 -0
- package/dist/cjs/mixins/index.js +8 -17
- package/dist/cjs/server/index.js +2 -2
- 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/shared/utils/debugUtils.js +3 -3
- package/dist/cjs/utils/accountUtils.js +1 -55
- package/dist/cjs/utils/authWebUrl.js +10 -39
- package/dist/cjs/utils/coldBoot.js +10 -8
- package/dist/cjs/utils/platform.js +19 -0
- package/dist/cjs/utils/registrableApex.js +49 -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 +7 -4
- 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 +34 -17
- package/dist/esm/mixins/OxyServices.accounts.js +13 -26
- package/dist/esm/mixins/OxyServices.auth.js +66 -201
- package/dist/esm/mixins/OxyServices.deviceBoot.js +116 -0
- package/dist/esm/mixins/index.js +8 -17
- package/dist/esm/server/index.js +1 -1
- 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/shared/utils/debugUtils.js +3 -3
- package/dist/esm/utils/accountUtils.js +0 -53
- package/dist/esm/utils/authWebUrl.js +9 -37
- package/dist/esm/utils/coldBoot.js +10 -8
- package/dist/esm/utils/platform.js +18 -0
- package/dist/esm/utils/registrableApex.js +46 -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 +14 -1
- 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 +14 -21
- package/dist/types/mixins/OxyServices.accounts.d.ts +24 -29
- 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.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 +5 -9
- package/dist/types/models/interfaces.d.ts +0 -67
- package/dist/types/models/session.d.ts +4 -5
- package/dist/types/server/index.d.ts +1 -1
- package/dist/types/session/SessionClient.d.ts +26 -1
- package/dist/types/session/authStateStore.d.ts +119 -0
- package/dist/types/session/refresh.d.ts +93 -0
- package/dist/types/shared/utils/debugUtils.d.ts +3 -3
- package/dist/types/utils/accountUtils.d.ts +2 -17
- package/dist/types/utils/authWebUrl.d.ts +9 -35
- package/dist/types/utils/coldBoot.d.ts +17 -14
- package/dist/types/utils/platform.d.ts +14 -0
- package/dist/types/utils/registrableApex.d.ts +31 -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 +20 -4
- 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 +75 -65
- package/src/mixins/OxyServices.accounts.ts +27 -39
- package/src/mixins/OxyServices.auth.ts +78 -253
- 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__/passwordSignIn.test.ts +91 -0
- package/src/mixins/index.ts +10 -23
- package/src/models/interfaces.ts +0 -79
- package/src/models/session.ts +4 -5
- package/src/server/index.ts +1 -1
- package/src/session/SessionClient.ts +53 -2
- 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/shared/utils/debugUtils.ts +3 -3
- package/src/utils/__tests__/authWebUrl.test.ts +5 -40
- package/src/utils/__tests__/registrableApex.test.ts +62 -0
- package/src/utils/__tests__/validationUtils.test.ts +30 -0
- package/src/utils/accountUtils.ts +2 -65
- package/src/utils/authWebUrl.ts +9 -39
- package/src/utils/coldBoot.ts +17 -14
- package/src/utils/platform.ts +21 -0
- package/src/utils/registrableApex.ts +45 -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/fapiAutoDetect.js +0 -99
- 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/fapiAutoDetect.js +0 -95
- 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.redirect.d.ts +0 -92
- package/dist/types/mixins/OxyServices.silent.d.ts +0 -132
- package/dist/types/mixins/OxyServices.sso.d.ts +0 -138
- package/dist/types/utils/fapiAutoDetect.d.ts +0 -56
- 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__/fapiAutoDetect.test.ts +0 -183
- 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/fapiAutoDetect.ts +0 -91
- package/src/utils/ssoEstablish.ts +0 -174
- package/src/utils/ssoReturn.ts +0 -389
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createWebAuthStateStore,
|
|
3
|
+
createNativeAuthStateStore,
|
|
4
|
+
createMemoryAuthStateStore,
|
|
5
|
+
AUTH_STATE_STORAGE_KEY,
|
|
6
|
+
DEVICE_TOKEN_STORAGE_KEY,
|
|
7
|
+
type PersistedAuthState,
|
|
8
|
+
type NativeKeyValueStorage,
|
|
9
|
+
} from '../authStateStore';
|
|
10
|
+
|
|
11
|
+
const SAMPLE: PersistedAuthState = {
|
|
12
|
+
sessionId: 's-1',
|
|
13
|
+
refreshToken: 'r-abcdefghijklmnop',
|
|
14
|
+
userId: 'u-1',
|
|
15
|
+
deviceToken: 'd-1234567890',
|
|
16
|
+
accessToken: 'a-jwt',
|
|
17
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** A minimal in-map Storage; setItem can be made to throw (quota simulation). */
|
|
21
|
+
function makeFakeStorage(opts: { throwOnSet?: boolean } = {}): Storage {
|
|
22
|
+
const map = new Map<string, string>();
|
|
23
|
+
return {
|
|
24
|
+
getItem: (k: string) => (map.has(k) ? (map.get(k) as string) : null),
|
|
25
|
+
setItem: (k: string, v: string) => {
|
|
26
|
+
if (opts.throwOnSet) {
|
|
27
|
+
throw new DOMException('QuotaExceededError', 'QuotaExceededError');
|
|
28
|
+
}
|
|
29
|
+
map.set(k, v);
|
|
30
|
+
},
|
|
31
|
+
removeItem: (k: string) => {
|
|
32
|
+
map.delete(k);
|
|
33
|
+
},
|
|
34
|
+
clear: () => map.clear(),
|
|
35
|
+
key: (i: number) => Array.from(map.keys())[i] ?? null,
|
|
36
|
+
get length() {
|
|
37
|
+
return map.size;
|
|
38
|
+
},
|
|
39
|
+
} as Storage;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function installLocalStorage(storage: Storage): void {
|
|
43
|
+
Object.defineProperty(globalThis, 'localStorage', {
|
|
44
|
+
value: storage,
|
|
45
|
+
configurable: true,
|
|
46
|
+
writable: true,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
// Remove whatever localStorage the test installed (value or throwing getter).
|
|
52
|
+
if (Object.getOwnPropertyDescriptor(globalThis, 'localStorage')) {
|
|
53
|
+
delete (globalThis as { localStorage?: Storage }).localStorage;
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('createWebAuthStateStore', () => {
|
|
58
|
+
it('round-trips a PersistedAuthState under the versioned key', async () => {
|
|
59
|
+
const storage = makeFakeStorage();
|
|
60
|
+
installLocalStorage(storage);
|
|
61
|
+
const store = createWebAuthStateStore();
|
|
62
|
+
|
|
63
|
+
await store.save(SAMPLE);
|
|
64
|
+
expect(storage.getItem(AUTH_STATE_STORAGE_KEY)).toBeTruthy();
|
|
65
|
+
expect(await store.load()).toEqual(SAMPLE);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('clear() wipes the session but the deviceToken survives', async () => {
|
|
69
|
+
installLocalStorage(makeFakeStorage());
|
|
70
|
+
const store = createWebAuthStateStore();
|
|
71
|
+
|
|
72
|
+
await store.save(SAMPLE);
|
|
73
|
+
await store.saveDeviceToken('device-token-xyz');
|
|
74
|
+
await store.clear();
|
|
75
|
+
|
|
76
|
+
expect(await store.load()).toBeNull();
|
|
77
|
+
expect(await store.loadDeviceToken()).toBe('device-token-xyz');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('persists the deviceToken under a separate long-lived key', async () => {
|
|
81
|
+
const storage = makeFakeStorage();
|
|
82
|
+
installLocalStorage(storage);
|
|
83
|
+
const store = createWebAuthStateStore();
|
|
84
|
+
|
|
85
|
+
await store.saveDeviceToken('dt');
|
|
86
|
+
expect(storage.getItem(DEVICE_TOKEN_STORAGE_KEY)).toBe('dt');
|
|
87
|
+
await store.clearDeviceToken();
|
|
88
|
+
expect(await store.loadDeviceToken()).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('returns null for a malformed or incomplete blob', async () => {
|
|
92
|
+
const storage = makeFakeStorage();
|
|
93
|
+
installLocalStorage(storage);
|
|
94
|
+
const store = createWebAuthStateStore();
|
|
95
|
+
|
|
96
|
+
storage.setItem(AUTH_STATE_STORAGE_KEY, 'not-json');
|
|
97
|
+
expect(await store.load()).toBeNull();
|
|
98
|
+
|
|
99
|
+
storage.setItem(AUTH_STATE_STORAGE_KEY, JSON.stringify({ sessionId: 's', userId: 'u' }));
|
|
100
|
+
expect(await store.load()).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('degrades to in-memory when accessing localStorage throws (sandboxed iframe SecurityError)', async () => {
|
|
104
|
+
Object.defineProperty(globalThis, 'localStorage', {
|
|
105
|
+
configurable: true,
|
|
106
|
+
get() {
|
|
107
|
+
throw new DOMException('The operation is insecure.', 'SecurityError');
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Construction must not throw, and the store must still function (in-memory).
|
|
112
|
+
const store = createWebAuthStateStore();
|
|
113
|
+
await expect(store.save(SAMPLE)).resolves.toBeUndefined();
|
|
114
|
+
expect(await store.load()).toEqual(SAMPLE);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('swallows a write that throws (quota / private mode) without rejecting', async () => {
|
|
118
|
+
installLocalStorage(makeFakeStorage({ throwOnSet: true }));
|
|
119
|
+
const store = createWebAuthStateStore();
|
|
120
|
+
|
|
121
|
+
await expect(store.save(SAMPLE)).resolves.toBeUndefined();
|
|
122
|
+
// The write never reached storage...
|
|
123
|
+
expect(localStorage.getItem(AUTH_STATE_STORAGE_KEY)).toBeNull();
|
|
124
|
+
// ...but the in-memory mirror keeps the session live for this page's lifetime.
|
|
125
|
+
expect(await store.load()).toEqual(SAMPLE);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('the mirror keeps the deviceToken live when the write throws', async () => {
|
|
129
|
+
installLocalStorage(makeFakeStorage({ throwOnSet: true }));
|
|
130
|
+
const store = createWebAuthStateStore();
|
|
131
|
+
|
|
132
|
+
await store.saveDeviceToken('dt-mirrored');
|
|
133
|
+
expect(await store.loadDeviceToken()).toBe('dt-mirrored');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('a cleared session reads null even if storage later holds a stale blob (mirror wins)', async () => {
|
|
137
|
+
const storage = makeFakeStorage();
|
|
138
|
+
installLocalStorage(storage);
|
|
139
|
+
const store = createWebAuthStateStore();
|
|
140
|
+
|
|
141
|
+
await store.save(SAMPLE);
|
|
142
|
+
await store.clear();
|
|
143
|
+
// Something (another tab / a failed remove) leaves a stale blob behind.
|
|
144
|
+
storage.setItem(AUTH_STATE_STORAGE_KEY, JSON.stringify(SAMPLE));
|
|
145
|
+
// The authoritative in-memory mirror still reports the cleared state.
|
|
146
|
+
expect(await store.load()).toBeNull();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('createNativeAuthStateStore', () => {
|
|
151
|
+
function makeNativeStorage(): NativeKeyValueStorage & { map: Map<string, string> } {
|
|
152
|
+
const map = new Map<string, string>();
|
|
153
|
+
return {
|
|
154
|
+
map,
|
|
155
|
+
getItem: async (k) => (map.has(k) ? (map.get(k) as string) : null),
|
|
156
|
+
setItem: async (k, v) => {
|
|
157
|
+
map.set(k, v);
|
|
158
|
+
},
|
|
159
|
+
removeItem: async (k) => {
|
|
160
|
+
map.delete(k);
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
it('round-trips through the injected async storage', async () => {
|
|
166
|
+
const storage = makeNativeStorage();
|
|
167
|
+
const store = createNativeAuthStateStore(storage);
|
|
168
|
+
|
|
169
|
+
await store.save(SAMPLE);
|
|
170
|
+
expect(storage.map.get(AUTH_STATE_STORAGE_KEY)).toBeTruthy();
|
|
171
|
+
expect(await store.load()).toEqual(SAMPLE);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('deviceToken survives a session clear', async () => {
|
|
175
|
+
const store = createNativeAuthStateStore(makeNativeStorage());
|
|
176
|
+
await store.save(SAMPLE);
|
|
177
|
+
await store.saveDeviceToken('dt-native');
|
|
178
|
+
await store.clear();
|
|
179
|
+
expect(await store.load()).toBeNull();
|
|
180
|
+
expect(await store.loadDeviceToken()).toBe('dt-native');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('keeps the session live via the mirror when the injected storage throws (locked keychain)', async () => {
|
|
184
|
+
const store = createNativeAuthStateStore({
|
|
185
|
+
getItem: async () => {
|
|
186
|
+
throw new Error('secure store locked');
|
|
187
|
+
},
|
|
188
|
+
setItem: async () => {
|
|
189
|
+
throw new Error('secure store locked');
|
|
190
|
+
},
|
|
191
|
+
removeItem: async () => {
|
|
192
|
+
throw new Error('secure store locked');
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
await expect(store.save(SAMPLE)).resolves.toBeUndefined();
|
|
196
|
+
// The write threw, but the in-memory mirror preserves the session.
|
|
197
|
+
expect(await store.load()).toEqual(SAMPLE);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('createMemoryAuthStateStore', () => {
|
|
202
|
+
it('round-trips and clears in process memory', async () => {
|
|
203
|
+
const store = createMemoryAuthStateStore();
|
|
204
|
+
await store.save(SAMPLE);
|
|
205
|
+
expect(await store.load()).toEqual(SAMPLE);
|
|
206
|
+
await store.clear();
|
|
207
|
+
expect(await store.load()).toBeNull();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import type { OxyServices } from '../../OxyServices';
|
|
2
|
+
import type { TokenRefreshResponse } from '@oxyhq/contracts';
|
|
3
|
+
import type { SessionLoginResponse } from '../../models/session';
|
|
4
|
+
import { refreshPersistedSession, startTokenRefreshScheduler } from '../refresh';
|
|
5
|
+
import { createMemoryAuthStateStore, type PersistedAuthState } from '../authStateStore';
|
|
6
|
+
|
|
7
|
+
const STORED: PersistedAuthState = {
|
|
8
|
+
sessionId: 'sess-old',
|
|
9
|
+
refreshToken: 'refresh-old-abcdefghij',
|
|
10
|
+
userId: 'user-1',
|
|
11
|
+
deviceToken: 'device-abcdefghij',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const ROTATED: TokenRefreshResponse = {
|
|
15
|
+
accessToken: 'access-new',
|
|
16
|
+
refreshToken: 'refresh-new-abcdefghij',
|
|
17
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
18
|
+
sessionId: 'sess-new',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
interface RefreshMockOverrides {
|
|
22
|
+
refreshWithToken?: OxyServices['refreshWithToken'];
|
|
23
|
+
signInWithSharedIdentity?: OxyServices['signInWithSharedIdentity'];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeOxy(overrides: RefreshMockOverrides = {}): { oxy: OxyServices; setTokens: jest.Mock } {
|
|
27
|
+
const setTokens = jest.fn();
|
|
28
|
+
const oxy = {
|
|
29
|
+
setTokens,
|
|
30
|
+
refreshWithToken: overrides.refreshWithToken ?? (async () => ROTATED),
|
|
31
|
+
signInWithSharedIdentity: overrides.signInWithSharedIdentity ?? (async () => null),
|
|
32
|
+
} as unknown as OxyServices;
|
|
33
|
+
return { oxy, setTokens };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('refreshPersistedSession — arm 1 (refresh-token rotation)', () => {
|
|
37
|
+
it('rotates, plants, persists the new family head, and returns the new token', async () => {
|
|
38
|
+
const store = createMemoryAuthStateStore();
|
|
39
|
+
await store.save(STORED);
|
|
40
|
+
const { oxy, setTokens } = makeOxy();
|
|
41
|
+
|
|
42
|
+
const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false });
|
|
43
|
+
|
|
44
|
+
expect(token).toBe('access-new');
|
|
45
|
+
expect(setTokens).toHaveBeenCalledWith('access-new');
|
|
46
|
+
expect(await store.load()).toEqual({
|
|
47
|
+
sessionId: 'sess-new',
|
|
48
|
+
refreshToken: 'refresh-new-abcdefghij',
|
|
49
|
+
userId: 'user-1',
|
|
50
|
+
deviceToken: 'device-abcdefghij',
|
|
51
|
+
accessToken: 'access-new',
|
|
52
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('clears the store on a family-revoked (401) error', async () => {
|
|
57
|
+
const store = createMemoryAuthStateStore();
|
|
58
|
+
await store.save(STORED);
|
|
59
|
+
const { oxy } = makeOxy({
|
|
60
|
+
refreshWithToken: async () => {
|
|
61
|
+
throw Object.assign(new Error('revoked'), { status: 401 });
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false });
|
|
66
|
+
|
|
67
|
+
expect(token).toBeNull();
|
|
68
|
+
expect(await store.load()).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('clears the store on an invalid_grant code', async () => {
|
|
72
|
+
const store = createMemoryAuthStateStore();
|
|
73
|
+
await store.save(STORED);
|
|
74
|
+
const { oxy } = makeOxy({
|
|
75
|
+
refreshWithToken: async () => {
|
|
76
|
+
throw Object.assign(new Error('bad'), { code: 'invalid_grant' });
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false })).toBeNull();
|
|
81
|
+
expect(await store.load()).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('KEEPS the store on a transient (500 / network) error', async () => {
|
|
85
|
+
const store = createMemoryAuthStateStore();
|
|
86
|
+
await store.save(STORED);
|
|
87
|
+
const { oxy } = makeOxy({
|
|
88
|
+
refreshWithToken: async () => {
|
|
89
|
+
throw Object.assign(new Error('server'), { status: 500 });
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false })).toBeNull();
|
|
94
|
+
expect(await store.load()).toEqual(STORED);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('refreshPersistedSession — arm 2 (native shared-key fallback)', () => {
|
|
99
|
+
const SHARED_SESSION: SessionLoginResponse = {
|
|
100
|
+
sessionId: 'sess-shared',
|
|
101
|
+
deviceId: 'dev-1',
|
|
102
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
103
|
+
user: { id: 'user-1', username: 'u', name: {}, avatar: undefined },
|
|
104
|
+
accessToken: 'access-shared',
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
it('re-mints via shared identity when there is no refresh token', async () => {
|
|
108
|
+
const store = createMemoryAuthStateStore(); // no stored refresh token
|
|
109
|
+
const signInWithSharedIdentity = jest.fn(async () => SHARED_SESSION);
|
|
110
|
+
const { oxy } = makeOxy({ signInWithSharedIdentity });
|
|
111
|
+
|
|
112
|
+
const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true });
|
|
113
|
+
|
|
114
|
+
expect(token).toBe('access-shared');
|
|
115
|
+
expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('falls back to shared-key after a revoked refresh token', async () => {
|
|
119
|
+
const store = createMemoryAuthStateStore();
|
|
120
|
+
await store.save(STORED);
|
|
121
|
+
const signInWithSharedIdentity = jest.fn(async () => SHARED_SESSION);
|
|
122
|
+
const { oxy } = makeOxy({
|
|
123
|
+
refreshWithToken: async () => {
|
|
124
|
+
throw Object.assign(new Error('revoked'), { status: 403 });
|
|
125
|
+
},
|
|
126
|
+
signInWithSharedIdentity,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true });
|
|
130
|
+
|
|
131
|
+
expect(token).toBe('access-shared');
|
|
132
|
+
expect(await store.load()).toBeNull(); // revoked family was cleared
|
|
133
|
+
expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('does NOT try shared-key when the fallback is disabled (web)', async () => {
|
|
137
|
+
const store = createMemoryAuthStateStore();
|
|
138
|
+
const signInWithSharedIdentity = jest.fn(async () => SHARED_SESSION);
|
|
139
|
+
const { oxy } = makeOxy({ signInWithSharedIdentity });
|
|
140
|
+
|
|
141
|
+
expect(await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false })).toBeNull();
|
|
142
|
+
expect(signInWithSharedIdentity).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('startTokenRefreshScheduler', () => {
|
|
147
|
+
function makeSchedulerOxy(expiresInSeconds: number | null): {
|
|
148
|
+
oxy: OxyServices;
|
|
149
|
+
refreshAccessToken: jest.Mock;
|
|
150
|
+
} {
|
|
151
|
+
let cleared = false;
|
|
152
|
+
const refreshAccessToken = jest.fn(async () => {
|
|
153
|
+
cleared = true; // stop the reschedule loop after the first fire
|
|
154
|
+
return null;
|
|
155
|
+
});
|
|
156
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
157
|
+
const oxy = {
|
|
158
|
+
getAccessToken: () => (cleared ? null : 'tok'),
|
|
159
|
+
getAccessTokenExpiry: () => (expiresInSeconds === null ? null : nowSec + expiresInSeconds),
|
|
160
|
+
onTokensChanged: () => () => undefined,
|
|
161
|
+
httpService: { refreshAccessToken },
|
|
162
|
+
} as unknown as OxyServices;
|
|
163
|
+
return { oxy, refreshAccessToken };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
afterEach(() => {
|
|
167
|
+
jest.useRealTimers();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('fires a refresh ~60s before expiry', async () => {
|
|
171
|
+
jest.useFakeTimers();
|
|
172
|
+
const { oxy, refreshAccessToken } = makeSchedulerOxy(120);
|
|
173
|
+
const handle = startTokenRefreshScheduler(oxy);
|
|
174
|
+
|
|
175
|
+
expect(refreshAccessToken).not.toHaveBeenCalled();
|
|
176
|
+
await jest.advanceTimersByTimeAsync(60_000);
|
|
177
|
+
expect(refreshAccessToken).toHaveBeenCalledWith('preflight');
|
|
178
|
+
|
|
179
|
+
handle.dispose();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('no-ops cleanly for an opaque token with no exp', () => {
|
|
183
|
+
jest.useFakeTimers();
|
|
184
|
+
const { oxy, refreshAccessToken } = makeSchedulerOxy(null);
|
|
185
|
+
const handle = startTokenRefreshScheduler(oxy);
|
|
186
|
+
jest.advanceTimersByTime(10 * 60_000);
|
|
187
|
+
expect(refreshAccessToken).not.toHaveBeenCalled();
|
|
188
|
+
handle.dispose();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('backs off on repeated failure — bounded attempts, never a zero-delay busy loop', async () => {
|
|
192
|
+
jest.useFakeTimers();
|
|
193
|
+
// Already-expired token + a refresh that ALWAYS fails. The old code floored
|
|
194
|
+
// the delay to 0 and re-armed in the finally block → a tight loop that would
|
|
195
|
+
// exhaust jest's 100k-timer guard. The backoff schedule fires only a handful
|
|
196
|
+
// of times per simulated minute.
|
|
197
|
+
const refreshAccessToken = jest.fn(async () => null);
|
|
198
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
199
|
+
const oxy = {
|
|
200
|
+
getAccessToken: () => 'tok',
|
|
201
|
+
getAccessTokenExpiry: () => nowSec - 10,
|
|
202
|
+
onTokensChanged: () => () => undefined,
|
|
203
|
+
httpService: { refreshAccessToken },
|
|
204
|
+
} as unknown as OxyServices;
|
|
205
|
+
|
|
206
|
+
const handle = startTokenRefreshScheduler(oxy);
|
|
207
|
+
await jest.advanceTimersByTimeAsync(60_000);
|
|
208
|
+
|
|
209
|
+
expect(refreshAccessToken.mock.calls.length).toBeGreaterThan(0);
|
|
210
|
+
expect(refreshAccessToken.mock.calls.length).toBeLessThan(10);
|
|
211
|
+
handle.dispose();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('resets the backoff after a successful refresh', async () => {
|
|
215
|
+
jest.useFakeTimers();
|
|
216
|
+
// Expiry is always ~1h from NOW, so a successful refresh genuinely pushes
|
|
217
|
+
// the token out (as a real rotation would). Fail once, then succeed.
|
|
218
|
+
const refreshAccessToken = jest
|
|
219
|
+
.fn<Promise<string | null>, [unknown]>()
|
|
220
|
+
.mockResolvedValueOnce(null)
|
|
221
|
+
.mockResolvedValue('fresh');
|
|
222
|
+
const oxy = {
|
|
223
|
+
getAccessToken: () => 'tok',
|
|
224
|
+
getAccessTokenExpiry: () => Math.floor(Date.now() / 1000) + 3600,
|
|
225
|
+
onTokensChanged: () => () => undefined,
|
|
226
|
+
httpService: { refreshAccessToken },
|
|
227
|
+
} as unknown as OxyServices;
|
|
228
|
+
|
|
229
|
+
const handle = startTokenRefreshScheduler(oxy);
|
|
230
|
+
// First fire ~ (3600-60)s out (fails → 5s backoff), then the backoff fire
|
|
231
|
+
// succeeds and re-arms ~1h out from the (now-fresh) expiry.
|
|
232
|
+
await jest.advanceTimersByTimeAsync((3600 - 60) * 1000);
|
|
233
|
+
await jest.advanceTimersByTimeAsync(5_000);
|
|
234
|
+
const callsAfterSuccess = refreshAccessToken.mock.calls.length;
|
|
235
|
+
expect(callsAfterSuccess).toBe(2); // one failure + one success
|
|
236
|
+
// A further minute must NOT produce a burst — the healthy re-arm is ~1h out.
|
|
237
|
+
await jest.advanceTimersByTimeAsync(60_000);
|
|
238
|
+
expect(refreshAccessToken.mock.calls.length).toBe(callsAfterSuccess);
|
|
239
|
+
handle.dispose();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('calls unref() on its timer so it never holds the event loop', () => {
|
|
243
|
+
const unref = jest.fn();
|
|
244
|
+
const setTimeoutSpy = jest
|
|
245
|
+
.spyOn(global, 'setTimeout')
|
|
246
|
+
.mockReturnValue({ unref } as unknown as ReturnType<typeof setTimeout>);
|
|
247
|
+
const { oxy } = makeSchedulerOxy(120);
|
|
248
|
+
|
|
249
|
+
const handle = startTokenRefreshScheduler(oxy);
|
|
250
|
+
expect(setTimeoutSpy).toHaveBeenCalled();
|
|
251
|
+
expect(unref).toHaveBeenCalled();
|
|
252
|
+
|
|
253
|
+
handle.dispose();
|
|
254
|
+
setTimeoutSpy.mockRestore();
|
|
255
|
+
});
|
|
256
|
+
});
|