@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
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import type { OxyServices } from '../../OxyServices';
|
|
2
|
+
import type { AuthTokenBundle, TokenRefreshResponse, WebSessionResult } from '@oxyhq/contracts';
|
|
3
|
+
import type { SessionLoginResponse } from '../../models/session';
|
|
4
|
+
import {
|
|
5
|
+
runSessionColdBoot,
|
|
6
|
+
createBrowserColdBootDom,
|
|
7
|
+
isSameApex,
|
|
8
|
+
BOOT_ATTEMPTED_KEY,
|
|
9
|
+
type ColdBootDom,
|
|
10
|
+
} from '../coldBootV2';
|
|
11
|
+
import { BOOT_STATE_SESSION_KEY } from '../deviceBootReturn';
|
|
12
|
+
import { createMemoryAuthStateStore, type AuthStateStore } from '../../session/authStateStore';
|
|
13
|
+
import { KeyManager } from '../../crypto/keyManager';
|
|
14
|
+
|
|
15
|
+
const BUNDLE: AuthTokenBundle = {
|
|
16
|
+
sessionId: 'sess-boot',
|
|
17
|
+
accessToken: 'access-boot',
|
|
18
|
+
refreshToken: 'refresh-boot-abcdefghij',
|
|
19
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
20
|
+
user: { id: 'user-boot', name: {} } as AuthTokenBundle['user'],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const ROTATED: TokenRefreshResponse = {
|
|
24
|
+
accessToken: 'access-rot',
|
|
25
|
+
refreshToken: 'refresh-rot-abcdefghij',
|
|
26
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
27
|
+
sessionId: 'sess-rot',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
interface OxyOverrides {
|
|
31
|
+
baseURL?: string;
|
|
32
|
+
exchangeBootCode?: OxyServices['exchangeBootCode'];
|
|
33
|
+
refreshWithToken?: OxyServices['refreshWithToken'];
|
|
34
|
+
signInWithSharedIdentity?: OxyServices['signInWithSharedIdentity'];
|
|
35
|
+
issueNativeDeviceToken?: OxyServices['issueNativeDeviceToken'];
|
|
36
|
+
requestWebSession?: OxyServices['requestWebSession'];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: jest.Mock } {
|
|
40
|
+
const setTokens = jest.fn();
|
|
41
|
+
const oxy = {
|
|
42
|
+
getBaseURL: () => overrides.baseURL ?? 'https://api.oxy.so',
|
|
43
|
+
setTokens,
|
|
44
|
+
exchangeBootCode: overrides.exchangeBootCode ?? (async () => BUNDLE),
|
|
45
|
+
refreshWithToken: overrides.refreshWithToken ?? (async () => ROTATED),
|
|
46
|
+
signInWithSharedIdentity: overrides.signInWithSharedIdentity ?? (async () => null),
|
|
47
|
+
issueNativeDeviceToken: overrides.issueNativeDeviceToken ?? (async () => 'native-device-token'),
|
|
48
|
+
requestWebSession:
|
|
49
|
+
overrides.requestWebSession
|
|
50
|
+
?? (async () => ({ reason: 'no_session', deviceToken: 'dt-web' }) as WebSessionResult),
|
|
51
|
+
buildBootstrapUrl: (returnTo: string, state: string) =>
|
|
52
|
+
`https://api.oxy.so/auth/device/bootstrap?return_to=${encodeURIComponent(returnTo)}&state=${state}`,
|
|
53
|
+
} as unknown as OxyServices;
|
|
54
|
+
return { oxy, setTokens };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface DomHandle {
|
|
58
|
+
dom: ColdBootDom;
|
|
59
|
+
navigate: jest.Mock;
|
|
60
|
+
strip: jest.Mock;
|
|
61
|
+
session: Map<string, string>;
|
|
62
|
+
local: Map<string, string>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function makeDom(opts: { hash?: string; hostname?: string; sessionState?: string; attempted?: boolean } = {}): DomHandle {
|
|
66
|
+
const session = new Map<string, string>();
|
|
67
|
+
const local = new Map<string, string>();
|
|
68
|
+
if (opts.sessionState !== undefined) session.set(BOOT_STATE_SESSION_KEY, opts.sessionState);
|
|
69
|
+
if (opts.attempted) local.set(BOOT_ATTEMPTED_KEY, '1');
|
|
70
|
+
const navigate = jest.fn();
|
|
71
|
+
const strip = jest.fn();
|
|
72
|
+
const dom: ColdBootDom = {
|
|
73
|
+
getHash: () => opts.hash ?? '',
|
|
74
|
+
stripFragment: strip,
|
|
75
|
+
getSessionItem: (k) => session.get(k) ?? null,
|
|
76
|
+
setSessionItem: (k, v) => {
|
|
77
|
+
session.set(k, v);
|
|
78
|
+
},
|
|
79
|
+
removeSessionItem: (k) => {
|
|
80
|
+
session.delete(k);
|
|
81
|
+
},
|
|
82
|
+
getLocalItem: (k) => local.get(k) ?? null,
|
|
83
|
+
setLocalItem: (k, v) => {
|
|
84
|
+
local.set(k, v);
|
|
85
|
+
},
|
|
86
|
+
getLocationHostname: () => opts.hostname ?? 'accounts.oxy.so',
|
|
87
|
+
getReturnToHref: () => 'https://accounts.oxy.so/home',
|
|
88
|
+
navigate,
|
|
89
|
+
randomState: () => 'state-fixed-1234',
|
|
90
|
+
};
|
|
91
|
+
return { dom, navigate, strip, session, local };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const WEB = { isWeb: true, isNative: false };
|
|
95
|
+
const NATIVE = { isWeb: false, isNative: true };
|
|
96
|
+
|
|
97
|
+
describe('isSameApex', () => {
|
|
98
|
+
it('groups by registrable domain', () => {
|
|
99
|
+
expect(isSameApex('accounts.oxy.so', 'api.oxy.so')).toBe(true);
|
|
100
|
+
expect(isSameApex('oxy.so', 'api.oxy.so')).toBe(true);
|
|
101
|
+
expect(isSameApex('app.mention.earth', 'api.oxy.so')).toBe(false);
|
|
102
|
+
expect(isSameApex('homiio.com', 'api.oxy.so')).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('does NOT collapse distinct IPv4 hosts that share trailing octets', () => {
|
|
106
|
+
// The last-2-labels heuristic would map both to "1.1" — must be exact.
|
|
107
|
+
expect(isSameApex('192.168.1.1', '10.0.1.1')).toBe(false);
|
|
108
|
+
expect(isSameApex('192.168.1.10', '192.168.1.5')).toBe(false);
|
|
109
|
+
expect(isSameApex('192.168.1.5', '192.168.1.5')).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('requires exact equality for single-label / IPv6 / localhost hosts', () => {
|
|
113
|
+
expect(isSameApex('localhost', 'localhost')).toBe(true);
|
|
114
|
+
expect(isSameApex('localhost', 'api.oxy.so')).toBe(false);
|
|
115
|
+
expect(isSameApex('::1', '::1')).toBe(true);
|
|
116
|
+
expect(isSameApex('fe80::1', 'fe80::2')).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('runSessionColdBoot — step ordering', () => {
|
|
121
|
+
it('stored-tokens warm-plants a still-valid access token before any hop', async () => {
|
|
122
|
+
const store = createMemoryAuthStateStore();
|
|
123
|
+
await store.save({
|
|
124
|
+
sessionId: 'sess-warm',
|
|
125
|
+
refreshToken: 'r-abcdefghij',
|
|
126
|
+
userId: 'user-warm',
|
|
127
|
+
accessToken: 'warm-token',
|
|
128
|
+
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
|
129
|
+
});
|
|
130
|
+
const { oxy, setTokens } = makeOxy();
|
|
131
|
+
const requestWebSession = jest.spyOn(oxy, 'requestWebSession');
|
|
132
|
+
const onSession = jest.fn();
|
|
133
|
+
|
|
134
|
+
const outcome = await runSessionColdBoot({
|
|
135
|
+
oxy, store, platform: WEB, dom: makeDom().dom, onSession,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(outcome).toEqual({ kind: 'session', via: 'stored-tokens', session: expect.any(Object) });
|
|
139
|
+
expect(setTokens).toHaveBeenCalledWith('warm-token');
|
|
140
|
+
expect(onSession).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-warm', via: 'stored-tokens' }));
|
|
141
|
+
expect(requestWebSession).not.toHaveBeenCalled();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('stored-tokens rotates when the warm token is absent/expired', async () => {
|
|
145
|
+
const store = createMemoryAuthStateStore();
|
|
146
|
+
await store.save({ sessionId: 'sess-old', refreshToken: 'r-abcdefghij', userId: 'user-1' });
|
|
147
|
+
const { oxy, setTokens } = makeOxy();
|
|
148
|
+
|
|
149
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: makeDom().dom });
|
|
150
|
+
|
|
151
|
+
expect(outcome.kind).toBe('session');
|
|
152
|
+
expect(setTokens).toHaveBeenCalledWith('access-rot');
|
|
153
|
+
expect(await store.load()).toMatchObject({ sessionId: 'sess-rot', refreshToken: 'refresh-rot-abcdefghij' });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('bootstrap-return wins over later steps when a valid fragment is present', async () => {
|
|
157
|
+
const store = createMemoryAuthStateStore();
|
|
158
|
+
// A base64url fragment carrying reason:session + matching state.
|
|
159
|
+
const frag = {
|
|
160
|
+
v: 1,
|
|
161
|
+
state: 'state-match',
|
|
162
|
+
reason: 'session',
|
|
163
|
+
code: 'c'.repeat(24),
|
|
164
|
+
deviceToken: 'd'.repeat(24),
|
|
165
|
+
};
|
|
166
|
+
const b64 = Buffer.from(JSON.stringify(frag), 'utf-8')
|
|
167
|
+
.toString('base64')
|
|
168
|
+
.replace(/\+/g, '-')
|
|
169
|
+
.replace(/\//g, '_')
|
|
170
|
+
.replace(/=+$/, '');
|
|
171
|
+
const domHandle = makeDom({ hash: `#oxy_boot=${b64}`, sessionState: 'state-match' });
|
|
172
|
+
const { oxy, setTokens } = makeOxy();
|
|
173
|
+
const onSession = jest.fn();
|
|
174
|
+
|
|
175
|
+
const outcome = await runSessionColdBoot({
|
|
176
|
+
oxy, store, platform: WEB, dom: domHandle.dom, onSession,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(outcome).toEqual({ kind: 'session', via: 'bootstrap-return', session: expect.any(Object) });
|
|
180
|
+
expect(domHandle.strip).toHaveBeenCalled();
|
|
181
|
+
expect(setTokens).toHaveBeenCalledWith('access-boot');
|
|
182
|
+
expect(onSession).toHaveBeenCalledWith(expect.objectContaining({ via: 'bootstrap-return' }));
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('runSessionColdBoot — bootstrap-hop (web)', () => {
|
|
187
|
+
it('same-apex: resolves a session via the inline web-session fetch (no navigation)', async () => {
|
|
188
|
+
const store = createMemoryAuthStateStore();
|
|
189
|
+
// The EXACT post-unwrap server shape (PR #526): a reason-`session` envelope
|
|
190
|
+
// nesting the bundle under `session` plus the rotated deviceToken.
|
|
191
|
+
const sessionEnvelope: WebSessionResult = { reason: 'session', session: BUNDLE, deviceToken: 'dt-rotated' };
|
|
192
|
+
const requestWebSession = jest.fn(async () => sessionEnvelope);
|
|
193
|
+
const { oxy, setTokens } = makeOxy({ requestWebSession });
|
|
194
|
+
const domHandle = makeDom({ hostname: 'accounts.oxy.so' });
|
|
195
|
+
|
|
196
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: domHandle.dom });
|
|
197
|
+
|
|
198
|
+
expect(outcome).toEqual({ kind: 'session', via: 'bootstrap-hop', session: expect.any(Object) });
|
|
199
|
+
expect(requestWebSession).toHaveBeenCalledTimes(1);
|
|
200
|
+
expect(setTokens).toHaveBeenCalledWith('access-boot');
|
|
201
|
+
expect(domHandle.navigate).not.toHaveBeenCalled();
|
|
202
|
+
expect(await store.load()).toMatchObject({ sessionId: 'sess-boot', refreshToken: 'refresh-boot-abcdefghij' });
|
|
203
|
+
// deviceToken from the SAME envelope is persisted on the session arm too.
|
|
204
|
+
expect(await store.loadDeviceToken()).toBe('dt-rotated');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('same-apex: signed-out device persists the deviceToken and reports signed out', async () => {
|
|
208
|
+
const store = createMemoryAuthStateStore();
|
|
209
|
+
const requestWebSession = jest.fn(async () => ({ reason: 'no_session', deviceToken: 'dt-web' }) as WebSessionResult);
|
|
210
|
+
const { oxy } = makeOxy({ requestWebSession });
|
|
211
|
+
const domHandle = makeDom({ hostname: 'accounts.oxy.so' });
|
|
212
|
+
const onSignedOut = jest.fn();
|
|
213
|
+
|
|
214
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: domHandle.dom, onSignedOut });
|
|
215
|
+
|
|
216
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
217
|
+
expect(onSignedOut).toHaveBeenCalledWith('no_session');
|
|
218
|
+
expect(await store.loadDeviceToken()).toBe('dt-web');
|
|
219
|
+
expect(domHandle.navigate).not.toHaveBeenCalled();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('cross-apex: navigates ONCE, stashing state + the attempted flag', async () => {
|
|
223
|
+
const store = createMemoryAuthStateStore();
|
|
224
|
+
const { oxy } = makeOxy();
|
|
225
|
+
const domHandle = makeDom({ hostname: 'app.mention.earth' });
|
|
226
|
+
const onSignedOut = jest.fn();
|
|
227
|
+
const onSession = jest.fn();
|
|
228
|
+
|
|
229
|
+
await runSessionColdBoot({ oxy, store, platform: WEB, dom: domHandle.dom, onSignedOut, onSession });
|
|
230
|
+
|
|
231
|
+
expect(domHandle.navigate).toHaveBeenCalledTimes(1);
|
|
232
|
+
expect(domHandle.navigate).toHaveBeenCalledWith(expect.stringContaining('/auth/device/bootstrap?return_to='));
|
|
233
|
+
expect(domHandle.session.get(BOOT_STATE_SESSION_KEY)).toBe('state-fixed-1234');
|
|
234
|
+
expect(domHandle.local.get(BOOT_ATTEMPTED_KEY)).toBe('1');
|
|
235
|
+
// Navigating away — neither callback fires (no signed-out flash).
|
|
236
|
+
expect(onSignedOut).not.toHaveBeenCalled();
|
|
237
|
+
expect(onSession).not.toHaveBeenCalled();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('cross-apex: NEVER navigates a second time once the attempted flag is set', async () => {
|
|
241
|
+
const store = createMemoryAuthStateStore();
|
|
242
|
+
const { oxy } = makeOxy();
|
|
243
|
+
const domHandle = makeDom({ hostname: 'app.mention.earth', attempted: true });
|
|
244
|
+
const onSignedOut = jest.fn();
|
|
245
|
+
|
|
246
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: domHandle.dom, onSignedOut });
|
|
247
|
+
|
|
248
|
+
expect(domHandle.navigate).not.toHaveBeenCalled();
|
|
249
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
250
|
+
expect(onSignedOut).toHaveBeenCalledWith('no_session');
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe('runSessionColdBoot — native shared-key', () => {
|
|
255
|
+
afterEach(() => jest.restoreAllMocks());
|
|
256
|
+
|
|
257
|
+
it('re-mints via shared identity and issues + mirrors a deviceToken the first time', async () => {
|
|
258
|
+
const store = createMemoryAuthStateStore();
|
|
259
|
+
const sharedSession: SessionLoginResponse = {
|
|
260
|
+
sessionId: 'sess-shared',
|
|
261
|
+
deviceId: 'dev-1',
|
|
262
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
263
|
+
user: { id: 'user-shared', username: 'u', name: {}, avatar: undefined },
|
|
264
|
+
accessToken: 'access-shared',
|
|
265
|
+
};
|
|
266
|
+
const issueNativeDeviceToken = jest.fn(async () => 'fresh-device-token');
|
|
267
|
+
const { oxy } = makeOxy({
|
|
268
|
+
signInWithSharedIdentity: async () => sharedSession,
|
|
269
|
+
issueNativeDeviceToken,
|
|
270
|
+
});
|
|
271
|
+
const getShared = jest.spyOn(KeyManager, 'getSharedDeviceToken').mockResolvedValue(null);
|
|
272
|
+
const setShared = jest.spyOn(KeyManager, 'setSharedDeviceToken').mockResolvedValue(undefined);
|
|
273
|
+
const requestWebSession = jest.spyOn(oxy, 'requestWebSession');
|
|
274
|
+
|
|
275
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE, dom: makeDom().dom });
|
|
276
|
+
|
|
277
|
+
expect(outcome).toEqual({ kind: 'session', via: 'shared-key-signin', session: expect.any(Object) });
|
|
278
|
+
expect(getShared).toHaveBeenCalled();
|
|
279
|
+
expect(issueNativeDeviceToken).toHaveBeenCalled();
|
|
280
|
+
expect(setShared).toHaveBeenCalledWith('fresh-device-token');
|
|
281
|
+
expect(await store.loadDeviceToken()).toBe('fresh-device-token');
|
|
282
|
+
// bootstrap-hop is web-only — never runs on native.
|
|
283
|
+
expect(requestWebSession).not.toHaveBeenCalled();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('skips device-token issuance when the shared token already exists', async () => {
|
|
287
|
+
const store = createMemoryAuthStateStore();
|
|
288
|
+
const issueNativeDeviceToken = jest.fn(async () => 'unused');
|
|
289
|
+
const { oxy } = makeOxy({
|
|
290
|
+
signInWithSharedIdentity: async () => ({
|
|
291
|
+
sessionId: 'sess-shared',
|
|
292
|
+
deviceId: 'dev-1',
|
|
293
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
294
|
+
user: { id: 'user-shared', username: 'u', name: {}, avatar: undefined },
|
|
295
|
+
accessToken: 'access-shared',
|
|
296
|
+
}),
|
|
297
|
+
issueNativeDeviceToken,
|
|
298
|
+
});
|
|
299
|
+
jest.spyOn(KeyManager, 'getSharedDeviceToken').mockResolvedValue('already-there');
|
|
300
|
+
|
|
301
|
+
await runSessionColdBoot({ oxy, store, platform: NATIVE, dom: makeDom().dom });
|
|
302
|
+
|
|
303
|
+
expect(issueNativeDeviceToken).not.toHaveBeenCalled();
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe('createBrowserColdBootDom', () => {
|
|
308
|
+
it('returns neutral values under the jest node environment (no window)', () => {
|
|
309
|
+
const dom = createBrowserColdBootDom();
|
|
310
|
+
expect(dom.getHash()).toBe('');
|
|
311
|
+
expect(dom.getLocationHostname()).toBeNull();
|
|
312
|
+
expect(dom.getSessionItem('x')).toBeNull();
|
|
313
|
+
expect(() => dom.stripFragment()).not.toThrow();
|
|
314
|
+
expect(() => dom.navigate('https://x')).not.toThrow();
|
|
315
|
+
expect(dom.randomState()).toMatch(/^[0-9a-f]+$/);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { AuthTokenBundle } from '@oxyhq/contracts';
|
|
2
|
+
import {
|
|
3
|
+
parseDeviceBootFragment,
|
|
4
|
+
hashHasBootFragment,
|
|
5
|
+
consumeDeviceBootReturn,
|
|
6
|
+
type ConsumeDeviceBootReturnDeps,
|
|
7
|
+
} from '../deviceBootReturn';
|
|
8
|
+
import { createMemoryAuthStateStore } from '../../session/authStateStore';
|
|
9
|
+
|
|
10
|
+
const STATE = 'st-1234567890';
|
|
11
|
+
const CODE = 'c'.repeat(24);
|
|
12
|
+
const DEVICE_TOKEN = 'd'.repeat(24);
|
|
13
|
+
|
|
14
|
+
// A loose record (fed to `encodeHash(unknown)` and validated by the schema) so
|
|
15
|
+
// tests can build the session arm plus invalid variants (missing/extra `code`,
|
|
16
|
+
// `v: 2`) without fighting the discriminated-union type.
|
|
17
|
+
function fragmentObject(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
18
|
+
return { v: 1, state: STATE, reason: 'session', code: CODE, deviceToken: DEVICE_TOKEN, ...overrides };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function encodeHash(obj: unknown): string {
|
|
22
|
+
const b64 = Buffer.from(JSON.stringify(obj), 'utf-8')
|
|
23
|
+
.toString('base64')
|
|
24
|
+
.replace(/\+/g, '-')
|
|
25
|
+
.replace(/\//g, '_')
|
|
26
|
+
.replace(/=+$/, '');
|
|
27
|
+
return `#oxy_boot=${b64}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const BUNDLE: AuthTokenBundle = {
|
|
31
|
+
sessionId: 'sess-1',
|
|
32
|
+
accessToken: 'access-jwt',
|
|
33
|
+
refreshToken: 'refresh-abcdefghijklmnop',
|
|
34
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
35
|
+
user: { id: 'user-1', name: {} } as AuthTokenBundle['user'],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Build the injectable deps around a memory store, recording call order. */
|
|
39
|
+
function makeDeps(
|
|
40
|
+
hash: string,
|
|
41
|
+
overrides: Partial<ConsumeDeviceBootReturnDeps> & { expectedState?: string | null } = {},
|
|
42
|
+
): { deps: ConsumeDeviceBootReturnDeps; calls: string[]; store: ReturnType<typeof createMemoryAuthStateStore> } {
|
|
43
|
+
const calls: string[] = [];
|
|
44
|
+
const store = createMemoryAuthStateStore();
|
|
45
|
+
const deps: ConsumeDeviceBootReturnDeps = {
|
|
46
|
+
hash,
|
|
47
|
+
stripFragment: () => calls.push('strip'),
|
|
48
|
+
readExpectedState: () => (overrides.expectedState !== undefined ? overrides.expectedState : STATE),
|
|
49
|
+
clearExpectedState: () => calls.push('clearState'),
|
|
50
|
+
store,
|
|
51
|
+
exchangeBootCode: overrides.exchangeBootCode
|
|
52
|
+
?? (async () => {
|
|
53
|
+
calls.push('exchange');
|
|
54
|
+
return BUNDLE;
|
|
55
|
+
}),
|
|
56
|
+
plantAccessToken: overrides.plantAccessToken ?? ((token) => calls.push(`plant:${token}`)),
|
|
57
|
+
};
|
|
58
|
+
return { deps, calls, store };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('parseDeviceBootFragment', () => {
|
|
62
|
+
it('parses a valid base64url fragment', () => {
|
|
63
|
+
expect(parseDeviceBootFragment(encodeHash(fragmentObject()))).toEqual(fragmentObject());
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('returns null when the parameter is absent', () => {
|
|
67
|
+
expect(parseDeviceBootFragment('#something=else')).toBeNull();
|
|
68
|
+
expect(parseDeviceBootFragment('')).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('returns null for malformed base64 / non-JSON / wrong shape', () => {
|
|
72
|
+
expect(parseDeviceBootFragment('#oxy_boot=!!!not-base64!!!')).toBeNull();
|
|
73
|
+
expect(parseDeviceBootFragment(encodeHash({ nope: true }))).toBeNull();
|
|
74
|
+
// v must be the literal 1
|
|
75
|
+
expect(parseDeviceBootFragment(encodeHash(fragmentObject({ v: 2 })))).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('returns null for a session fragment missing its code (discriminated union)', () => {
|
|
79
|
+
expect(parseDeviceBootFragment(encodeHash(fragmentObject({ code: undefined })))).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('hashHasBootFragment', () => {
|
|
84
|
+
it('detects the fragment regardless of position', () => {
|
|
85
|
+
expect(hashHasBootFragment('#oxy_boot=abc')).toBe(true);
|
|
86
|
+
expect(hashHasBootFragment('#x=1&oxy_boot=abc')).toBe(true);
|
|
87
|
+
expect(hashHasBootFragment('#other=1')).toBe(false);
|
|
88
|
+
expect(hashHasBootFragment('')).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('consumeDeviceBootReturn', () => {
|
|
93
|
+
it('returns none and does not touch the URL when no fragment is present', async () => {
|
|
94
|
+
const { deps, calls } = makeDeps('#unrelated=1');
|
|
95
|
+
expect(await consumeDeviceBootReturn(deps)).toEqual({ kind: 'none' });
|
|
96
|
+
expect(calls).not.toContain('strip');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('strips the fragment BEFORE exchanging the code', async () => {
|
|
100
|
+
const { deps, calls } = makeDeps(encodeHash(fragmentObject()));
|
|
101
|
+
const outcome = await consumeDeviceBootReturn(deps);
|
|
102
|
+
expect(outcome.kind).toBe('session');
|
|
103
|
+
expect(calls.indexOf('strip')).toBeLessThan(calls.indexOf('exchange'));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('resolves a session, persists it, and plants the token', async () => {
|
|
107
|
+
const { deps, store } = makeDeps(encodeHash(fragmentObject()));
|
|
108
|
+
const outcome = await consumeDeviceBootReturn(deps);
|
|
109
|
+
expect(outcome).toEqual({
|
|
110
|
+
kind: 'session',
|
|
111
|
+
session: { sessionId: 'sess-1', userId: 'user-1', accessToken: 'access-jwt' },
|
|
112
|
+
});
|
|
113
|
+
expect(await store.load()).toMatchObject({
|
|
114
|
+
sessionId: 'sess-1',
|
|
115
|
+
refreshToken: 'refresh-abcdefghijklmnop',
|
|
116
|
+
userId: 'user-1',
|
|
117
|
+
deviceToken: DEVICE_TOKEN,
|
|
118
|
+
});
|
|
119
|
+
expect(await store.loadDeviceToken()).toBe(DEVICE_TOKEN);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('rejects a state mismatch without persisting or exchanging', async () => {
|
|
123
|
+
const { deps, calls, store } = makeDeps(encodeHash(fragmentObject()), { expectedState: 'WRONG' });
|
|
124
|
+
expect(await consumeDeviceBootReturn(deps)).toEqual({ kind: 'state-mismatch' });
|
|
125
|
+
expect(calls).toContain('strip');
|
|
126
|
+
expect(calls).not.toContain('exchange');
|
|
127
|
+
expect(await store.loadDeviceToken()).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('rejects when there is no expected state stashed at all', async () => {
|
|
131
|
+
const { deps } = makeDeps(encodeHash(fragmentObject()), { expectedState: null });
|
|
132
|
+
expect(await consumeDeviceBootReturn(deps)).toEqual({ kind: 'state-mismatch' });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('persists the deviceToken but returns no-session for a signed-out device', async () => {
|
|
136
|
+
const { deps, store, calls } = makeDeps(
|
|
137
|
+
encodeHash(fragmentObject({ reason: 'new_device', code: undefined })),
|
|
138
|
+
);
|
|
139
|
+
expect(await consumeDeviceBootReturn(deps)).toEqual({ kind: 'no-session', reason: 'new_device' });
|
|
140
|
+
expect(calls).not.toContain('exchange');
|
|
141
|
+
expect(await store.loadDeviceToken()).toBe(DEVICE_TOKEN);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('returns no-session when the code exchange fails (burned/expired code)', async () => {
|
|
145
|
+
const { deps } = makeDeps(encodeHash(fragmentObject()), {
|
|
146
|
+
exchangeBootCode: async () => {
|
|
147
|
+
throw new Error('code already burned');
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
expect(await consumeDeviceBootReturn(deps)).toEqual({ kind: 'no-session', reason: 'no_session' });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('strips even a malformed fragment and returns none', async () => {
|
|
154
|
+
const { deps, calls } = makeDeps('#oxy_boot=!!!malformed!!!');
|
|
155
|
+
expect(await consumeDeviceBootReturn(deps)).toEqual({ kind: 'none' });
|
|
156
|
+
expect(calls).toContain('strip');
|
|
157
|
+
});
|
|
158
|
+
});
|