@oxyhq/core 8.0.0 → 9.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/boot/coldBootV2.js +66 -281
- 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 +24 -92
- 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 +14 -88
- package/dist/cjs/session/createSessionClient.js +2 -9
- package/dist/cjs/session/refresh.js +46 -62
- package/dist/cjs/utils/registrableApex.js +2 -6
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/coldBootV2.js +67 -279
- 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 +25 -93
- 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 +13 -87
- package/dist/esm/session/createSessionClient.js +2 -9
- package/dist/esm/session/refresh.js +46 -62
- 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 +22 -46
- package/dist/types/session/SessionClient.d.ts +4 -38
- package/dist/types/session/accountDialogController.d.ts +1 -3
- package/dist/types/session/authStateStore.d.ts +38 -43
- 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 +2 -2
- package/src/HttpService.ts +3 -3
- package/src/boot/__tests__/coldBootV2.test.ts +237 -236
- package/src/boot/coldBootV2.ts +92 -333
- 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 +30 -115
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +36 -80
- 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/session/SessionClient.ts +29 -120
- 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 +47 -33
- package/src/session/__tests__/refresh.test.ts +72 -44
- package/src/session/accountDialogController.ts +4 -18
- package/src/session/authStateStore.ts +43 -111
- package/src/session/createSessionClient.ts +2 -9
- package/src/session/refresh.ts +60 -83
- package/src/utils/registrableApex.ts +2 -6
- package/dist/cjs/boot/deviceBootReturn.js +0 -152
- package/dist/esm/boot/deviceBootReturn.js +0 -146
- package/dist/types/boot/deviceBootReturn.d.ts +0 -83
- package/src/boot/__tests__/deviceBootReturn.test.ts +0 -158
- package/src/boot/deviceBootReturn.ts +0 -195
- package/src/crypto/__tests__/sharedDeviceToken.test.ts +0 -24
- package/src/session/__tests__/SessionClient.signedOut.test.ts +0 -224
|
@@ -4,12 +4,8 @@
|
|
|
4
4
|
* The client FAPI auto-detection helper was removed in the device-first cutover
|
|
5
5
|
* (which is why this file is now named for what it actually is, not the old
|
|
6
6
|
* `fapiAutoDetect`). What survives is the pure registrable-domain kernel, still
|
|
7
|
-
* used server
|
|
8
|
-
*
|
|
9
|
-
* `isSameSiteTrustedRequest`) and the `@oxyhq/core/server` CORS/re-export layer.
|
|
10
|
-
*
|
|
11
|
-
* `registrableApex` is NOT legacy — the device-first same-apex check is a live
|
|
12
|
-
* consumer, so this kernel stays regardless of the SSO/FedCM removal.
|
|
7
|
+
* used by the `@oxyhq/core/server` CORS layer (the `*.oxy.so` same-apex trust
|
|
8
|
+
* check) and its re-export surface.
|
|
13
9
|
*/
|
|
14
10
|
/**
|
|
15
11
|
* Compute the bare registrable apex (eTLD+1) of a hostname using the Public
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxyhq/core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "9.0.0",
|
|
4
4
|
"description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
|
|
5
5
|
"main": "dist/cjs/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -94,7 +94,7 @@
|
|
|
94
94
|
}
|
|
95
95
|
},
|
|
96
96
|
"dependencies": {
|
|
97
|
-
"@oxyhq/contracts": "^0.
|
|
97
|
+
"@oxyhq/contracts": "^0.13.0",
|
|
98
98
|
"@oxyhq/protocol": "^0.1.2",
|
|
99
99
|
"bip39": "^3.1.0",
|
|
100
100
|
"buffer": "^6.0.3",
|
package/src/HttpService.ts
CHANGED
|
@@ -74,13 +74,13 @@ export interface RequestOptions {
|
|
|
74
74
|
* Skip BOTH the bearer auth header (and its near-expiry preflight refresh)
|
|
75
75
|
* AND the 401-driven auto-refresh/retry for this request.
|
|
76
76
|
*
|
|
77
|
-
* Required for the body-authenticated
|
|
78
|
-
* /
|
|
77
|
+
* Required for the body-authenticated device-secret mint (`POST
|
|
78
|
+
* /session/device/token`): it does not need a bearer, and — critically — it is
|
|
79
79
|
* itself invoked from inside the registered `AuthRefreshHandler`. If it went
|
|
80
80
|
* through the normal preflight, `getAuthHeader` would call
|
|
81
81
|
* `refreshAccessToken` while the handler-owning `tokenRefreshPromise` is
|
|
82
82
|
* still in flight and await ITSELF (deadlock). Skipping auth makes the
|
|
83
|
-
*
|
|
83
|
+
* mint call fully independent of the current (near-expired) bearer.
|
|
84
84
|
*/
|
|
85
85
|
skipAuth?: boolean;
|
|
86
86
|
}
|
|
@@ -1,261 +1,215 @@
|
|
|
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 {
|
|
10
|
+
import type { DeviceTokenMintResponse } from '@oxyhq/contracts';
|
|
3
11
|
import type { SessionLoginResponse } from '../../models/session';
|
|
4
|
-
import {
|
|
5
|
-
|
|
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
|
-
};
|
|
12
|
+
import { runSessionColdBoot } from '../coldBootV2';
|
|
13
|
+
import { createMemoryAuthStateStore, type PersistedAuthState } from '../../session/authStateStore';
|
|
29
14
|
|
|
30
15
|
interface OxyOverrides {
|
|
31
|
-
baseURL?: string;
|
|
32
|
-
exchangeBootCode?: OxyServices['exchangeBootCode'];
|
|
33
|
-
refreshWithToken?: OxyServices['refreshWithToken'];
|
|
34
16
|
signInWithSharedIdentity?: OxyServices['signInWithSharedIdentity'];
|
|
35
|
-
|
|
36
|
-
requestWebSession?: OxyServices['requestWebSession'];
|
|
17
|
+
mintFromDeviceSecret?: OxyServices['mintFromDeviceSecret'];
|
|
37
18
|
}
|
|
38
19
|
|
|
39
20
|
function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: jest.Mock } {
|
|
40
21
|
const setTokens = jest.fn();
|
|
41
22
|
const oxy = {
|
|
42
|
-
getBaseURL: () =>
|
|
23
|
+
getBaseURL: () => 'https://api.oxy.so',
|
|
43
24
|
setTokens,
|
|
44
|
-
exchangeBootCode: overrides.exchangeBootCode ?? (async () => BUNDLE),
|
|
45
|
-
refreshWithToken: overrides.refreshWithToken ?? (async () => ROTATED),
|
|
46
25
|
signInWithSharedIdentity: overrides.signInWithSharedIdentity ?? (async () => null),
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
26
|
+
// Default: no persisted secret in these fixtures, so the mint step skips
|
|
27
|
+
// before ever calling this. Tests that exercise the mint pass an override.
|
|
28
|
+
mintFromDeviceSecret:
|
|
29
|
+
overrides.mintFromDeviceSecret
|
|
30
|
+
?? (async () => {
|
|
31
|
+
throw new Error('mintFromDeviceSecret not stubbed');
|
|
32
|
+
}),
|
|
53
33
|
} as unknown as OxyServices;
|
|
54
34
|
return { oxy, setTokens };
|
|
55
35
|
}
|
|
56
36
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
37
|
+
const MINT: DeviceTokenMintResponse = {
|
|
38
|
+
accessToken: 'access-minted',
|
|
39
|
+
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
|
40
|
+
nextDeviceSecret: 'ds-next-secret',
|
|
41
|
+
state: {
|
|
42
|
+
deviceId: 'dev-mint',
|
|
43
|
+
accounts: [{ accountId: 'user-mint', sessionId: 'sess-mint', authuser: 0 }],
|
|
44
|
+
activeAccountId: 'user-mint',
|
|
45
|
+
revision: 2,
|
|
46
|
+
updatedAt: 1_700_000_000_000,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
64
49
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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 };
|
|
50
|
+
/** A 401 error shaped like `HttpService`/`handleError` output for the given body. */
|
|
51
|
+
function mint401(body: string): Error & { status: number } {
|
|
52
|
+
return Object.assign(new Error(body), { status: 401 });
|
|
92
53
|
}
|
|
93
54
|
|
|
94
55
|
const WEB = { isWeb: true, isNative: false };
|
|
95
56
|
const NATIVE = { isWeb: false, isNative: true };
|
|
96
57
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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 () => {
|
|
64
|
+
await store.save({
|
|
65
|
+
sessionId: 'sess-old',
|
|
66
|
+
userId: 'user-old',
|
|
67
|
+
deviceId: 'dev-mint',
|
|
68
|
+
deviceSecret: 'ds-secret-orig',
|
|
69
|
+
...extra,
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
104
74
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
75
|
+
describe('runSessionColdBoot — device-secret-mint', () => {
|
|
76
|
+
it('wins FIRST via the mint, persisting nextDeviceSecret BEFORE planting the token', async () => {
|
|
77
|
+
const { store, seed } = seedCredStore();
|
|
78
|
+
await seed();
|
|
79
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT);
|
|
80
|
+
const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
|
|
81
|
+
const saveSpy = jest.spyOn(store, 'save');
|
|
82
|
+
const onSession = jest.fn();
|
|
111
83
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
expect(
|
|
115
|
-
expect(
|
|
116
|
-
expect(
|
|
84
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSession });
|
|
85
|
+
|
|
86
|
+
expect(outcome).toEqual({ kind: 'session', via: 'device-secret-mint', session: expect.any(Object) });
|
|
87
|
+
expect(mintFromDeviceSecret).toHaveBeenCalledWith('dev-mint', 'ds-secret-orig');
|
|
88
|
+
expect(setTokens).toHaveBeenCalledWith('access-minted');
|
|
89
|
+
// Session identity comes from the mint's authoritative active account.
|
|
90
|
+
expect(onSession).toHaveBeenCalledWith(
|
|
91
|
+
expect.objectContaining({ sessionId: 'sess-mint', userId: 'user-mint', via: 'device-secret-mint' }),
|
|
92
|
+
);
|
|
93
|
+
// Rotation-in-use anti-loss: the store held the NEXT secret before the plant.
|
|
94
|
+
const lastSaveOrder = Math.max(...saveSpy.mock.invocationCallOrder);
|
|
95
|
+
const firstPlantOrder = Math.min(...setTokens.mock.invocationCallOrder);
|
|
96
|
+
expect(lastSaveOrder).toBeLessThan(firstPlantOrder);
|
|
97
|
+
expect(await store.load()).toMatchObject({
|
|
98
|
+
deviceId: 'dev-mint',
|
|
99
|
+
deviceSecret: 'ds-next-secret',
|
|
100
|
+
sessionId: 'sess-mint',
|
|
101
|
+
userId: 'user-mint',
|
|
102
|
+
accessToken: 'access-minted',
|
|
103
|
+
});
|
|
117
104
|
});
|
|
118
|
-
});
|
|
119
105
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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();
|
|
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 });
|
|
133
112
|
|
|
134
|
-
const outcome = await runSessionColdBoot({
|
|
135
|
-
oxy, store, platform: WEB, dom: makeDom().dom, onSession,
|
|
136
|
-
});
|
|
113
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE });
|
|
137
114
|
|
|
138
|
-
expect(outcome).
|
|
139
|
-
expect(setTokens).toHaveBeenCalledWith('
|
|
140
|
-
|
|
141
|
-
expect(
|
|
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();
|
|
142
119
|
});
|
|
143
120
|
|
|
144
|
-
it('
|
|
121
|
+
it('skips (no mint) when only one of deviceId / deviceSecret is persisted', async () => {
|
|
145
122
|
const store = createMemoryAuthStateStore();
|
|
146
|
-
await store.save({ sessionId: '
|
|
147
|
-
const
|
|
123
|
+
await store.save({ sessionId: 's', userId: 'u', deviceSecret: 'ds-only' });
|
|
124
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT);
|
|
125
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
126
|
+
const onSignedOut = jest.fn();
|
|
148
127
|
|
|
149
|
-
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB,
|
|
128
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSignedOut });
|
|
150
129
|
|
|
151
|
-
expect(
|
|
152
|
-
|
|
153
|
-
expect(
|
|
130
|
+
expect(mintFromDeviceSecret).not.toHaveBeenCalled();
|
|
131
|
+
// Nothing else can resolve on web → signed out.
|
|
132
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
133
|
+
expect(onSignedOut).toHaveBeenCalledWith('no_session');
|
|
154
134
|
});
|
|
155
135
|
|
|
156
|
-
it('
|
|
157
|
-
const store =
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
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,
|
|
136
|
+
it('401 invalid_device_secret → drops the secret (keeps deviceId) and ends signed out on web', async () => {
|
|
137
|
+
const { store, seed } = seedCredStore();
|
|
138
|
+
await seed();
|
|
139
|
+
const mintFromDeviceSecret = jest.fn(async () => {
|
|
140
|
+
throw mint401('invalid_device_secret');
|
|
177
141
|
});
|
|
142
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
143
|
+
const onSignedOut = jest.fn();
|
|
178
144
|
|
|
179
|
-
|
|
180
|
-
expect(domHandle.strip).toHaveBeenCalled();
|
|
181
|
-
expect(setTokens).toHaveBeenCalledWith('access-boot');
|
|
182
|
-
expect(onSession).toHaveBeenCalledWith(expect.objectContaining({ via: 'bootstrap-return' }));
|
|
183
|
-
});
|
|
184
|
-
});
|
|
145
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSignedOut });
|
|
185
146
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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');
|
|
147
|
+
expect(mintFromDeviceSecret).toHaveBeenCalled();
|
|
148
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
149
|
+
expect(onSignedOut).toHaveBeenCalledWith('no_session');
|
|
150
|
+
const persisted = await store.load();
|
|
151
|
+
expect(persisted?.deviceSecret).toBeUndefined();
|
|
152
|
+
expect(persisted?.deviceId).toBe('dev-mint');
|
|
205
153
|
});
|
|
206
154
|
|
|
207
|
-
it('
|
|
208
|
-
const store =
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
|
|
155
|
+
it('401 no_active_session → keeps the secret and reports signed out', async () => {
|
|
156
|
+
const { store, seed } = seedCredStore();
|
|
157
|
+
await seed();
|
|
158
|
+
const mintFromDeviceSecret = jest.fn(async () => {
|
|
159
|
+
throw mint401('no_active_session');
|
|
160
|
+
});
|
|
161
|
+
const signInWithSharedIdentity = jest.fn(async () => null);
|
|
162
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret, signInWithSharedIdentity });
|
|
212
163
|
const onSignedOut = jest.fn();
|
|
213
164
|
|
|
214
|
-
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB,
|
|
165
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSignedOut });
|
|
215
166
|
|
|
216
167
|
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
217
168
|
expect(onSignedOut).toHaveBeenCalledWith('no_session');
|
|
218
|
-
|
|
219
|
-
expect(
|
|
169
|
+
// The known-signed-out device retains its secret (it may sign in again).
|
|
170
|
+
expect((await store.load())?.deviceSecret).toBe('ds-secret-orig');
|
|
171
|
+
// Web has no shared-key lane.
|
|
172
|
+
expect(signInWithSharedIdentity).not.toHaveBeenCalled();
|
|
220
173
|
});
|
|
221
174
|
|
|
222
|
-
it('
|
|
223
|
-
const store =
|
|
224
|
-
|
|
225
|
-
|
|
175
|
+
it('classifies a PLAIN-OBJECT 401 (no Error prototype) — no_active_session still keeps the secret', async () => {
|
|
176
|
+
const { store, seed } = seedCredStore();
|
|
177
|
+
await seed();
|
|
178
|
+
// Cross-realm / ApiError-shaped throw: not `instanceof Error`. Must still be
|
|
179
|
+
// read as no_active_session — misreading it as a stale secret would drop it.
|
|
180
|
+
const mintFromDeviceSecret = jest.fn(async () => {
|
|
181
|
+
throw { status: 401, message: 'no_active_session' };
|
|
182
|
+
});
|
|
183
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
226
184
|
const onSignedOut = jest.fn();
|
|
227
|
-
const onSession = jest.fn();
|
|
228
185
|
|
|
229
|
-
await runSessionColdBoot({ oxy, store, platform: WEB,
|
|
186
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSignedOut });
|
|
230
187
|
|
|
231
|
-
expect(
|
|
232
|
-
expect(
|
|
233
|
-
expect(
|
|
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();
|
|
188
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
189
|
+
expect(onSignedOut).toHaveBeenCalledWith('no_session');
|
|
190
|
+
expect((await store.load())?.deviceSecret).toBe('ds-secret-orig');
|
|
238
191
|
});
|
|
239
192
|
|
|
240
|
-
it('
|
|
241
|
-
const store =
|
|
242
|
-
|
|
243
|
-
const
|
|
193
|
+
it('transient (non-401) mint error → keeps the secret and reports signed out on web', async () => {
|
|
194
|
+
const { store, seed } = seedCredStore();
|
|
195
|
+
await seed();
|
|
196
|
+
const mintFromDeviceSecret = jest.fn(async () => {
|
|
197
|
+
throw new Error('network down');
|
|
198
|
+
});
|
|
199
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
244
200
|
const onSignedOut = jest.fn();
|
|
245
201
|
|
|
246
|
-
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB,
|
|
202
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSignedOut });
|
|
247
203
|
|
|
248
|
-
expect(domHandle.navigate).not.toHaveBeenCalled();
|
|
249
204
|
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
250
205
|
expect(onSignedOut).toHaveBeenCalledWith('no_session');
|
|
206
|
+
// The secret survives a transient failure so a later attempt can succeed.
|
|
207
|
+
expect((await store.load())?.deviceSecret).toBe('ds-secret-orig');
|
|
251
208
|
});
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
describe('runSessionColdBoot — native shared-key', () => {
|
|
255
|
-
afterEach(() => jest.restoreAllMocks());
|
|
256
209
|
|
|
257
|
-
it('
|
|
258
|
-
const store =
|
|
210
|
+
it('a dropped secret (401) on native falls through to the shared-key lane', async () => {
|
|
211
|
+
const { store, seed } = seedCredStore();
|
|
212
|
+
await seed();
|
|
259
213
|
const sharedSession: SessionLoginResponse = {
|
|
260
214
|
sessionId: 'sess-shared',
|
|
261
215
|
deviceId: 'dev-1',
|
|
@@ -263,55 +217,102 @@ describe('runSessionColdBoot — native shared-key', () => {
|
|
|
263
217
|
user: { id: 'user-shared', username: 'u', name: {}, avatar: undefined },
|
|
264
218
|
accessToken: 'access-shared',
|
|
265
219
|
};
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
signInWithSharedIdentity: async () => sharedSession,
|
|
269
|
-
issueNativeDeviceToken,
|
|
220
|
+
const mintFromDeviceSecret = jest.fn(async () => {
|
|
221
|
+
throw mint401('invalid_device_secret');
|
|
270
222
|
});
|
|
271
|
-
const
|
|
272
|
-
const
|
|
273
|
-
|
|
223
|
+
const signInWithSharedIdentity = jest.fn(async () => sharedSession);
|
|
224
|
+
const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret, signInWithSharedIdentity });
|
|
225
|
+
|
|
226
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE });
|
|
227
|
+
|
|
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();
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
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
|
+
};
|
|
274
245
|
|
|
275
|
-
|
|
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();
|
|
251
|
+
|
|
252
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE, onSession });
|
|
276
253
|
|
|
277
254
|
expect(outcome).toEqual({ kind: 'session', via: 'shared-key-signin', session: expect.any(Object) });
|
|
278
|
-
expect(
|
|
279
|
-
expect(
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
// bootstrap-hop is web-only — never runs on native.
|
|
283
|
-
expect(requestWebSession).not.toHaveBeenCalled();
|
|
255
|
+
expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
|
|
256
|
+
expect(onSession).toHaveBeenCalledWith(
|
|
257
|
+
expect.objectContaining({ sessionId: 'sess-shared', userId: 'user-shared', via: 'shared-key-signin' }),
|
|
258
|
+
);
|
|
284
259
|
});
|
|
285
260
|
|
|
286
|
-
it('
|
|
261
|
+
it('does NOT run the shared-key lane on web', async () => {
|
|
287
262
|
const store = createMemoryAuthStateStore();
|
|
288
|
-
const
|
|
289
|
-
const { oxy } = makeOxy({
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
263
|
+
const signInWithSharedIdentity = jest.fn(async () => sharedSession);
|
|
264
|
+
const { oxy } = makeOxy({ signInWithSharedIdentity });
|
|
265
|
+
const onSignedOut = jest.fn();
|
|
266
|
+
|
|
267
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSignedOut });
|
|
268
|
+
|
|
269
|
+
expect(signInWithSharedIdentity).not.toHaveBeenCalled();
|
|
270
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
271
|
+
expect(onSignedOut).toHaveBeenCalledWith('no_session');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('reports signed out when the shared identity yields no session', async () => {
|
|
275
|
+
const store = createMemoryAuthStateStore();
|
|
276
|
+
const signInWithSharedIdentity = jest.fn(async () => null);
|
|
277
|
+
const { oxy } = makeOxy({ signInWithSharedIdentity });
|
|
278
|
+
const onSignedOut = jest.fn();
|
|
279
|
+
|
|
280
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE, onSignedOut });
|
|
281
|
+
|
|
282
|
+
expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
|
|
283
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
284
|
+
expect(onSignedOut).toHaveBeenCalledWith('no_session');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('reports a step error (onStepError + signed-out reason `error`) when the shared identity throws', async () => {
|
|
288
|
+
const store = createMemoryAuthStateStore();
|
|
289
|
+
const signInWithSharedIdentity = jest.fn(async () => {
|
|
290
|
+
throw new Error('keychain locked');
|
|
298
291
|
});
|
|
299
|
-
|
|
292
|
+
const { oxy } = makeOxy({ signInWithSharedIdentity });
|
|
293
|
+
const onSignedOut = jest.fn();
|
|
294
|
+
const onStepError = jest.fn();
|
|
300
295
|
|
|
301
|
-
await runSessionColdBoot({ oxy, store, platform: NATIVE,
|
|
296
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE, onSignedOut, onStepError });
|
|
302
297
|
|
|
303
|
-
expect(
|
|
298
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
299
|
+
expect(onStepError).toHaveBeenCalledWith('shared-key-signin', expect.any(Error));
|
|
300
|
+
expect(onSignedOut).toHaveBeenCalledWith('error');
|
|
304
301
|
});
|
|
305
302
|
});
|
|
306
303
|
|
|
307
|
-
describe('
|
|
308
|
-
it('
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
304
|
+
describe('runSessionColdBoot — signed out', () => {
|
|
305
|
+
it('reports signed out when the store is empty and no lane resolves (web)', async () => {
|
|
306
|
+
const store = createMemoryAuthStateStore();
|
|
307
|
+
const { oxy, setTokens } = makeOxy();
|
|
308
|
+
const onSession = jest.fn();
|
|
309
|
+
const onSignedOut = jest.fn();
|
|
310
|
+
|
|
311
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSession, onSignedOut });
|
|
312
|
+
|
|
313
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
314
|
+
expect(onSignedOut).toHaveBeenCalledWith('no_session');
|
|
315
|
+
expect(onSession).not.toHaveBeenCalled();
|
|
316
|
+
expect(setTokens).not.toHaveBeenCalled();
|
|
316
317
|
});
|
|
317
318
|
});
|