@oxyhq/core 8.1.0 → 9.1.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/README.md +1 -3
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/boot/coldBootV2.js +22 -332
- package/dist/cjs/crypto/keyManager.js +0 -95
- package/dist/cjs/index.js +6 -19
- package/dist/cjs/mixins/OxyServices.auth.js +4 -7
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +19 -115
- package/dist/cjs/mixins/index.js +2 -3
- package/dist/cjs/session/SessionClient.js +29 -100
- package/dist/cjs/session/accountDialogController.js +0 -13
- package/dist/cjs/session/authStateStore.js +9 -89
- package/dist/cjs/session/createSessionClient.js +2 -9
- package/dist/cjs/session/refresh.js +46 -71
- package/dist/cjs/utils/registrableApex.js +2 -6
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/coldBootV2.js +23 -330
- package/dist/esm/crypto/keyManager.js +0 -95
- package/dist/esm/index.js +7 -11
- package/dist/esm/mixins/OxyServices.auth.js +4 -7
- package/dist/esm/mixins/OxyServices.deviceBoot.js +20 -116
- package/dist/esm/mixins/index.js +2 -3
- package/dist/esm/session/SessionClient.js +29 -100
- package/dist/esm/session/accountDialogController.js +0 -13
- package/dist/esm/session/authStateStore.js +8 -88
- package/dist/esm/session/createSessionClient.js +2 -9
- package/dist/esm/session/refresh.js +46 -71
- package/dist/esm/utils/registrableApex.js +2 -6
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +3 -3
- package/dist/types/boot/coldBootV2.d.ts +28 -53
- package/dist/types/crypto/keyManager.d.ts +0 -21
- package/dist/types/index.d.ts +3 -5
- package/dist/types/mixins/OxyServices.auth.d.ts +4 -7
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +20 -60
- package/dist/types/models/interfaces.d.ts +15 -0
- package/dist/types/session/SessionClient.d.ts +6 -40
- package/dist/types/session/accountDialogController.d.ts +1 -3
- package/dist/types/session/authStateStore.d.ts +32 -57
- package/dist/types/session/createSessionClient.d.ts +2 -9
- package/dist/types/session/refresh.d.ts +32 -28
- package/dist/types/utils/registrableApex.d.ts +2 -6
- package/package.json +3 -3
- package/src/HttpService.ts +3 -3
- package/src/boot/__tests__/coldBootV2.test.ts +140 -353
- package/src/boot/coldBootV2.ts +35 -391
- package/src/crypto/keyManager.ts +0 -101
- package/src/index.ts +7 -30
- package/src/mixins/OxyServices.auth.ts +5 -9
- package/src/mixins/OxyServices.deviceBoot.ts +19 -142
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +9 -96
- package/src/mixins/__tests__/onTokensChanged.test.ts +0 -1
- package/src/mixins/__tests__/passwordSignIn.test.ts +33 -9
- package/src/mixins/index.ts +2 -3
- package/src/models/interfaces.ts +15 -0
- package/src/session/SessionClient.ts +31 -122
- package/src/session/__tests__/SessionClient.broadcastChannel.test.ts +113 -0
- package/src/session/__tests__/SessionClient.socket.test.ts +8 -8
- package/src/session/__tests__/authStateStore.test.ts +29 -42
- package/src/session/__tests__/refresh.test.ts +66 -52
- package/src/session/accountDialogController.ts +4 -18
- package/src/session/authStateStore.ts +32 -126
- package/src/session/createSessionClient.ts +2 -9
- package/src/session/refresh.ts +60 -92
- package/src/utils/registrableApex.ts +2 -6
- package/dist/cjs/boot/deviceBootReturn.js +0 -167
- package/dist/esm/boot/deviceBootReturn.js +0 -161
- package/dist/types/boot/deviceBootReturn.d.ts +0 -83
- package/src/boot/__tests__/deviceBootReturn.test.ts +0 -190
- package/src/boot/deviceBootReturn.ts +0 -210
- package/src/crypto/__tests__/sharedDeviceToken.test.ts +0 -24
- package/src/session/__tests__/SessionClient.signedOut.test.ts +0 -224
|
@@ -1,59 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cold boot v3 (`runSessionColdBoot`) — the zero-cookie, DOM-less device-first
|
|
3
|
+
* boot. Only two ordered steps remain:
|
|
4
|
+
* 1. `device-secret-mint` (web + native) — mint an access token from the
|
|
5
|
+
* persisted `deviceId` + `deviceSecret`.
|
|
6
|
+
* 2. `shared-key-signin` (native) — re-mint from the shared-keychain identity.
|
|
7
|
+
* Anything unresolved ends signed out (never a redirect).
|
|
8
|
+
*/
|
|
1
9
|
import type { OxyServices } from '../../OxyServices';
|
|
2
|
-
import type {
|
|
3
|
-
AuthTokenBundle,
|
|
4
|
-
DeviceTokenMintResponse,
|
|
5
|
-
TokenRefreshResponse,
|
|
6
|
-
WebSessionResult,
|
|
7
|
-
} from '@oxyhq/contracts';
|
|
10
|
+
import type { DeviceTokenMintResponse } from '@oxyhq/contracts';
|
|
8
11
|
import type { SessionLoginResponse } from '../../models/session';
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
createBrowserColdBootDom,
|
|
12
|
-
isSameApex,
|
|
13
|
-
BOOT_ATTEMPTED_KEY,
|
|
14
|
-
type ColdBootDom,
|
|
15
|
-
} from '../coldBootV2';
|
|
16
|
-
import { BOOT_STATE_SESSION_KEY } from '../deviceBootReturn';
|
|
17
|
-
import { createMemoryAuthStateStore, type AuthStateStore } from '../../session/authStateStore';
|
|
18
|
-
import { KeyManager } from '../../crypto/keyManager';
|
|
19
|
-
|
|
20
|
-
const BUNDLE: AuthTokenBundle = {
|
|
21
|
-
sessionId: 'sess-boot',
|
|
22
|
-
accessToken: 'access-boot',
|
|
23
|
-
refreshToken: 'refresh-boot-abcdefghij',
|
|
24
|
-
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
25
|
-
user: { id: 'user-boot', name: {} } as AuthTokenBundle['user'],
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
const ROTATED: TokenRefreshResponse = {
|
|
29
|
-
accessToken: 'access-rot',
|
|
30
|
-
refreshToken: 'refresh-rot-abcdefghij',
|
|
31
|
-
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
32
|
-
sessionId: 'sess-rot',
|
|
33
|
-
};
|
|
12
|
+
import { runSessionColdBoot } from '../coldBootV2';
|
|
13
|
+
import { createMemoryAuthStateStore, type PersistedAuthState } from '../../session/authStateStore';
|
|
34
14
|
|
|
35
15
|
interface OxyOverrides {
|
|
36
|
-
baseURL?: string;
|
|
37
|
-
exchangeBootCode?: OxyServices['exchangeBootCode'];
|
|
38
|
-
refreshWithToken?: OxyServices['refreshWithToken'];
|
|
39
16
|
signInWithSharedIdentity?: OxyServices['signInWithSharedIdentity'];
|
|
40
|
-
issueNativeDeviceToken?: OxyServices['issueNativeDeviceToken'];
|
|
41
|
-
requestWebSession?: OxyServices['requestWebSession'];
|
|
42
17
|
mintFromDeviceSecret?: OxyServices['mintFromDeviceSecret'];
|
|
43
18
|
}
|
|
44
19
|
|
|
45
20
|
function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: jest.Mock } {
|
|
46
21
|
const setTokens = jest.fn();
|
|
47
22
|
const oxy = {
|
|
48
|
-
getBaseURL: () =>
|
|
23
|
+
getBaseURL: () => 'https://api.oxy.so',
|
|
49
24
|
setTokens,
|
|
50
|
-
exchangeBootCode: overrides.exchangeBootCode ?? (async () => BUNDLE),
|
|
51
|
-
refreshWithToken: overrides.refreshWithToken ?? (async () => ROTATED),
|
|
52
25
|
signInWithSharedIdentity: overrides.signInWithSharedIdentity ?? (async () => null),
|
|
53
|
-
issueNativeDeviceToken: overrides.issueNativeDeviceToken ?? (async () => 'native-device-token'),
|
|
54
|
-
requestWebSession:
|
|
55
|
-
overrides.requestWebSession
|
|
56
|
-
?? (async () => ({ reason: 'no_session', deviceToken: 'dt-web' }) as WebSessionResult),
|
|
57
26
|
// Default: no persisted secret in these fixtures, so the mint step skips
|
|
58
27
|
// before ever calling this. Tests that exercise the mint pass an override.
|
|
59
28
|
mintFromDeviceSecret:
|
|
@@ -61,8 +30,6 @@ function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: j
|
|
|
61
30
|
?? (async () => {
|
|
62
31
|
throw new Error('mintFromDeviceSecret not stubbed');
|
|
63
32
|
}),
|
|
64
|
-
buildBootstrapUrl: (returnTo: string, state: string) =>
|
|
65
|
-
`https://api.oxy.so/auth/device/bootstrap?return_to=${encodeURIComponent(returnTo)}&state=${state}`,
|
|
66
33
|
} as unknown as OxyServices;
|
|
67
34
|
return { oxy, setTokens };
|
|
68
35
|
}
|
|
@@ -85,159 +52,36 @@ function mint401(body: string): Error & { status: number } {
|
|
|
85
52
|
return Object.assign(new Error(body), { status: 401 });
|
|
86
53
|
}
|
|
87
54
|
|
|
88
|
-
interface DomHandle {
|
|
89
|
-
dom: ColdBootDom;
|
|
90
|
-
navigate: jest.Mock;
|
|
91
|
-
strip: jest.Mock;
|
|
92
|
-
session: Map<string, string>;
|
|
93
|
-
local: Map<string, string>;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function makeDom(opts: { hash?: string; hostname?: string; sessionState?: string; attempted?: boolean } = {}): DomHandle {
|
|
97
|
-
const session = new Map<string, string>();
|
|
98
|
-
const local = new Map<string, string>();
|
|
99
|
-
if (opts.sessionState !== undefined) session.set(BOOT_STATE_SESSION_KEY, opts.sessionState);
|
|
100
|
-
if (opts.attempted) local.set(BOOT_ATTEMPTED_KEY, '1');
|
|
101
|
-
const navigate = jest.fn();
|
|
102
|
-
const strip = jest.fn();
|
|
103
|
-
const dom: ColdBootDom = {
|
|
104
|
-
getHash: () => opts.hash ?? '',
|
|
105
|
-
stripFragment: strip,
|
|
106
|
-
getSessionItem: (k) => session.get(k) ?? null,
|
|
107
|
-
setSessionItem: (k, v) => {
|
|
108
|
-
session.set(k, v);
|
|
109
|
-
},
|
|
110
|
-
removeSessionItem: (k) => {
|
|
111
|
-
session.delete(k);
|
|
112
|
-
},
|
|
113
|
-
getLocalItem: (k) => local.get(k) ?? null,
|
|
114
|
-
setLocalItem: (k, v) => {
|
|
115
|
-
local.set(k, v);
|
|
116
|
-
},
|
|
117
|
-
getLocationHostname: () => opts.hostname ?? 'accounts.oxy.so',
|
|
118
|
-
getReturnToHref: () => 'https://accounts.oxy.so/home',
|
|
119
|
-
navigate,
|
|
120
|
-
randomState: () => 'state-fixed-1234',
|
|
121
|
-
};
|
|
122
|
-
return { dom, navigate, strip, session, local };
|
|
123
|
-
}
|
|
124
|
-
|
|
125
55
|
const WEB = { isWeb: true, isNative: false };
|
|
126
56
|
const NATIVE = { isWeb: false, isNative: true };
|
|
127
57
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it('does NOT collapse distinct IPv4 hosts that share trailing octets', () => {
|
|
137
|
-
// The last-2-labels heuristic would map both to "1.1" — must be exact.
|
|
138
|
-
expect(isSameApex('192.168.1.1', '10.0.1.1')).toBe(false);
|
|
139
|
-
expect(isSameApex('192.168.1.10', '192.168.1.5')).toBe(false);
|
|
140
|
-
expect(isSameApex('192.168.1.5', '192.168.1.5')).toBe(true);
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it('requires exact equality for single-label / IPv6 / localhost hosts', () => {
|
|
144
|
-
expect(isSameApex('localhost', 'localhost')).toBe(true);
|
|
145
|
-
expect(isSameApex('localhost', 'api.oxy.so')).toBe(false);
|
|
146
|
-
expect(isSameApex('::1', '::1')).toBe(true);
|
|
147
|
-
expect(isSameApex('fe80::1', 'fe80::2')).toBe(false);
|
|
148
|
-
});
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
describe('runSessionColdBoot — step ordering', () => {
|
|
152
|
-
it('stored-tokens warm-plants a still-valid access token before any hop', async () => {
|
|
153
|
-
const store = createMemoryAuthStateStore();
|
|
154
|
-
await store.save({
|
|
155
|
-
sessionId: 'sess-warm',
|
|
156
|
-
refreshToken: 'r-abcdefghij',
|
|
157
|
-
userId: 'user-warm',
|
|
158
|
-
accessToken: 'warm-token',
|
|
159
|
-
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
|
160
|
-
});
|
|
161
|
-
const { oxy, setTokens } = makeOxy();
|
|
162
|
-
const requestWebSession = jest.spyOn(oxy, 'requestWebSession');
|
|
163
|
-
const onSession = jest.fn();
|
|
164
|
-
|
|
165
|
-
const outcome = await runSessionColdBoot({
|
|
166
|
-
oxy, store, platform: WEB, dom: makeDom().dom, onSession,
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
expect(outcome).toEqual({ kind: 'session', via: 'stored-tokens', session: expect.any(Object) });
|
|
170
|
-
expect(setTokens).toHaveBeenCalledWith('warm-token');
|
|
171
|
-
expect(onSession).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-warm', via: 'stored-tokens' }));
|
|
172
|
-
expect(requestWebSession).not.toHaveBeenCalled();
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it('stored-tokens rotates when the warm token is absent/expired', async () => {
|
|
176
|
-
const store = createMemoryAuthStateStore();
|
|
177
|
-
await store.save({ sessionId: 'sess-old', refreshToken: 'r-abcdefghij', userId: 'user-1' });
|
|
178
|
-
const { oxy, setTokens } = makeOxy();
|
|
179
|
-
|
|
180
|
-
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: makeDom().dom });
|
|
181
|
-
|
|
182
|
-
expect(outcome.kind).toBe('session');
|
|
183
|
-
expect(setTokens).toHaveBeenCalledWith('access-rot');
|
|
184
|
-
expect(await store.load()).toMatchObject({ sessionId: 'sess-rot', refreshToken: 'refresh-rot-abcdefghij' });
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it('bootstrap-return wins over later steps when a valid fragment is present', async () => {
|
|
188
|
-
const store = createMemoryAuthStateStore();
|
|
189
|
-
// A base64url fragment carrying reason:session + matching state.
|
|
190
|
-
const frag = {
|
|
191
|
-
v: 1,
|
|
192
|
-
state: 'state-match',
|
|
193
|
-
reason: 'session',
|
|
194
|
-
code: 'c'.repeat(24),
|
|
195
|
-
deviceToken: 'd'.repeat(24),
|
|
196
|
-
};
|
|
197
|
-
const b64 = Buffer.from(JSON.stringify(frag), 'utf-8')
|
|
198
|
-
.toString('base64')
|
|
199
|
-
.replace(/\+/g, '-')
|
|
200
|
-
.replace(/\//g, '_')
|
|
201
|
-
.replace(/=+$/, '');
|
|
202
|
-
const domHandle = makeDom({ hash: `#oxy_boot=${b64}`, sessionState: 'state-match' });
|
|
203
|
-
const { oxy, setTokens } = makeOxy();
|
|
204
|
-
const onSession = jest.fn();
|
|
205
|
-
|
|
206
|
-
const outcome = await runSessionColdBoot({
|
|
207
|
-
oxy, store, platform: WEB, dom: domHandle.dom, onSession,
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
expect(outcome).toEqual({ kind: 'session', via: 'bootstrap-return', session: expect.any(Object) });
|
|
211
|
-
expect(domHandle.strip).toHaveBeenCalled();
|
|
212
|
-
expect(setTokens).toHaveBeenCalledWith('access-boot');
|
|
213
|
-
expect(onSession).toHaveBeenCalledWith(expect.objectContaining({ via: 'bootstrap-return' }));
|
|
214
|
-
});
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
describe('runSessionColdBoot — device-secret-mint (phase 2c)', () => {
|
|
218
|
-
function makeCredStore(extra: Partial<import('../../session/authStateStore').PersistedAuthState> = {}) {
|
|
219
|
-
const store = createMemoryAuthStateStore();
|
|
220
|
-
return { store, seed: async () => {
|
|
58
|
+
/** Seed a store with the zero-cookie mint credential. */
|
|
59
|
+
function seedCredStore(extra: Partial<PersistedAuthState> = {}) {
|
|
60
|
+
const store = createMemoryAuthStateStore();
|
|
61
|
+
return {
|
|
62
|
+
store,
|
|
63
|
+
seed: async () => {
|
|
221
64
|
await store.save({
|
|
222
65
|
sessionId: 'sess-old',
|
|
223
|
-
refreshToken: 'r-abcdefghij',
|
|
224
66
|
userId: 'user-old',
|
|
225
67
|
deviceId: 'dev-mint',
|
|
226
68
|
deviceSecret: 'ds-secret-orig',
|
|
227
69
|
...extra,
|
|
228
70
|
});
|
|
229
|
-
}
|
|
230
|
-
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
231
74
|
|
|
75
|
+
describe('runSessionColdBoot — device-secret-mint', () => {
|
|
232
76
|
it('wins FIRST via the mint, persisting nextDeviceSecret BEFORE planting the token', async () => {
|
|
233
|
-
const { store, seed } =
|
|
77
|
+
const { store, seed } = seedCredStore();
|
|
234
78
|
await seed();
|
|
235
79
|
const mintFromDeviceSecret = jest.fn(async () => MINT);
|
|
236
80
|
const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
|
|
237
81
|
const saveSpy = jest.spyOn(store, 'save');
|
|
238
82
|
const onSession = jest.fn();
|
|
239
83
|
|
|
240
|
-
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB,
|
|
84
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSession });
|
|
241
85
|
|
|
242
86
|
expect(outcome).toEqual({ kind: 'session', via: 'device-secret-mint', session: expect.any(Object) });
|
|
243
87
|
expect(mintFromDeviceSecret).toHaveBeenCalledWith('dev-mint', 'ds-secret-orig');
|
|
@@ -251,6 +95,7 @@ describe('runSessionColdBoot — device-secret-mint (phase 2c)', () => {
|
|
|
251
95
|
const firstPlantOrder = Math.min(...setTokens.mock.invocationCallOrder);
|
|
252
96
|
expect(lastSaveOrder).toBeLessThan(firstPlantOrder);
|
|
253
97
|
expect(await store.load()).toMatchObject({
|
|
98
|
+
deviceId: 'dev-mint',
|
|
254
99
|
deviceSecret: 'ds-next-secret',
|
|
255
100
|
sessionId: 'sess-mint',
|
|
256
101
|
userId: 'user-mint',
|
|
@@ -258,66 +103,77 @@ describe('runSessionColdBoot — device-secret-mint (phase 2c)', () => {
|
|
|
258
103
|
});
|
|
259
104
|
});
|
|
260
105
|
|
|
106
|
+
it('mints on native too (the step runs on both platforms)', async () => {
|
|
107
|
+
const { store, seed } = seedCredStore();
|
|
108
|
+
await seed();
|
|
109
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT);
|
|
110
|
+
const signInWithSharedIdentity = jest.fn(async () => null);
|
|
111
|
+
const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret, signInWithSharedIdentity });
|
|
112
|
+
|
|
113
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE });
|
|
114
|
+
|
|
115
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'device-secret-mint' });
|
|
116
|
+
expect(setTokens).toHaveBeenCalledWith('access-minted');
|
|
117
|
+
// The mint won first — the shared-key fallback was never reached.
|
|
118
|
+
expect(signInWithSharedIdentity).not.toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
|
|
261
121
|
it('skips (no mint) when only one of deviceId / deviceSecret is persisted', async () => {
|
|
262
122
|
const store = createMemoryAuthStateStore();
|
|
263
|
-
await store.save({ sessionId: 's',
|
|
123
|
+
await store.save({ sessionId: 's', userId: 'u', deviceSecret: 'ds-only' });
|
|
264
124
|
const mintFromDeviceSecret = jest.fn(async () => MINT);
|
|
265
125
|
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
126
|
+
const onSignedOut = jest.fn();
|
|
266
127
|
|
|
267
|
-
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB,
|
|
128
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSignedOut });
|
|
268
129
|
|
|
269
130
|
expect(mintFromDeviceSecret).not.toHaveBeenCalled();
|
|
270
|
-
//
|
|
271
|
-
expect(outcome).toEqual({ kind: '
|
|
131
|
+
// Nothing else can resolve on web → signed out.
|
|
132
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
133
|
+
expect(onSignedOut).toHaveBeenCalledWith('no_session');
|
|
272
134
|
});
|
|
273
135
|
|
|
274
|
-
it('401 invalid_device_secret → drops the secret (keeps deviceId) and
|
|
275
|
-
const { store, seed } =
|
|
136
|
+
it('401 invalid_device_secret → drops the secret (keeps deviceId) and ends signed out on web', async () => {
|
|
137
|
+
const { store, seed } = seedCredStore();
|
|
276
138
|
await seed();
|
|
277
139
|
const mintFromDeviceSecret = jest.fn(async () => {
|
|
278
140
|
throw mint401('invalid_device_secret');
|
|
279
141
|
});
|
|
280
142
|
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
143
|
+
const onSignedOut = jest.fn();
|
|
281
144
|
|
|
282
|
-
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB,
|
|
145
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSignedOut });
|
|
283
146
|
|
|
284
147
|
expect(mintFromDeviceSecret).toHaveBeenCalled();
|
|
285
|
-
|
|
286
|
-
expect(
|
|
287
|
-
expect(outcome).toMatchObject({ via: 'stored-tokens' });
|
|
148
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
149
|
+
expect(onSignedOut).toHaveBeenCalledWith('no_session');
|
|
288
150
|
const persisted = await store.load();
|
|
289
151
|
expect(persisted?.deviceSecret).toBeUndefined();
|
|
290
152
|
expect(persisted?.deviceId).toBe('dev-mint');
|
|
291
153
|
});
|
|
292
154
|
|
|
293
|
-
it('401 no_active_session → keeps the secret
|
|
294
|
-
const { store, seed } =
|
|
155
|
+
it('401 no_active_session → keeps the secret and reports signed out', async () => {
|
|
156
|
+
const { store, seed } = seedCredStore();
|
|
295
157
|
await seed();
|
|
296
158
|
const mintFromDeviceSecret = jest.fn(async () => {
|
|
297
159
|
throw mint401('no_active_session');
|
|
298
160
|
});
|
|
299
|
-
const
|
|
300
|
-
const
|
|
301
|
-
const requestWebSession = jest.spyOn(oxy, 'requestWebSession');
|
|
302
|
-
const domHandle = makeDom();
|
|
161
|
+
const signInWithSharedIdentity = jest.fn(async () => null);
|
|
162
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret, signInWithSharedIdentity });
|
|
303
163
|
const onSignedOut = jest.fn();
|
|
304
164
|
|
|
305
|
-
const outcome = await runSessionColdBoot({
|
|
306
|
-
oxy, store, platform: WEB, dom: domHandle.dom, onSignedOut,
|
|
307
|
-
});
|
|
165
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSignedOut });
|
|
308
166
|
|
|
309
167
|
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
310
168
|
expect(onSignedOut).toHaveBeenCalledWith('no_session');
|
|
311
|
-
// The known-signed-out device
|
|
312
|
-
expect(refreshWithToken).not.toHaveBeenCalled();
|
|
313
|
-
expect(requestWebSession).not.toHaveBeenCalled();
|
|
314
|
-
expect(domHandle.navigate).not.toHaveBeenCalled();
|
|
315
|
-
// The secret is retained — the device may sign in again.
|
|
169
|
+
// The known-signed-out device retains its secret (it may sign in again).
|
|
316
170
|
expect((await store.load())?.deviceSecret).toBe('ds-secret-orig');
|
|
171
|
+
// Web has no shared-key lane.
|
|
172
|
+
expect(signInWithSharedIdentity).not.toHaveBeenCalled();
|
|
317
173
|
});
|
|
318
174
|
|
|
319
175
|
it('classifies a PLAIN-OBJECT 401 (no Error prototype) — no_active_session still keeps the secret', async () => {
|
|
320
|
-
const { store, seed } =
|
|
176
|
+
const { store, seed } = seedCredStore();
|
|
321
177
|
await seed();
|
|
322
178
|
// Cross-realm / ApiError-shaped throw: not `instanceof Error`. Must still be
|
|
323
179
|
// read as no_active_session — misreading it as a stale secret would drop it.
|
|
@@ -327,205 +183,136 @@ describe('runSessionColdBoot — device-secret-mint (phase 2c)', () => {
|
|
|
327
183
|
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
328
184
|
const onSignedOut = jest.fn();
|
|
329
185
|
|
|
330
|
-
const outcome = await runSessionColdBoot({
|
|
331
|
-
oxy, store, platform: WEB, dom: makeDom().dom, onSignedOut,
|
|
332
|
-
});
|
|
186
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSignedOut });
|
|
333
187
|
|
|
334
188
|
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
189
|
+
expect(onSignedOut).toHaveBeenCalledWith('no_session');
|
|
335
190
|
expect((await store.load())?.deviceSecret).toBe('ds-secret-orig');
|
|
336
191
|
});
|
|
337
192
|
|
|
338
|
-
it('transient (non-401) mint error → keeps the secret and
|
|
339
|
-
const { store, seed } =
|
|
193
|
+
it('transient (non-401) mint error → keeps the secret and reports signed out on web', async () => {
|
|
194
|
+
const { store, seed } = seedCredStore();
|
|
340
195
|
await seed();
|
|
341
196
|
const mintFromDeviceSecret = jest.fn(async () => {
|
|
342
197
|
throw new Error('network down');
|
|
343
198
|
});
|
|
344
199
|
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
200
|
+
const onSignedOut = jest.fn();
|
|
345
201
|
|
|
346
|
-
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB,
|
|
202
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSignedOut });
|
|
347
203
|
|
|
348
|
-
expect(outcome).
|
|
349
|
-
|
|
204
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
205
|
+
expect(onSignedOut).toHaveBeenCalledWith('no_session');
|
|
206
|
+
// The secret survives a transient failure so a later attempt can succeed.
|
|
350
207
|
expect((await store.load())?.deviceSecret).toBe('ds-secret-orig');
|
|
351
208
|
});
|
|
352
209
|
|
|
353
|
-
it('
|
|
354
|
-
const { store, seed } =
|
|
210
|
+
it('a dropped secret (401) on native falls through to the shared-key lane', async () => {
|
|
211
|
+
const { store, seed } = seedCredStore();
|
|
355
212
|
await seed();
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
213
|
+
const sharedSession: SessionLoginResponse = {
|
|
214
|
+
sessionId: 'sess-shared',
|
|
215
|
+
deviceId: 'dev-1',
|
|
216
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
217
|
+
user: { id: 'user-shared', username: 'u', name: {}, avatar: undefined },
|
|
218
|
+
accessToken: 'access-shared',
|
|
219
|
+
};
|
|
359
220
|
const mintFromDeviceSecret = jest.fn(async () => {
|
|
360
|
-
throw
|
|
361
|
-
});
|
|
362
|
-
const refreshWithToken = jest.fn(async () => {
|
|
363
|
-
throw new Error('refresh down');
|
|
221
|
+
throw mint401('invalid_device_secret');
|
|
364
222
|
});
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
);
|
|
368
|
-
const { oxy } = makeOxy({ mintFromDeviceSecret, refreshWithToken, requestWebSession });
|
|
369
|
-
const domHandle = makeDom({ hostname: 'accounts.oxy.so' });
|
|
223
|
+
const signInWithSharedIdentity = jest.fn(async () => sharedSession);
|
|
224
|
+
const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret, signInWithSharedIdentity });
|
|
370
225
|
|
|
371
|
-
const outcome = await runSessionColdBoot({ oxy, store, platform:
|
|
226
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE });
|
|
372
227
|
|
|
373
|
-
expect(outcome).
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
expect(
|
|
228
|
+
expect(outcome).toEqual({ kind: 'session', via: 'shared-key-signin', session: expect.any(Object) });
|
|
229
|
+
expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
|
|
230
|
+
// The mint lane still dropped the stale secret before falling through.
|
|
231
|
+
expect((await store.load())?.deviceSecret).toBeUndefined();
|
|
232
|
+
// Shared-key plants tokens itself (via verifyChallenge); the cold boot does not.
|
|
233
|
+
expect(setTokens).not.toHaveBeenCalled();
|
|
377
234
|
});
|
|
235
|
+
});
|
|
378
236
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
.replace(/=+$/, '');
|
|
388
|
-
const domHandle = makeDom({ hash: `#oxy_boot=${b64}`, sessionState: 'state-match' });
|
|
389
|
-
const mintFromDeviceSecret = jest.fn(async () => MINT);
|
|
390
|
-
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
237
|
+
describe('runSessionColdBoot — shared-key-signin (native)', () => {
|
|
238
|
+
const sharedSession: SessionLoginResponse = {
|
|
239
|
+
sessionId: 'sess-shared',
|
|
240
|
+
deviceId: 'dev-1',
|
|
241
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
242
|
+
user: { id: 'user-shared', username: 'u', name: {}, avatar: undefined },
|
|
243
|
+
accessToken: 'access-shared',
|
|
244
|
+
};
|
|
391
245
|
|
|
392
|
-
|
|
246
|
+
it('re-mints via the shared identity when there is no persisted mint credential', async () => {
|
|
247
|
+
const store = createMemoryAuthStateStore(); // no deviceId/deviceSecret
|
|
248
|
+
const signInWithSharedIdentity = jest.fn(async () => sharedSession);
|
|
249
|
+
const { oxy } = makeOxy({ signInWithSharedIdentity });
|
|
250
|
+
const onSession = jest.fn();
|
|
393
251
|
|
|
394
|
-
|
|
395
|
-
expect(mintFromDeviceSecret).not.toHaveBeenCalled();
|
|
396
|
-
expect(domHandle.strip).toHaveBeenCalled();
|
|
397
|
-
});
|
|
398
|
-
});
|
|
252
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE, onSession });
|
|
399
253
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
const sessionEnvelope: WebSessionResult = { reason: 'session', session: BUNDLE, deviceToken: 'dt-rotated' };
|
|
406
|
-
const requestWebSession = jest.fn(async () => sessionEnvelope);
|
|
407
|
-
const { oxy, setTokens } = makeOxy({ requestWebSession });
|
|
408
|
-
const domHandle = makeDom({ hostname: 'accounts.oxy.so' });
|
|
409
|
-
|
|
410
|
-
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: domHandle.dom });
|
|
411
|
-
|
|
412
|
-
expect(outcome).toEqual({ kind: 'session', via: 'bootstrap-hop', session: expect.any(Object) });
|
|
413
|
-
expect(requestWebSession).toHaveBeenCalledTimes(1);
|
|
414
|
-
expect(setTokens).toHaveBeenCalledWith('access-boot');
|
|
415
|
-
expect(domHandle.navigate).not.toHaveBeenCalled();
|
|
416
|
-
expect(await store.load()).toMatchObject({ sessionId: 'sess-boot', refreshToken: 'refresh-boot-abcdefghij' });
|
|
417
|
-
// deviceToken from the SAME envelope is persisted on the session arm too.
|
|
418
|
-
expect(await store.loadDeviceToken()).toBe('dt-rotated');
|
|
254
|
+
expect(outcome).toEqual({ kind: 'session', via: 'shared-key-signin', session: expect.any(Object) });
|
|
255
|
+
expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
|
|
256
|
+
expect(onSession).toHaveBeenCalledWith(
|
|
257
|
+
expect.objectContaining({ sessionId: 'sess-shared', userId: 'user-shared', via: 'shared-key-signin' }),
|
|
258
|
+
);
|
|
419
259
|
});
|
|
420
260
|
|
|
421
|
-
it('
|
|
261
|
+
it('does NOT run the shared-key lane on web', async () => {
|
|
422
262
|
const store = createMemoryAuthStateStore();
|
|
423
|
-
const
|
|
424
|
-
const { oxy } = makeOxy({
|
|
425
|
-
const domHandle = makeDom({ hostname: 'accounts.oxy.so' });
|
|
263
|
+
const signInWithSharedIdentity = jest.fn(async () => sharedSession);
|
|
264
|
+
const { oxy } = makeOxy({ signInWithSharedIdentity });
|
|
426
265
|
const onSignedOut = jest.fn();
|
|
427
266
|
|
|
428
|
-
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB,
|
|
267
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSignedOut });
|
|
429
268
|
|
|
269
|
+
expect(signInWithSharedIdentity).not.toHaveBeenCalled();
|
|
430
270
|
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
431
271
|
expect(onSignedOut).toHaveBeenCalledWith('no_session');
|
|
432
|
-
expect(await store.loadDeviceToken()).toBe('dt-web');
|
|
433
|
-
expect(domHandle.navigate).not.toHaveBeenCalled();
|
|
434
272
|
});
|
|
435
273
|
|
|
436
|
-
it('
|
|
274
|
+
it('reports signed out when the shared identity yields no session', async () => {
|
|
437
275
|
const store = createMemoryAuthStateStore();
|
|
438
|
-
const
|
|
439
|
-
const
|
|
276
|
+
const signInWithSharedIdentity = jest.fn(async () => null);
|
|
277
|
+
const { oxy } = makeOxy({ signInWithSharedIdentity });
|
|
440
278
|
const onSignedOut = jest.fn();
|
|
441
|
-
const onSession = jest.fn();
|
|
442
279
|
|
|
443
|
-
await runSessionColdBoot({ oxy, store, platform:
|
|
280
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE, onSignedOut });
|
|
444
281
|
|
|
445
|
-
expect(
|
|
446
|
-
expect(domHandle.navigate).toHaveBeenCalledWith(expect.stringContaining('/auth/device/bootstrap?return_to='));
|
|
447
|
-
expect(domHandle.session.get(BOOT_STATE_SESSION_KEY)).toBe('state-fixed-1234');
|
|
448
|
-
expect(domHandle.local.get(BOOT_ATTEMPTED_KEY)).toBe('1');
|
|
449
|
-
// Navigating away — neither callback fires (no signed-out flash).
|
|
450
|
-
expect(onSignedOut).not.toHaveBeenCalled();
|
|
451
|
-
expect(onSession).not.toHaveBeenCalled();
|
|
452
|
-
});
|
|
453
|
-
|
|
454
|
-
it('cross-apex: NEVER navigates a second time once the attempted flag is set', async () => {
|
|
455
|
-
const store = createMemoryAuthStateStore();
|
|
456
|
-
const { oxy } = makeOxy();
|
|
457
|
-
const domHandle = makeDom({ hostname: 'app.mention.earth', attempted: true });
|
|
458
|
-
const onSignedOut = jest.fn();
|
|
459
|
-
|
|
460
|
-
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, dom: domHandle.dom, onSignedOut });
|
|
461
|
-
|
|
462
|
-
expect(domHandle.navigate).not.toHaveBeenCalled();
|
|
282
|
+
expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
|
|
463
283
|
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
464
284
|
expect(onSignedOut).toHaveBeenCalledWith('no_session');
|
|
465
285
|
});
|
|
466
|
-
});
|
|
467
286
|
|
|
468
|
-
|
|
469
|
-
afterEach(() => jest.restoreAllMocks());
|
|
470
|
-
|
|
471
|
-
it('re-mints via shared identity and issues + mirrors a deviceToken the first time', async () => {
|
|
287
|
+
it('reports a step error (onStepError + signed-out reason `error`) when the shared identity throws', async () => {
|
|
472
288
|
const store = createMemoryAuthStateStore();
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
deviceId: 'dev-1',
|
|
476
|
-
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
477
|
-
user: { id: 'user-shared', username: 'u', name: {}, avatar: undefined },
|
|
478
|
-
accessToken: 'access-shared',
|
|
479
|
-
};
|
|
480
|
-
const issueNativeDeviceToken = jest.fn(async () => 'fresh-device-token');
|
|
481
|
-
const { oxy } = makeOxy({
|
|
482
|
-
signInWithSharedIdentity: async () => sharedSession,
|
|
483
|
-
issueNativeDeviceToken,
|
|
289
|
+
const signInWithSharedIdentity = jest.fn(async () => {
|
|
290
|
+
throw new Error('keychain locked');
|
|
484
291
|
});
|
|
485
|
-
const
|
|
486
|
-
const
|
|
487
|
-
const
|
|
292
|
+
const { oxy } = makeOxy({ signInWithSharedIdentity });
|
|
293
|
+
const onSignedOut = jest.fn();
|
|
294
|
+
const onStepError = jest.fn();
|
|
488
295
|
|
|
489
|
-
const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE,
|
|
296
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE, onSignedOut, onStepError });
|
|
490
297
|
|
|
491
|
-
expect(outcome).toEqual({ kind: '
|
|
492
|
-
expect(
|
|
493
|
-
expect(
|
|
494
|
-
expect(setShared).toHaveBeenCalledWith('fresh-device-token');
|
|
495
|
-
expect(await store.loadDeviceToken()).toBe('fresh-device-token');
|
|
496
|
-
// bootstrap-hop is web-only — never runs on native.
|
|
497
|
-
expect(requestWebSession).not.toHaveBeenCalled();
|
|
298
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
299
|
+
expect(onStepError).toHaveBeenCalledWith('shared-key-signin', expect.any(Error));
|
|
300
|
+
expect(onSignedOut).toHaveBeenCalledWith('error');
|
|
498
301
|
});
|
|
302
|
+
});
|
|
499
303
|
|
|
500
|
-
|
|
304
|
+
describe('runSessionColdBoot — signed out', () => {
|
|
305
|
+
it('reports signed out when the store is empty and no lane resolves (web)', async () => {
|
|
501
306
|
const store = createMemoryAuthStateStore();
|
|
502
|
-
const
|
|
503
|
-
const
|
|
504
|
-
|
|
505
|
-
sessionId: 'sess-shared',
|
|
506
|
-
deviceId: 'dev-1',
|
|
507
|
-
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
508
|
-
user: { id: 'user-shared', username: 'u', name: {}, avatar: undefined },
|
|
509
|
-
accessToken: 'access-shared',
|
|
510
|
-
}),
|
|
511
|
-
issueNativeDeviceToken,
|
|
512
|
-
});
|
|
513
|
-
jest.spyOn(KeyManager, 'getSharedDeviceToken').mockResolvedValue('already-there');
|
|
514
|
-
|
|
515
|
-
await runSessionColdBoot({ oxy, store, platform: NATIVE, dom: makeDom().dom });
|
|
307
|
+
const { oxy, setTokens } = makeOxy();
|
|
308
|
+
const onSession = jest.fn();
|
|
309
|
+
const onSignedOut = jest.fn();
|
|
516
310
|
|
|
517
|
-
|
|
518
|
-
});
|
|
519
|
-
});
|
|
311
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSession, onSignedOut });
|
|
520
312
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
expect(
|
|
525
|
-
expect(dom.getLocationHostname()).toBeNull();
|
|
526
|
-
expect(dom.getSessionItem('x')).toBeNull();
|
|
527
|
-
expect(() => dom.stripFragment()).not.toThrow();
|
|
528
|
-
expect(() => dom.navigate('https://x')).not.toThrow();
|
|
529
|
-
expect(dom.randomState()).toMatch(/^[0-9a-f]+$/);
|
|
313
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
314
|
+
expect(onSignedOut).toHaveBeenCalledWith('no_session');
|
|
315
|
+
expect(onSession).not.toHaveBeenCalled();
|
|
316
|
+
expect(setTokens).not.toHaveBeenCalled();
|
|
530
317
|
});
|
|
531
318
|
});
|