@oxyhq/core 12.10.6 → 12.11.1
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 +66 -6
- package/dist/cjs/boot/sessionColdBoot.js +142 -37
- package/dist/cjs/i18n/locales/ar-SA.json +13 -1
- package/dist/cjs/i18n/locales/ca-ES.json +13 -1
- package/dist/cjs/i18n/locales/de-DE.json +13 -1
- package/dist/cjs/i18n/locales/en-US.json +13 -4
- package/dist/cjs/i18n/locales/es-ES.json +13 -4
- package/dist/cjs/i18n/locales/fr-FR.json +13 -1
- package/dist/cjs/i18n/locales/it-IT.json +13 -1
- package/dist/cjs/i18n/locales/ja-JP.json +13 -1
- package/dist/cjs/i18n/locales/ko-KR.json +13 -1
- package/dist/cjs/i18n/locales/locales/ar-SA.json +13 -1
- package/dist/cjs/i18n/locales/locales/ca-ES.json +13 -1
- package/dist/cjs/i18n/locales/locales/de-DE.json +13 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +13 -4
- package/dist/cjs/i18n/locales/locales/es-ES.json +13 -4
- package/dist/cjs/i18n/locales/locales/fr-FR.json +13 -1
- package/dist/cjs/i18n/locales/locales/it-IT.json +13 -1
- package/dist/cjs/i18n/locales/locales/ja-JP.json +13 -1
- package/dist/cjs/i18n/locales/locales/ko-KR.json +13 -1
- package/dist/cjs/i18n/locales/locales/pt-PT.json +13 -1
- package/dist/cjs/i18n/locales/locales/zh-CN.json +13 -1
- package/dist/cjs/i18n/locales/pt-PT.json +13 -1
- package/dist/cjs/i18n/locales/zh-CN.json +13 -1
- package/dist/cjs/index.js +31 -5
- package/dist/cjs/mixins/OxyServices.auth.js +257 -17
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +54 -3
- package/dist/cjs/mixins/OxyServices.notifications.js +63 -0
- package/dist/cjs/mixins/OxyServices.privacy.js +6 -0
- package/dist/cjs/mixins/OxyServices.user.js +1 -0
- package/dist/cjs/mixins/index.js +5 -0
- package/dist/cjs/session/SessionClient.js +40 -4
- package/dist/cjs/session/accountDialogController.js +288 -41
- package/dist/cjs/session/identityPin.js +256 -0
- package/dist/cjs/session/identitySession.js +135 -0
- package/dist/cjs/session/projectSessionState.js +54 -15
- package/dist/cjs/session/refresh.js +73 -8
- package/dist/cjs/utils/commonsDelivery.js +59 -0
- package/dist/cjs/utils/oauthPkce.js +33 -3
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +66 -6
- package/dist/esm/boot/sessionColdBoot.js +142 -37
- package/dist/esm/i18n/locales/ar-SA.json +13 -1
- package/dist/esm/i18n/locales/ca-ES.json +13 -1
- package/dist/esm/i18n/locales/de-DE.json +13 -1
- package/dist/esm/i18n/locales/en-US.json +13 -4
- package/dist/esm/i18n/locales/es-ES.json +13 -4
- package/dist/esm/i18n/locales/fr-FR.json +13 -1
- package/dist/esm/i18n/locales/it-IT.json +13 -1
- package/dist/esm/i18n/locales/ja-JP.json +13 -1
- package/dist/esm/i18n/locales/ko-KR.json +13 -1
- package/dist/esm/i18n/locales/locales/ar-SA.json +13 -1
- package/dist/esm/i18n/locales/locales/ca-ES.json +13 -1
- package/dist/esm/i18n/locales/locales/de-DE.json +13 -1
- package/dist/esm/i18n/locales/locales/en-US.json +13 -4
- package/dist/esm/i18n/locales/locales/es-ES.json +13 -4
- package/dist/esm/i18n/locales/locales/fr-FR.json +13 -1
- package/dist/esm/i18n/locales/locales/it-IT.json +13 -1
- package/dist/esm/i18n/locales/locales/ja-JP.json +13 -1
- package/dist/esm/i18n/locales/locales/ko-KR.json +13 -1
- package/dist/esm/i18n/locales/locales/pt-PT.json +13 -1
- package/dist/esm/i18n/locales/locales/zh-CN.json +13 -1
- package/dist/esm/i18n/locales/pt-PT.json +13 -1
- package/dist/esm/i18n/locales/zh-CN.json +13 -1
- package/dist/esm/index.js +14 -1
- package/dist/esm/mixins/OxyServices.auth.js +257 -17
- package/dist/esm/mixins/OxyServices.deviceBoot.js +52 -3
- package/dist/esm/mixins/OxyServices.notifications.js +60 -0
- package/dist/esm/mixins/OxyServices.privacy.js +6 -0
- package/dist/esm/mixins/OxyServices.user.js +1 -0
- package/dist/esm/mixins/index.js +5 -0
- package/dist/esm/session/SessionClient.js +40 -4
- package/dist/esm/session/accountDialogController.js +288 -41
- package/dist/esm/session/identityPin.js +249 -0
- package/dist/esm/session/identitySession.js +131 -0
- package/dist/esm/session/projectSessionState.js +54 -15
- package/dist/esm/session/refresh.js +73 -8
- package/dist/esm/utils/commonsDelivery.js +54 -0
- package/dist/esm/utils/oauthPkce.js +31 -2
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +18 -1
- package/dist/types/boot/sessionColdBoot.d.ts +38 -1
- package/dist/types/index.d.ts +12 -4
- package/dist/types/mixins/OxyServices.auth.d.ts +262 -11
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +31 -1
- package/dist/types/mixins/OxyServices.notifications.d.ts +143 -0
- package/dist/types/mixins/OxyServices.user.d.ts +2 -0
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/session/SessionClient.d.ts +28 -0
- package/dist/types/session/accountDialogController.d.ts +187 -9
- package/dist/types/session/identityPin.d.ts +87 -0
- package/dist/types/session/identitySession.d.ts +105 -0
- package/dist/types/session/projectSessionState.d.ts +21 -19
- package/dist/types/session/refresh.d.ts +32 -1
- package/dist/types/utils/commonsDelivery.d.ts +105 -0
- package/dist/types/utils/oauthPkce.d.ts +21 -1
- package/package.json +3 -3
- package/src/HttpService.ts +67 -6
- package/src/__tests__/inSessionRefresh.test.ts +67 -0
- package/src/boot/__tests__/sessionColdBoot.identity.test.ts +393 -0
- package/src/boot/sessionColdBoot.ts +183 -37
- package/src/i18n/locales/ar-SA.json +13 -1
- package/src/i18n/locales/ca-ES.json +13 -1
- package/src/i18n/locales/de-DE.json +13 -1
- package/src/i18n/locales/en-US.json +13 -4
- package/src/i18n/locales/es-ES.json +13 -4
- package/src/i18n/locales/fr-FR.json +13 -1
- package/src/i18n/locales/it-IT.json +13 -1
- package/src/i18n/locales/ja-JP.json +13 -1
- package/src/i18n/locales/ko-KR.json +13 -1
- package/src/i18n/locales/pt-PT.json +13 -1
- package/src/i18n/locales/zh-CN.json +13 -1
- package/src/index.ts +49 -0
- package/src/mixins/OxyServices.auth.ts +464 -24
- package/src/mixins/OxyServices.deviceBoot.ts +54 -2
- package/src/mixins/OxyServices.notifications.ts +142 -0
- package/src/mixins/OxyServices.privacy.ts +6 -0
- package/src/mixins/OxyServices.user.ts +3 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +754 -2
- package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +113 -0
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +18 -0
- package/src/mixins/__tests__/privacyCacheInvalidation.test.ts +2 -0
- package/src/mixins/index.ts +6 -0
- package/src/session/SessionClient.ts +57 -4
- package/src/session/__tests__/SessionClient.identityPin.test.ts +175 -0
- package/src/session/__tests__/accountDialogController.test.ts +812 -1
- package/src/session/__tests__/identityPin.test.ts +131 -0
- package/src/session/__tests__/identitySession.test.ts +194 -0
- package/src/session/__tests__/projectSessionState.test.ts +88 -0
- package/src/session/__tests__/refresh.identityPin.test.ts +306 -0
- package/src/session/accountDialogController.ts +435 -44
- package/src/session/identityPin.ts +310 -0
- package/src/session/identitySession.ts +206 -0
- package/src/session/projectSessionState.ts +61 -13
- package/src/session/refresh.ts +103 -9
- package/src/utils/__tests__/oauthPkce.test.ts +51 -0
- package/src/utils/commonsDelivery.ts +132 -0
- package/src/utils/oauthPkce.ts +56 -3
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cold boot in IDENTITY mode — the identity vault's boot.
|
|
3
|
+
*
|
|
4
|
+
* Invariants under test:
|
|
5
|
+
* - a persisted warm token that belongs to ANOTHER account is never planted;
|
|
6
|
+
* the boot falls through to the PINNED mint instead;
|
|
7
|
+
* - the mint carries the pinned `accountId`, and the resolved session is the
|
|
8
|
+
* pinned account even while the device is switched elsewhere;
|
|
9
|
+
* - `identity-key-signin` (the PRIMARY local key) replaces `shared-key-signin`
|
|
10
|
+
* (the CROSS-APP shared slot) — and the reverse in account mode;
|
|
11
|
+
* - with no verified pin the (unpinned) mint lane is skipped entirely rather
|
|
12
|
+
* than adopting whichever account the device is currently switched to.
|
|
13
|
+
*/
|
|
14
|
+
import type { DeviceTokenMintResponse } from '@oxyhq/contracts';
|
|
15
|
+
import type { OxyServices } from '../../OxyServices';
|
|
16
|
+
import type { SessionLoginResponse } from '../../models/session';
|
|
17
|
+
import type { AuthChallenge } from '../../crypto/signatureService';
|
|
18
|
+
import { runSessionColdBoot } from '../sessionColdBoot';
|
|
19
|
+
import type { DeviceSecretMintOutcome } from '../../session/refresh';
|
|
20
|
+
import { createMemoryAuthStateStore, type PersistedAuthState } from '../../session/authStateStore';
|
|
21
|
+
import { createMemoryIdentityPinStore, type IdentityPin } from '../../session/identityPin';
|
|
22
|
+
import type { IdentityBinding } from '../../session/identitySession';
|
|
23
|
+
|
|
24
|
+
const PUBLIC_KEY = `02${'a'.repeat(64)}`;
|
|
25
|
+
const OTHER_PUBLIC_KEY = `03${'b'.repeat(64)}`;
|
|
26
|
+
const PIN: IdentityPin = { publicKey: PUBLIC_KEY, accountId: 'vault-user' };
|
|
27
|
+
|
|
28
|
+
const NATIVE = { isWeb: false, isNative: true };
|
|
29
|
+
|
|
30
|
+
/** A minimal jwt-decode-able token whose `userId` claim is `accountId`. */
|
|
31
|
+
function jwtFor(accountId: string): string {
|
|
32
|
+
const payload = Buffer.from(JSON.stringify({ userId: accountId })).toString('base64url');
|
|
33
|
+
return `h.${payload}.s`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Comfortably beyond the 60s refresh lead window. */
|
|
37
|
+
const farFuture = (): string => new Date(Date.now() + 3_600_000).toISOString();
|
|
38
|
+
|
|
39
|
+
function makeMintSingleFlight(): (mint: () => Promise<DeviceSecretMintOutcome>) => Promise<DeviceSecretMintOutcome> {
|
|
40
|
+
let inFlight: Promise<DeviceSecretMintOutcome> | null = null;
|
|
41
|
+
return (mint) => {
|
|
42
|
+
if (!inFlight) {
|
|
43
|
+
inFlight = mint().finally(() => {
|
|
44
|
+
inFlight = null;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return inFlight;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The device is switched to `other-user`; the pinned account is still a member. */
|
|
52
|
+
const MINT_WHILE_SWITCHED: DeviceTokenMintResponse = {
|
|
53
|
+
accessToken: jwtFor('vault-user'),
|
|
54
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
55
|
+
nextDeviceSecret: 'ds-next-secret',
|
|
56
|
+
state: {
|
|
57
|
+
deviceId: 'dev-1',
|
|
58
|
+
accounts: [
|
|
59
|
+
{ accountId: 'vault-user', sessionId: 'sess-vault-new', authuser: 0 },
|
|
60
|
+
{ accountId: 'other-user', sessionId: 'sess-other', authuser: 1 },
|
|
61
|
+
],
|
|
62
|
+
activeAccountId: 'other-user',
|
|
63
|
+
revision: 9,
|
|
64
|
+
updatedAt: 1_700_000_000_000,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const IDENTITY_SESSION: SessionLoginResponse = {
|
|
69
|
+
sessionId: 'sess-identity',
|
|
70
|
+
deviceId: 'dev-1',
|
|
71
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
72
|
+
accessToken: 'access-identity',
|
|
73
|
+
deviceSecret: 'ds-identity',
|
|
74
|
+
user: { id: 'vault-user', username: 'vault', name: {} },
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const SHARED_SESSION: SessionLoginResponse = {
|
|
78
|
+
sessionId: 'sess-shared',
|
|
79
|
+
deviceId: 'dev-1',
|
|
80
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
81
|
+
accessToken: 'access-shared',
|
|
82
|
+
user: { id: 'shared-user', username: 'shared', name: {} },
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
interface OxyOverrides {
|
|
86
|
+
mintFromDeviceSecret?: OxyServices['mintFromDeviceSecret'];
|
|
87
|
+
signInWithSharedIdentity?: OxyServices['signInWithSharedIdentity'];
|
|
88
|
+
requestChallenge?: OxyServices['requestChallenge'];
|
|
89
|
+
verifyChallenge?: OxyServices['verifyChallenge'];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: jest.Mock } {
|
|
93
|
+
const setTokens = jest.fn();
|
|
94
|
+
const oxy = {
|
|
95
|
+
getBaseURL: () => 'https://api.oxy.so',
|
|
96
|
+
setTokens,
|
|
97
|
+
mintFromDeviceSecret:
|
|
98
|
+
overrides.mintFromDeviceSecret
|
|
99
|
+
?? (async () => {
|
|
100
|
+
throw new Error('mintFromDeviceSecret not stubbed');
|
|
101
|
+
}),
|
|
102
|
+
signInWithSharedIdentity: overrides.signInWithSharedIdentity ?? (async () => null),
|
|
103
|
+
requestChallenge:
|
|
104
|
+
overrides.requestChallenge
|
|
105
|
+
?? (async () => ({ challenge: 'chal-1', expiresAt: '2030-01-01T00:00:00.000Z' })),
|
|
106
|
+
verifyChallenge: overrides.verifyChallenge ?? (async () => IDENTITY_SESSION),
|
|
107
|
+
httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight() },
|
|
108
|
+
} as unknown as OxyServices;
|
|
109
|
+
return { oxy, setTokens };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function signWith(publicKey: string): (challenge: string) => Promise<AuthChallenge> {
|
|
113
|
+
return async (challenge) => ({
|
|
114
|
+
challenge: `sig(${challenge})`,
|
|
115
|
+
publicKey,
|
|
116
|
+
timestamp: 1_700_000_000_000,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** An identity binding, optionally pre-seeded with a pin. */
|
|
121
|
+
async function makeBinding(options: { pin?: IdentityPin; publicKey?: string } = {}): Promise<IdentityBinding> {
|
|
122
|
+
const publicKey = options.publicKey ?? PUBLIC_KEY;
|
|
123
|
+
const pinStore = createMemoryIdentityPinStore();
|
|
124
|
+
if (options.pin) {
|
|
125
|
+
await pinStore.save(options.pin);
|
|
126
|
+
}
|
|
127
|
+
return { pinStore, readPublicKey: async () => publicKey, signChallenge: signWith(publicKey) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function seedStore(extra: Partial<PersistedAuthState> = {}) {
|
|
131
|
+
const store = createMemoryAuthStateStore();
|
|
132
|
+
await store.save({
|
|
133
|
+
sessionId: 'sess-vault',
|
|
134
|
+
userId: 'vault-user',
|
|
135
|
+
deviceId: 'dev-1',
|
|
136
|
+
deviceSecret: 'ds-secret-orig',
|
|
137
|
+
...extra,
|
|
138
|
+
});
|
|
139
|
+
return store;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
describe('runSessionColdBoot — identity mode: warm-token-plant', () => {
|
|
143
|
+
it('plants a warm token that belongs to the pinned account', async () => {
|
|
144
|
+
const store = await seedStore({ accessToken: jwtFor('vault-user'), expiresAt: farFuture() });
|
|
145
|
+
const { oxy, setTokens } = makeOxy();
|
|
146
|
+
|
|
147
|
+
const outcome = await runSessionColdBoot({
|
|
148
|
+
oxy,
|
|
149
|
+
store,
|
|
150
|
+
platform: NATIVE,
|
|
151
|
+
sessionMode: 'identity',
|
|
152
|
+
identity: await makeBinding({ pin: PIN }),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'warm-token-plant' });
|
|
156
|
+
expect(setTokens).toHaveBeenCalledWith(jwtFor('vault-user'));
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('does NOT plant a warm token minted for ANOTHER account — falls through to the pinned mint', async () => {
|
|
160
|
+
// The durable drift: an account-mode re-mint rewrote the persisted session to
|
|
161
|
+
// `other-user` while the device was switched. Identity mode must reject it.
|
|
162
|
+
const store = await seedStore({
|
|
163
|
+
sessionId: 'sess-other',
|
|
164
|
+
userId: 'other-user',
|
|
165
|
+
accessToken: jwtFor('other-user'),
|
|
166
|
+
expiresAt: farFuture(),
|
|
167
|
+
});
|
|
168
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT_WHILE_SWITCHED);
|
|
169
|
+
const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
|
|
170
|
+
|
|
171
|
+
const outcome = await runSessionColdBoot({
|
|
172
|
+
oxy,
|
|
173
|
+
store,
|
|
174
|
+
platform: NATIVE,
|
|
175
|
+
sessionMode: 'identity',
|
|
176
|
+
identity: await makeBinding({ pin: PIN }),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(setTokens).not.toHaveBeenCalledWith(jwtFor('other-user'));
|
|
180
|
+
expect(outcome).toMatchObject({
|
|
181
|
+
kind: 'session',
|
|
182
|
+
via: 'device-secret-mint',
|
|
183
|
+
session: { sessionId: 'sess-vault-new', userId: 'vault-user' },
|
|
184
|
+
});
|
|
185
|
+
expect(mintFromDeviceSecret).toHaveBeenCalledWith('dev-1', 'ds-secret-orig', {
|
|
186
|
+
accountId: 'vault-user',
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('does NOT plant a warm token whose persisted identity matches but whose CLAIM does not', async () => {
|
|
191
|
+
// Belt-and-braces: `userId` says the pinned account, the token itself does not.
|
|
192
|
+
const store = await seedStore({ accessToken: jwtFor('other-user'), expiresAt: farFuture() });
|
|
193
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT_WHILE_SWITCHED);
|
|
194
|
+
const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
|
|
195
|
+
|
|
196
|
+
const outcome = await runSessionColdBoot({
|
|
197
|
+
oxy,
|
|
198
|
+
store,
|
|
199
|
+
platform: NATIVE,
|
|
200
|
+
sessionMode: 'identity',
|
|
201
|
+
identity: await makeBinding({ pin: PIN }),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
expect(setTokens).not.toHaveBeenCalledWith(jwtFor('other-user'));
|
|
205
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'device-secret-mint' });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('plants a warm token in ACCOUNT mode regardless of which account it belongs to (unchanged)', async () => {
|
|
209
|
+
const store = await seedStore({
|
|
210
|
+
sessionId: 'sess-other',
|
|
211
|
+
userId: 'other-user',
|
|
212
|
+
accessToken: jwtFor('other-user'),
|
|
213
|
+
expiresAt: farFuture(),
|
|
214
|
+
});
|
|
215
|
+
const { oxy, setTokens } = makeOxy();
|
|
216
|
+
|
|
217
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE });
|
|
218
|
+
|
|
219
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'warm-token-plant' });
|
|
220
|
+
expect(setTokens).toHaveBeenCalledWith(jwtFor('other-user'));
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('runSessionColdBoot — identity mode: device-secret-mint', () => {
|
|
225
|
+
it('skips the mint entirely when there is no verified pin', async () => {
|
|
226
|
+
const store = await seedStore();
|
|
227
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT_WHILE_SWITCHED);
|
|
228
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
229
|
+
|
|
230
|
+
const outcome = await runSessionColdBoot({
|
|
231
|
+
oxy,
|
|
232
|
+
store,
|
|
233
|
+
platform: NATIVE,
|
|
234
|
+
sessionMode: 'identity',
|
|
235
|
+
identity: await makeBinding(), // no pin seeded
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// An unpinned mint would have resolved `other-user` — never acceptable here.
|
|
239
|
+
expect(mintFromDeviceSecret).not.toHaveBeenCalled();
|
|
240
|
+
expect(outcome).toMatchObject({
|
|
241
|
+
kind: 'session',
|
|
242
|
+
via: 'identity-key-signin',
|
|
243
|
+
session: { userId: 'vault-user' },
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('keeps the device secret and re-establishes when the pinned account left the device', async () => {
|
|
248
|
+
const store = await seedStore();
|
|
249
|
+
const mintFromDeviceSecret = jest.fn(async () => {
|
|
250
|
+
throw Object.assign(new Error('account_not_on_device'), { status: 401 });
|
|
251
|
+
});
|
|
252
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
253
|
+
|
|
254
|
+
const outcome = await runSessionColdBoot({
|
|
255
|
+
oxy,
|
|
256
|
+
store,
|
|
257
|
+
platform: NATIVE,
|
|
258
|
+
sessionMode: 'identity',
|
|
259
|
+
identity: await makeBinding({ pin: PIN }),
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'identity-key-signin' });
|
|
263
|
+
// The healthy credential was never dropped over a stale identity binding
|
|
264
|
+
// (the identity sign-in then overwrote it with the freshly minted one).
|
|
265
|
+
expect(await store.load()).toMatchObject({ deviceSecret: 'ds-identity' });
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('clears a pin whose key no longer matches, then re-establishes from the current key', async () => {
|
|
269
|
+
const store = await seedStore();
|
|
270
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT_WHILE_SWITCHED);
|
|
271
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
272
|
+
const pinStore = createMemoryIdentityPinStore();
|
|
273
|
+
await pinStore.save(PIN);
|
|
274
|
+
|
|
275
|
+
const outcome = await runSessionColdBoot({
|
|
276
|
+
oxy,
|
|
277
|
+
store,
|
|
278
|
+
platform: NATIVE,
|
|
279
|
+
sessionMode: 'identity',
|
|
280
|
+
identity: {
|
|
281
|
+
pinStore,
|
|
282
|
+
readPublicKey: async () => OTHER_PUBLIC_KEY,
|
|
283
|
+
signChallenge: signWith(OTHER_PUBLIC_KEY),
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
expect(mintFromDeviceSecret).not.toHaveBeenCalled();
|
|
288
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'identity-key-signin' });
|
|
289
|
+
expect(await pinStore.load()).toEqual({ publicKey: OTHER_PUBLIC_KEY, accountId: 'vault-user' });
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe('runSessionColdBoot — identity-key-signin vs shared-key-signin', () => {
|
|
294
|
+
it('identity mode runs identity-key-signin and NEVER the shared-keychain lane', async () => {
|
|
295
|
+
const store = createMemoryAuthStateStore(); // no mint credential
|
|
296
|
+
const signInWithSharedIdentity = jest.fn(async () => SHARED_SESSION);
|
|
297
|
+
const requestChallenge = jest.fn(async () => ({
|
|
298
|
+
challenge: 'chal-1',
|
|
299
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
300
|
+
}));
|
|
301
|
+
const verifyChallenge = jest.fn(async () => IDENTITY_SESSION);
|
|
302
|
+
const { oxy } = makeOxy({ signInWithSharedIdentity, requestChallenge, verifyChallenge });
|
|
303
|
+
const binding = await makeBinding();
|
|
304
|
+
const onSession = jest.fn();
|
|
305
|
+
|
|
306
|
+
const outcome = await runSessionColdBoot({
|
|
307
|
+
oxy,
|
|
308
|
+
store,
|
|
309
|
+
platform: NATIVE,
|
|
310
|
+
sessionMode: 'identity',
|
|
311
|
+
identity: binding,
|
|
312
|
+
onSession,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
expect(signInWithSharedIdentity).not.toHaveBeenCalled();
|
|
316
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'identity-key-signin' });
|
|
317
|
+
expect(onSession).toHaveBeenCalledWith(
|
|
318
|
+
expect.objectContaining({
|
|
319
|
+
sessionId: 'sess-identity',
|
|
320
|
+
userId: 'vault-user',
|
|
321
|
+
via: 'identity-key-signin',
|
|
322
|
+
}),
|
|
323
|
+
);
|
|
324
|
+
// Boot-path network calls stay single-attempt.
|
|
325
|
+
expect(requestChallenge).toHaveBeenCalledWith(PUBLIC_KEY, { retry: false });
|
|
326
|
+
expect(verifyChallenge).toHaveBeenCalledWith(
|
|
327
|
+
PUBLIC_KEY,
|
|
328
|
+
'chal-1',
|
|
329
|
+
'sig(chal-1)',
|
|
330
|
+
1_700_000_000_000,
|
|
331
|
+
undefined,
|
|
332
|
+
undefined,
|
|
333
|
+
{ retry: false },
|
|
334
|
+
);
|
|
335
|
+
// The pin and the fast-lane credential are both written for the next boot.
|
|
336
|
+
expect(await binding.pinStore.load()).toEqual(PIN);
|
|
337
|
+
expect(await store.load()).toMatchObject({ userId: 'vault-user', deviceSecret: 'ds-identity' });
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('account mode runs shared-key-signin and NEVER the identity lane (unchanged)', async () => {
|
|
341
|
+
const store = createMemoryAuthStateStore();
|
|
342
|
+
const signInWithSharedIdentity = jest.fn(async () => SHARED_SESSION);
|
|
343
|
+
const requestChallenge = jest.fn(async () => ({ challenge: 'c', expiresAt: 'e' }));
|
|
344
|
+
const { oxy } = makeOxy({ signInWithSharedIdentity, requestChallenge });
|
|
345
|
+
|
|
346
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE });
|
|
347
|
+
|
|
348
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'shared-key-signin' });
|
|
349
|
+
expect(signInWithSharedIdentity).toHaveBeenCalledWith({ requestOptions: { retry: false } });
|
|
350
|
+
expect(requestChallenge).not.toHaveBeenCalled();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('is offline-gated like every other network step', async () => {
|
|
354
|
+
const store = createMemoryAuthStateStore();
|
|
355
|
+
const requestChallenge = jest.fn(async () => ({ challenge: 'c', expiresAt: 'e' }));
|
|
356
|
+
const { oxy } = makeOxy({ requestChallenge });
|
|
357
|
+
const onSignedOut = jest.fn();
|
|
358
|
+
|
|
359
|
+
const outcome = await runSessionColdBoot({
|
|
360
|
+
oxy,
|
|
361
|
+
store,
|
|
362
|
+
platform: NATIVE,
|
|
363
|
+
sessionMode: 'identity',
|
|
364
|
+
identity: await makeBinding(),
|
|
365
|
+
isOffline: () => true,
|
|
366
|
+
onSignedOut,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
expect(requestChallenge).not.toHaveBeenCalled();
|
|
370
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
371
|
+
expect(onSignedOut).toHaveBeenCalledWith('no_session');
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('ends signed out (never account-mode) when identity mode is requested without a binding', async () => {
|
|
375
|
+
const store = await seedStore({ accessToken: jwtFor('other-user'), expiresAt: farFuture() });
|
|
376
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT_WHILE_SWITCHED);
|
|
377
|
+
const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
|
|
378
|
+
const onSignedOut = jest.fn();
|
|
379
|
+
|
|
380
|
+
const outcome = await runSessionColdBoot({
|
|
381
|
+
oxy,
|
|
382
|
+
store,
|
|
383
|
+
platform: NATIVE,
|
|
384
|
+
sessionMode: 'identity',
|
|
385
|
+
onSignedOut,
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
389
|
+
expect(onSignedOut).toHaveBeenCalledWith('error');
|
|
390
|
+
expect(setTokens).not.toHaveBeenCalled();
|
|
391
|
+
expect(mintFromDeviceSecret).not.toHaveBeenCalled();
|
|
392
|
+
});
|
|
393
|
+
});
|
|
@@ -15,18 +15,45 @@
|
|
|
15
15
|
* origin persisted a `deviceId` + `deviceSecret`, mint a short access token
|
|
16
16
|
* with a single bearer-less POST to `/session/device/token` (no cookie, no
|
|
17
17
|
* navigation) and rotate the secret in-use.
|
|
18
|
-
* 3. `shared-key-signin` (native) — re-mint from the
|
|
18
|
+
* 3. `shared-key-signin` (native, ACCOUNT mode) — re-mint from the
|
|
19
|
+
* shared-keychain identity — OR `identity-key-signin` (IDENTITY mode) —
|
|
20
|
+
* re-mint from THIS device's primary identity key.
|
|
19
21
|
* 4. Signed out.
|
|
20
22
|
*
|
|
23
|
+
* Two session modes (see {@link RunSessionColdBootOptions.sessionMode}):
|
|
24
|
+
* - `account` (default) — the device's ACTIVE account owns the session. Every
|
|
25
|
+
* Oxy app but the identity vault boots this way; behaviour is unchanged.
|
|
26
|
+
* - `identity` — the owner of the local PRIMARY identity key owns the session,
|
|
27
|
+
* permanently, regardless of which account the device is switched to. Each
|
|
28
|
+
* step above is bound to the persisted identity pin, and the shared-keychain
|
|
29
|
+
* lane is replaced by the primary-key one (the shared slot is a CROSS-APP
|
|
30
|
+
* slot that may hold a different identity).
|
|
31
|
+
*
|
|
21
32
|
* ESM-safe (no `require()`); no react/react-native/expo imports.
|
|
22
33
|
*/
|
|
23
34
|
import { runColdBoot, type ColdBootOutcome, type ColdBootStep } from '../utils/coldBoot';
|
|
24
35
|
import { isNative as detectNative } from '../utils/platform';
|
|
25
36
|
import { logger } from '../logger';
|
|
37
|
+
import { computeIdentityTag } from '../utils/cacheKey';
|
|
26
38
|
import { TOKEN_REFRESH_LEAD_MS, refreshDeviceSecretArm } from '../session/refresh';
|
|
39
|
+
import {
|
|
40
|
+
establishIdentitySession,
|
|
41
|
+
resolveIdentityPin,
|
|
42
|
+
type IdentityBinding,
|
|
43
|
+
} from '../session/identitySession';
|
|
44
|
+
import type { IdentityPin } from '../session/identityPin';
|
|
27
45
|
import type { OxyServices } from '../OxyServices';
|
|
28
46
|
import type { AuthStateStore } from '../session/authStateStore';
|
|
29
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Who owns the session this boot resolves.
|
|
50
|
+
*
|
|
51
|
+
* - `account` — the device's active account (every ordinary Oxy app).
|
|
52
|
+
* - `identity` — the owner of the device's primary identity key (the identity
|
|
53
|
+
* vault). Requires {@link RunSessionColdBootOptions.identity}.
|
|
54
|
+
*/
|
|
55
|
+
export type SessionMode = 'account' | 'identity';
|
|
56
|
+
|
|
30
57
|
/** The winning session shape a cold-boot step reports. */
|
|
31
58
|
export interface DeviceBootSession {
|
|
32
59
|
sessionId: string;
|
|
@@ -72,6 +99,23 @@ export interface RunSessionColdBootOptions {
|
|
|
72
99
|
* so a flaky probe can never falsely skip a real sign-in.
|
|
73
100
|
*/
|
|
74
101
|
isOffline?: () => boolean;
|
|
102
|
+
/**
|
|
103
|
+
* Who owns the resolved session. Defaults to `'account'` — the device's active
|
|
104
|
+
* account — which is the behaviour every ordinary Oxy app has today and which
|
|
105
|
+
* this option leaves byte-for-byte unchanged.
|
|
106
|
+
*
|
|
107
|
+
* `'identity'` binds the boot to the owner of this device's PRIMARY identity
|
|
108
|
+
* key and REQUIRES {@link identity}. When it is missing the boot refuses to
|
|
109
|
+
* run any lane (an identity-bound client silently falling back to the device's
|
|
110
|
+
* active account is precisely the bug this mode exists to prevent) and
|
|
111
|
+
* resolves signed out.
|
|
112
|
+
*/
|
|
113
|
+
sessionMode?: SessionMode;
|
|
114
|
+
/**
|
|
115
|
+
* The identity binding (pin store + key/signature access) used by
|
|
116
|
+
* `sessionMode: 'identity'`. Ignored in `'account'` mode.
|
|
117
|
+
*/
|
|
118
|
+
identity?: IdentityBinding;
|
|
75
119
|
}
|
|
76
120
|
|
|
77
121
|
/**
|
|
@@ -85,6 +129,19 @@ export async function runSessionColdBoot(
|
|
|
85
129
|
const { oxy, store } = opts;
|
|
86
130
|
const isNative = opts.platform?.isNative ?? detectNative();
|
|
87
131
|
|
|
132
|
+
// Non-null ONLY in identity mode; every `!== null` test below therefore reads
|
|
133
|
+
// as "is this boot identity-bound?" while also narrowing the binding.
|
|
134
|
+
const identityBinding = opts.sessionMode === 'identity' ? opts.identity ?? null : null;
|
|
135
|
+
if (opts.sessionMode === 'identity' && identityBinding === null) {
|
|
136
|
+
logger.error(
|
|
137
|
+
'runSessionColdBoot: sessionMode "identity" requires an `identity` binding — refusing to run any lane (an identity-bound client must never adopt the device active account)',
|
|
138
|
+
undefined,
|
|
139
|
+
{ component: 'sessionColdBoot', method: 'runSessionColdBoot' },
|
|
140
|
+
);
|
|
141
|
+
await opts.onSignedOut?.('error');
|
|
142
|
+
return { kind: 'unauthenticated' };
|
|
143
|
+
}
|
|
144
|
+
|
|
88
145
|
// Best-effort connectivity gate for the NETWORK steps only. A missing hint or
|
|
89
146
|
// any non-`true` verdict means "assume online" — never falsely skip a real
|
|
90
147
|
// sign-in on an ambiguous probe.
|
|
@@ -94,6 +151,23 @@ export async function runSessionColdBoot(
|
|
|
94
151
|
// bundler re-evaluation.
|
|
95
152
|
let signedOutReason: SignedOutReason = 'no_session';
|
|
96
153
|
|
|
154
|
+
// Boot-local memo for the identity pin. Resolved lazily INSIDE a step so the
|
|
155
|
+
// (local, but storage-backed) read is covered by `overallDeadlineMs`, and
|
|
156
|
+
// resolved at most once per boot so two steps cannot disagree. Always `null`
|
|
157
|
+
// in account mode.
|
|
158
|
+
let pinResolved = false;
|
|
159
|
+
let cachedPin: IdentityPin | null = null;
|
|
160
|
+
const getIdentityPin = async (): Promise<IdentityPin | null> => {
|
|
161
|
+
if (identityBinding === null) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
if (!pinResolved) {
|
|
165
|
+
cachedPin = await resolveIdentityPin(identityBinding);
|
|
166
|
+
pinResolved = true;
|
|
167
|
+
}
|
|
168
|
+
return cachedPin;
|
|
169
|
+
};
|
|
170
|
+
|
|
97
171
|
const steps: Array<ColdBootStep<DeviceBootSession>> = [];
|
|
98
172
|
|
|
99
173
|
// 1. warm-token-plant (web + native) — the fastest path. When the persisted
|
|
@@ -122,6 +196,21 @@ export async function runSessionColdBoot(
|
|
|
122
196
|
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now() + TOKEN_REFRESH_LEAD_MS) {
|
|
123
197
|
return { kind: 'skip' };
|
|
124
198
|
}
|
|
199
|
+
if (identityBinding !== null) {
|
|
200
|
+
// Identity-bound: a persisted token is only acceptable when it belongs to
|
|
201
|
+
// the PINNED account. Both the token's own claim (`computeIdentityTag`,
|
|
202
|
+
// the same derivation `SessionClient.applyState` uses) and the owning
|
|
203
|
+
// session identity must match — a token planted for another account by an
|
|
204
|
+
// earlier account-mode write is exactly the durable drift this gate stops.
|
|
205
|
+
const pin = await getIdentityPin();
|
|
206
|
+
if (
|
|
207
|
+
pin === null ||
|
|
208
|
+
persisted.userId !== pin.accountId ||
|
|
209
|
+
computeIdentityTag(persisted.accessToken) !== pin.accountId
|
|
210
|
+
) {
|
|
211
|
+
return { kind: 'skip' };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
125
214
|
oxy.setTokens(persisted.accessToken);
|
|
126
215
|
return {
|
|
127
216
|
kind: 'session',
|
|
@@ -148,7 +237,16 @@ export async function runSessionColdBoot(
|
|
|
148
237
|
// a doomed mint cannot burn the overall deadline before routing settles.
|
|
149
238
|
enabled: () => !isOffline(),
|
|
150
239
|
run: async () => {
|
|
151
|
-
const
|
|
240
|
+
const pin = await getIdentityPin();
|
|
241
|
+
if (identityBinding !== null && pin === null) {
|
|
242
|
+
// Identity-bound with no verified pin (never written, or cleared because
|
|
243
|
+
// the local key changed/vanished). An UNPINNED mint would adopt whichever
|
|
244
|
+
// account the device is currently switched to — the exact drift this mode
|
|
245
|
+
// exists to prevent. Fall through to `identity-key-signin`, which
|
|
246
|
+
// re-derives the account from the local key and rewrites the pin.
|
|
247
|
+
return { kind: 'skip' };
|
|
248
|
+
}
|
|
249
|
+
const result = await refreshDeviceSecretArm({ oxy, store, pin });
|
|
152
250
|
switch (result.status) {
|
|
153
251
|
case 'ok':
|
|
154
252
|
// The arm persisted the rotated secret and planted the token.
|
|
@@ -186,6 +284,15 @@ export async function runSessionColdBoot(
|
|
|
186
284
|
{ component: 'sessionColdBoot', method: 'device-secret-mint' },
|
|
187
285
|
);
|
|
188
286
|
return { kind: 'skip' };
|
|
287
|
+
case 'account-not-on-device':
|
|
288
|
+
// Pinned mint rejected: the pinned account is no longer a live account
|
|
289
|
+
// of this device session. The secret is healthy — keep it untouched and
|
|
290
|
+
// let `identity-key-signin` re-establish from the local key.
|
|
291
|
+
logger.debug(
|
|
292
|
+
'pinned device-secret mint rejected (account_not_on_device) — keeping secret, re-establishing from the identity key',
|
|
293
|
+
{ component: 'sessionColdBoot', method: 'device-secret-mint' },
|
|
294
|
+
);
|
|
295
|
+
return { kind: 'skip' };
|
|
189
296
|
case 'transient':
|
|
190
297
|
// Network / 5xx: keep the secret; a later attempt can succeed.
|
|
191
298
|
logger.debug(
|
|
@@ -199,42 +306,81 @@ export async function runSessionColdBoot(
|
|
|
199
306
|
},
|
|
200
307
|
});
|
|
201
308
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
await
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
accessToken: session.accessToken,
|
|
225
|
-
expiresAt: session.expiresAt,
|
|
309
|
+
if (identityBinding !== null) {
|
|
310
|
+
// 3-identity. identity-key-signin — re-mint from THIS device's PRIMARY
|
|
311
|
+
// identity key (`getPublicKey` → challenge → sign → verify). It REPLACES
|
|
312
|
+
// `shared-key-signin`, which reads the CROSS-APP shared keychain slot and
|
|
313
|
+
// may therefore hold a different identity than the device's primary — the
|
|
314
|
+
// one thing an identity-bound client must never adopt. The server resolves
|
|
315
|
+
// the account from the verified signer, so the winning session is
|
|
316
|
+
// identity-authoritative and the pin is (re)written from it.
|
|
317
|
+
//
|
|
318
|
+
// Online-gated like every network step; `{ retry: false }` keeps the two
|
|
319
|
+
// round-trips single attempts (the refresh scheduler / 401 lane own later
|
|
320
|
+
// retries) so this step cannot multiply boot latency. On web
|
|
321
|
+
// `KeyManager.getPublicKey()` resolves to `null`, so it skips.
|
|
322
|
+
steps.push({
|
|
323
|
+
id: 'identity-key-signin',
|
|
324
|
+
enabled: () => !isOffline(),
|
|
325
|
+
run: async () => {
|
|
326
|
+
const established = await establishIdentitySession({
|
|
327
|
+
oxy,
|
|
328
|
+
store,
|
|
329
|
+
binding: identityBinding,
|
|
330
|
+
requestOptions: { retry: false },
|
|
226
331
|
});
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
332
|
+
const accessToken = established?.session.accessToken;
|
|
333
|
+
if (!established || !accessToken) {
|
|
334
|
+
return { kind: 'skip' };
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
kind: 'session',
|
|
338
|
+
session: {
|
|
339
|
+
sessionId: established.session.sessionId,
|
|
340
|
+
userId: established.session.user.id,
|
|
341
|
+
accessToken,
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
} else {
|
|
347
|
+
// 3. shared-key-signin (native) — re-mint from the shared identity. Native
|
|
348
|
+
// AND online: it is a network step (challenge + verify round-trips), so it
|
|
349
|
+
// is gated by the same offline hint as the mint lane. `{ retry: false }`
|
|
350
|
+
// keeps the two round-trips as single attempts — the refresh scheduler /
|
|
351
|
+
// 401 lane own later retries — so this step cannot multiply boot latency.
|
|
352
|
+
steps.push({
|
|
353
|
+
id: 'shared-key-signin',
|
|
354
|
+
enabled: () => isNative && !isOffline(),
|
|
355
|
+
run: async () => {
|
|
356
|
+
const session = await oxy.signInWithSharedIdentity({ requestOptions: { retry: false } });
|
|
357
|
+
if (!session?.accessToken) {
|
|
358
|
+
return { kind: 'skip' };
|
|
359
|
+
}
|
|
360
|
+
// `verifyChallenge` mints a rotating deviceSecret; persist it so the next
|
|
361
|
+
// boot can use the faster device-secret-mint lane (sockets + tab-focus
|
|
362
|
+
// re-mint depend on the credential being in the store).
|
|
363
|
+
if (session.deviceId && session.deviceSecret) {
|
|
364
|
+
await store.save({
|
|
365
|
+
sessionId: session.sessionId,
|
|
366
|
+
userId: session.user.id,
|
|
367
|
+
deviceId: session.deviceId,
|
|
368
|
+
deviceSecret: session.deviceSecret,
|
|
369
|
+
accessToken: session.accessToken,
|
|
370
|
+
expiresAt: session.expiresAt,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
kind: 'session',
|
|
375
|
+
session: {
|
|
376
|
+
sessionId: session.sessionId,
|
|
377
|
+
userId: session.user.id,
|
|
378
|
+
accessToken: session.accessToken,
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
}
|
|
238
384
|
|
|
239
385
|
const outcome = await runColdBoot<DeviceBootSession>({
|
|
240
386
|
steps,
|