@oxyhq/core 9.2.1 → 9.2.3
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 +27 -0
- package/dist/cjs/boot/sessionColdBoot.js +83 -53
- package/dist/cjs/crypto/keyManager.js +45 -12
- package/dist/cjs/index.js +2 -1
- package/dist/cjs/mixins/OxyServices.utility.js +9 -5
- package/dist/cjs/session/SessionClient.js +54 -4
- package/dist/cjs/session/accountDialogController.js +30 -0
- package/dist/cjs/session/authStateStore.js +204 -16
- package/dist/cjs/session/refresh.js +110 -37
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +27 -0
- package/dist/esm/boot/sessionColdBoot.js +83 -53
- package/dist/esm/crypto/keyManager.js +46 -13
- package/dist/esm/index.js +1 -1
- package/dist/esm/mixins/OxyServices.utility.js +9 -5
- package/dist/esm/session/SessionClient.js +54 -4
- package/dist/esm/session/accountDialogController.js +30 -0
- package/dist/esm/session/authStateStore.js +203 -15
- package/dist/esm/session/refresh.js +109 -37
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +21 -0
- package/dist/types/boot/sessionColdBoot.d.ts +7 -3
- package/dist/types/index.d.ts +3 -3
- package/dist/types/session/SessionClient.d.ts +37 -4
- package/dist/types/session/accountDialogController.d.ts +17 -0
- package/dist/types/session/authStateStore.d.ts +48 -9
- package/dist/types/session/refresh.d.ts +67 -31
- package/package.json +2 -2
- package/src/HttpService.ts +31 -0
- package/src/boot/__tests__/sessionColdBoot.test.ts +119 -0
- package/src/boot/sessionColdBoot.ts +93 -74
- package/src/crypto/keyManager.ts +42 -15
- package/src/index.ts +3 -2
- package/src/mixins/OxyServices.utility.ts +10 -9
- package/src/session/SessionClient.ts +79 -7
- package/src/session/__tests__/SessionClient.additive.test.ts +58 -1
- package/src/session/__tests__/SessionClient.serverEvents.test.ts +71 -0
- package/src/session/__tests__/SessionClient.socket.test.ts +32 -0
- package/src/session/__tests__/accountDialogController.test.ts +85 -0
- package/src/session/__tests__/authStateStore.test.ts +232 -4
- package/src/session/__tests__/refresh.test.ts +141 -2
- package/src/session/accountDialogController.ts +45 -0
- package/src/session/authStateStore.ts +242 -16
- package/src/session/refresh.ts +146 -40
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { DeviceSessionState } from '@oxyhq/contracts';
|
|
2
|
+
import type { MinimalSocket, SocketIOFactory } from '../socketLoader';
|
|
3
|
+
import { SessionClient, type SessionClientHost } from '../SessionClient';
|
|
4
|
+
|
|
5
|
+
type Handler = (...args: unknown[]) => void;
|
|
6
|
+
class FakeSocket implements MinimalSocket {
|
|
7
|
+
connected = false;
|
|
8
|
+
handlers = new Map<string, Handler[]>();
|
|
9
|
+
on(event: string, cb: Handler) { const l = this.handlers.get(event) ?? []; l.push(cb); this.handlers.set(event, l); }
|
|
10
|
+
off(event: string, cb?: Handler) { if (!cb) { this.handlers.delete(event); return; } this.handlers.set(event, (this.handlers.get(event) ?? []).filter((h) => h !== cb)); }
|
|
11
|
+
connect() { this.connected = true; }
|
|
12
|
+
disconnect() { this.connected = false; }
|
|
13
|
+
emitServer(event: string, payload: unknown) { for (const h of this.handlers.get(event) ?? []) h(payload); }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const STATE = (rev: number): DeviceSessionState => ({ deviceId: 'd1', accounts: [{ accountId: 'a1', sessionId: 's1', authuser: 0 }], activeAccountId: 'a1', revision: rev, updatedAt: 1720000000000 });
|
|
17
|
+
const SYNC = (rev: number) => ({ state: STATE(rev), activeToken: { accessToken: `jwt-${rev}`, expiresAt: 'x' } });
|
|
18
|
+
|
|
19
|
+
function makeHost(over: Partial<SessionClientHost> = {}): SessionClientHost {
|
|
20
|
+
return {
|
|
21
|
+
makeRequest: jest.fn().mockResolvedValue(SYNC(1)),
|
|
22
|
+
getBaseURL: () => 'http://test.invalid',
|
|
23
|
+
getAccessToken: () => 'tok',
|
|
24
|
+
getDeviceCredential: () => null,
|
|
25
|
+
onTokensChanged: () => () => undefined,
|
|
26
|
+
setTokens: jest.fn(),
|
|
27
|
+
getCurrentAccountId: () => 'a1',
|
|
28
|
+
...over,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('SessionClient.onServerEvent', () => {
|
|
33
|
+
it('delivers a server event to a listener registered BEFORE the socket exists', async () => {
|
|
34
|
+
let created: FakeSocket | null = null;
|
|
35
|
+
const factory: SocketIOFactory = jest.fn(() => { created = new FakeSocket(); created.connected = true; return created; });
|
|
36
|
+
const client = new SessionClient(makeHost(), { socketFactory: factory });
|
|
37
|
+
const seen: unknown[] = [];
|
|
38
|
+
client.onServerEvent('civic:attested', (p) => seen.push(p));
|
|
39
|
+
await client.start();
|
|
40
|
+
created?.emitServer('civic:attested', { byUserId: 'u2' });
|
|
41
|
+
expect(seen).toEqual([{ byUserId: 'u2' }]);
|
|
42
|
+
client.stop();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('delivers to a listener registered AFTER the socket exists, and unsubscribe stops delivery', async () => {
|
|
46
|
+
let created: FakeSocket | null = null;
|
|
47
|
+
const factory: SocketIOFactory = jest.fn(() => { created = new FakeSocket(); created.connected = true; return created; });
|
|
48
|
+
const client = new SessionClient(makeHost(), { socketFactory: factory });
|
|
49
|
+
await client.start();
|
|
50
|
+
const seen: unknown[] = [];
|
|
51
|
+
const unsub = client.onServerEvent('civic:attested', (p) => seen.push(p));
|
|
52
|
+
created?.emitServer('civic:attested', 1);
|
|
53
|
+
unsub();
|
|
54
|
+
created?.emitServer('civic:attested', 2);
|
|
55
|
+
expect(seen).toEqual([1]);
|
|
56
|
+
client.stop();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('one listener throwing does not break the others', async () => {
|
|
60
|
+
let created: FakeSocket | null = null;
|
|
61
|
+
const factory: SocketIOFactory = jest.fn(() => { created = new FakeSocket(); created.connected = true; return created; });
|
|
62
|
+
const client = new SessionClient(makeHost(), { socketFactory: factory });
|
|
63
|
+
await client.start();
|
|
64
|
+
const seen: unknown[] = [];
|
|
65
|
+
client.onServerEvent('civic:attested', () => { throw new Error('boom'); });
|
|
66
|
+
client.onServerEvent('civic:attested', (p) => seen.push(p));
|
|
67
|
+
created?.emitServer('civic:attested', 'ok');
|
|
68
|
+
expect(seen).toEqual(['ok']);
|
|
69
|
+
client.stop();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -131,4 +131,36 @@ describe('SessionClient socket', () => {
|
|
|
131
131
|
c.stop();
|
|
132
132
|
expect(fakeSocket.connected).toBe(false);
|
|
133
133
|
});
|
|
134
|
+
|
|
135
|
+
it('a socket-pushed empty state fires onUnauthenticated with the PUSH origin (bug #4)', async () => {
|
|
136
|
+
const onUnauthenticated = jest.fn();
|
|
137
|
+
const c = new SessionClient(makeHost(), { onUnauthenticated });
|
|
138
|
+
await c.start();
|
|
139
|
+
|
|
140
|
+
const EMPTY: DeviceSessionState = { deviceId: 'd1', accounts: [], activeAccountId: null, revision: 9, updatedAt: 1720000000001 };
|
|
141
|
+
fakeSocket.trigger('session_state', EMPTY);
|
|
142
|
+
|
|
143
|
+
expect(onUnauthenticated).toHaveBeenCalledWith('push');
|
|
144
|
+
c.stop();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('a REST signOut-all empty response fires onUnauthenticated with the REQUEST origin', async () => {
|
|
148
|
+
const onUnauthenticated = jest.fn();
|
|
149
|
+
const EMPTY_SYNC = {
|
|
150
|
+
state: { deviceId: 'd1', accounts: [], activeAccountId: null, revision: 9, updatedAt: 1720000000001 },
|
|
151
|
+
activeToken: null,
|
|
152
|
+
};
|
|
153
|
+
// bootstrap during start() returns a populated state; the signout (and any
|
|
154
|
+
// stray reconcile) returns empty.
|
|
155
|
+
const makeRequest = jest.fn().mockResolvedValue(EMPTY_SYNC).mockResolvedValueOnce(SYNC(1));
|
|
156
|
+
const c = new SessionClient(makeHost({ makeRequest }), { onUnauthenticated });
|
|
157
|
+
await c.start();
|
|
158
|
+
|
|
159
|
+
await c.signOut({ all: true });
|
|
160
|
+
// Flush any fire-and-forget post-commit reconcile before asserting/teardown.
|
|
161
|
+
await Promise.resolve();
|
|
162
|
+
|
|
163
|
+
expect(onUnauthenticated).toHaveBeenCalledWith('request');
|
|
164
|
+
c.stop();
|
|
165
|
+
});
|
|
134
166
|
});
|
|
@@ -540,6 +540,91 @@ describe('AccountDialogController — sign in with Oxy', () => {
|
|
|
540
540
|
});
|
|
541
541
|
});
|
|
542
542
|
|
|
543
|
+
describe('AccountDialogController — Commons deep-link (canOpenApp)', () => {
|
|
544
|
+
const START_HANDLE = {
|
|
545
|
+
sessionToken: 'secret-tok',
|
|
546
|
+
authorizeCode: 'AUTH-CODE',
|
|
547
|
+
qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
|
|
548
|
+
expiresAt: Date.now() + 600_000,
|
|
549
|
+
status: 'pending' as const,
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
function makeController(opts: {
|
|
553
|
+
openUrl?: jest.Mock;
|
|
554
|
+
canOpenApp?: jest.Mock;
|
|
555
|
+
}): { controller: AccountDialogController; oxy: OxyMock } {
|
|
556
|
+
const oxy = makeOxy();
|
|
557
|
+
oxy.startCommonsSignIn.mockResolvedValue(START_HANDLE);
|
|
558
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
|
|
559
|
+
const controller = new AccountDialogController({
|
|
560
|
+
oxyServices: oxy as unknown as OxyServices,
|
|
561
|
+
sessionClient: new TestSessionClient(host()),
|
|
562
|
+
clientId: 'oxy_dk_test',
|
|
563
|
+
pollIntervalMs: 1000,
|
|
564
|
+
openUrl: opts.openUrl,
|
|
565
|
+
canOpenApp: opts.canOpenApp,
|
|
566
|
+
});
|
|
567
|
+
return { controller, oxy };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
it('deep-links into Commons via openUrl when canOpenApp reports it installed, keeping the QR/polling fallback', async () => {
|
|
571
|
+
const openUrl = jest.fn();
|
|
572
|
+
const canOpenApp = jest.fn().mockResolvedValue(true);
|
|
573
|
+
const { controller } = makeController({ openUrl, canOpenApp });
|
|
574
|
+
|
|
575
|
+
await controller.showQr();
|
|
576
|
+
await flush(); // let the (non-awaited) canOpenApp probe resolve
|
|
577
|
+
|
|
578
|
+
expect(canOpenApp).toHaveBeenCalledWith('oxycommons://');
|
|
579
|
+
expect(openUrl).toHaveBeenCalledWith('oxycommons://approve?v=1&code=AUTH-CODE');
|
|
580
|
+
// The QR + polling remain the fallback path — the flow is still waiting.
|
|
581
|
+
const snap = controller.getSnapshot();
|
|
582
|
+
expect(snap.view).toBe('qr');
|
|
583
|
+
expect(snap.signIn.phase).toBe('waiting');
|
|
584
|
+
expect(snap.signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
|
|
585
|
+
controller.cancelSignIn();
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it('does NOT open Commons when canOpenApp reports it absent (renders QR only)', async () => {
|
|
589
|
+
const openUrl = jest.fn();
|
|
590
|
+
const canOpenApp = jest.fn().mockResolvedValue(false);
|
|
591
|
+
const { controller } = makeController({ openUrl, canOpenApp });
|
|
592
|
+
|
|
593
|
+
await controller.showQr();
|
|
594
|
+
await flush();
|
|
595
|
+
|
|
596
|
+
expect(canOpenApp).toHaveBeenCalledWith('oxycommons://');
|
|
597
|
+
expect(openUrl).not.toHaveBeenCalled();
|
|
598
|
+
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
599
|
+
controller.cancelSignIn();
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('never probes or opens when canOpenApp is absent (web — unchanged behavior)', async () => {
|
|
603
|
+
const openUrl = jest.fn();
|
|
604
|
+
const { controller } = makeController({ openUrl });
|
|
605
|
+
|
|
606
|
+
await controller.showQr();
|
|
607
|
+
await flush();
|
|
608
|
+
|
|
609
|
+
expect(openUrl).not.toHaveBeenCalled();
|
|
610
|
+
expect(controller.getSnapshot().signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
|
|
611
|
+
controller.cancelSignIn();
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it('swallows a canOpenApp probe rejection and keeps the QR fallback', async () => {
|
|
615
|
+
const openUrl = jest.fn();
|
|
616
|
+
const canOpenApp = jest.fn().mockRejectedValue(new Error('probe boom'));
|
|
617
|
+
const { controller } = makeController({ openUrl, canOpenApp });
|
|
618
|
+
|
|
619
|
+
await controller.showQr();
|
|
620
|
+
await flush();
|
|
621
|
+
|
|
622
|
+
expect(openUrl).not.toHaveBeenCalled();
|
|
623
|
+
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
624
|
+
controller.cancelSignIn();
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
|
|
543
628
|
describe('AccountDialogController — openPasswordAtOxyAuth', () => {
|
|
544
629
|
beforeEach(() => {
|
|
545
630
|
const store = new Map<string, string>();
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
createNativeAuthStateStore,
|
|
4
4
|
createMemoryAuthStateStore,
|
|
5
5
|
AUTH_STATE_STORAGE_KEY,
|
|
6
|
+
AUTH_STATE_TOKEN_STORAGE_KEY,
|
|
6
7
|
type PersistedAuthState,
|
|
7
8
|
type NativeKeyValueStorage,
|
|
8
9
|
} from '../authStateStore';
|
|
@@ -133,7 +134,8 @@ describe('createWebAuthStateStore', () => {
|
|
|
133
134
|
|
|
134
135
|
// Construction must not throw, and the store must still function (in-memory).
|
|
135
136
|
const store = createWebAuthStateStore();
|
|
136
|
-
|
|
137
|
+
// A degraded in-memory store IS its own durability backing → reports `true`.
|
|
138
|
+
await expect(store.save(SAMPLE)).resolves.toBe(true);
|
|
137
139
|
expect(await store.load()).toEqual(SAMPLE);
|
|
138
140
|
});
|
|
139
141
|
|
|
@@ -142,13 +144,46 @@ describe('createWebAuthStateStore', () => {
|
|
|
142
144
|
const store = createWebAuthStateStore();
|
|
143
145
|
const withCreds: PersistedAuthState = { ...SAMPLE, deviceId: 'dev-abc', deviceSecret: 'ds-secret-xyz' };
|
|
144
146
|
|
|
145
|
-
|
|
147
|
+
// The durable write threw → the store reports the persist did NOT land.
|
|
148
|
+
await expect(store.save(withCreds)).resolves.toBe(false);
|
|
146
149
|
// The write never reached storage...
|
|
147
150
|
expect(localStorage.getItem(AUTH_STATE_STORAGE_KEY)).toBeNull();
|
|
148
151
|
// ...but the in-memory mirror keeps the session (incl. the mint credential) live.
|
|
149
152
|
expect(await store.load()).toEqual(withCreds);
|
|
150
153
|
});
|
|
151
154
|
|
|
155
|
+
it('reports false when the durable write SILENTLY no-ops (read-back mismatch, no throw)', async () => {
|
|
156
|
+
// A backing store whose setItem neither throws nor persists — the exact
|
|
157
|
+
// failure the read-back guards. The mirror keeps the session live, but the
|
|
158
|
+
// credential did not land, so save() must report false.
|
|
159
|
+
const map = new Map<string, string>();
|
|
160
|
+
const storage = {
|
|
161
|
+
getItem: (k: string) => map.get(k) ?? null,
|
|
162
|
+
setItem: (k: string, v: string) => {
|
|
163
|
+
// Only the WARM key writes; the durable credential silently vanishes.
|
|
164
|
+
if (k === AUTH_STATE_TOKEN_STORAGE_KEY) map.set(k, v);
|
|
165
|
+
},
|
|
166
|
+
removeItem: (k: string) => {
|
|
167
|
+
map.delete(k);
|
|
168
|
+
},
|
|
169
|
+
clear: () => map.clear(),
|
|
170
|
+
key: (i: number) => Array.from(map.keys())[i] ?? null,
|
|
171
|
+
get length() {
|
|
172
|
+
return map.size;
|
|
173
|
+
},
|
|
174
|
+
} as Storage;
|
|
175
|
+
installLocalStorage(storage);
|
|
176
|
+
const store = createWebAuthStateStore();
|
|
177
|
+
|
|
178
|
+
await expect(
|
|
179
|
+
store.save({ ...SAMPLE, deviceId: 'dev-x', deviceSecret: 'ds-x' }),
|
|
180
|
+
).resolves.toBe(false);
|
|
181
|
+
// The durable blob never landed…
|
|
182
|
+
expect(storage.getItem(AUTH_STATE_STORAGE_KEY)).toBeNull();
|
|
183
|
+
// …but the mirror still serves the session for this page's lifetime.
|
|
184
|
+
expect(await store.load()).toMatchObject({ deviceId: 'dev-x', deviceSecret: 'ds-x' });
|
|
185
|
+
});
|
|
186
|
+
|
|
152
187
|
it('a cleared session reads null even if storage later holds a stale blob (mirror wins)', async () => {
|
|
153
188
|
const storage = makeFakeStorage();
|
|
154
189
|
installLocalStorage(storage);
|
|
@@ -161,6 +196,103 @@ describe('createWebAuthStateStore', () => {
|
|
|
161
196
|
// The authoritative in-memory mirror still reports the cleared state.
|
|
162
197
|
expect(await store.load()).toBeNull();
|
|
163
198
|
});
|
|
199
|
+
|
|
200
|
+
it('splits the token into the warm key and keeps the durable blob token-free', async () => {
|
|
201
|
+
const storage = makeFakeStorage();
|
|
202
|
+
installLocalStorage(storage);
|
|
203
|
+
const store = createWebAuthStateStore();
|
|
204
|
+
|
|
205
|
+
await store.save({ ...SAMPLE, deviceId: 'dev-1', deviceSecret: 'ds-1' });
|
|
206
|
+
|
|
207
|
+
// Durable key holds ONLY the small mint-critical fields — never the JWT.
|
|
208
|
+
const durableRaw = storage.getItem(AUTH_STATE_STORAGE_KEY);
|
|
209
|
+
expect(durableRaw).toBeTruthy();
|
|
210
|
+
expect(JSON.parse(durableRaw ?? '{}')).toEqual({
|
|
211
|
+
sessionId: 's-1',
|
|
212
|
+
userId: 'u-1',
|
|
213
|
+
deviceId: 'dev-1',
|
|
214
|
+
deviceSecret: 'ds-1',
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Warm key holds ONLY the short-lived token pair.
|
|
218
|
+
const warmRaw = storage.getItem(AUTH_STATE_TOKEN_STORAGE_KEY);
|
|
219
|
+
expect(warmRaw).toBeTruthy();
|
|
220
|
+
expect(JSON.parse(warmRaw ?? '{}')).toEqual({
|
|
221
|
+
accessToken: 'a-jwt',
|
|
222
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// A FRESH store (empty mirror) composes both keys back into the same shape.
|
|
226
|
+
expect(await createWebAuthStateStore().load()).toEqual({
|
|
227
|
+
...SAMPLE,
|
|
228
|
+
deviceId: 'dev-1',
|
|
229
|
+
deviceSecret: 'ds-1',
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('persists the durable credential even when the warm-token write fails', async () => {
|
|
234
|
+
const map = new Map<string, string>();
|
|
235
|
+
const storage = {
|
|
236
|
+
getItem: (k: string) => map.get(k) ?? null,
|
|
237
|
+
setItem: (k: string, v: string) => {
|
|
238
|
+
// Simulate the warm token exceeding the store's capacity while the small
|
|
239
|
+
// durable blob writes fine.
|
|
240
|
+
if (k === AUTH_STATE_TOKEN_STORAGE_KEY) {
|
|
241
|
+
throw new DOMException('QuotaExceededError', 'QuotaExceededError');
|
|
242
|
+
}
|
|
243
|
+
map.set(k, v);
|
|
244
|
+
},
|
|
245
|
+
removeItem: (k: string) => {
|
|
246
|
+
map.delete(k);
|
|
247
|
+
},
|
|
248
|
+
clear: () => map.clear(),
|
|
249
|
+
key: (i: number) => Array.from(map.keys())[i] ?? null,
|
|
250
|
+
get length() {
|
|
251
|
+
return map.size;
|
|
252
|
+
},
|
|
253
|
+
} as Storage;
|
|
254
|
+
installLocalStorage(storage);
|
|
255
|
+
const store = createWebAuthStateStore();
|
|
256
|
+
|
|
257
|
+
await expect(
|
|
258
|
+
store.save({ ...SAMPLE, deviceId: 'dev-abc', deviceSecret: 'ds-secret-xyz' }),
|
|
259
|
+
).resolves.toBe(true);
|
|
260
|
+
|
|
261
|
+
// The durable mint credential landed despite the warm-token write throwing.
|
|
262
|
+
const durableRaw = storage.getItem(AUTH_STATE_STORAGE_KEY);
|
|
263
|
+
expect(durableRaw).toBeTruthy();
|
|
264
|
+
const durable = JSON.parse(durableRaw ?? '{}');
|
|
265
|
+
expect(durable.deviceId).toBe('dev-abc');
|
|
266
|
+
expect(durable.deviceSecret).toBe('ds-secret-xyz');
|
|
267
|
+
expect(durable.accessToken).toBeUndefined();
|
|
268
|
+
// The warm-token key never persisted.
|
|
269
|
+
expect(storage.getItem(AUTH_STATE_TOKEN_STORAGE_KEY)).toBeNull();
|
|
270
|
+
|
|
271
|
+
// A fresh store restores the mint credential from disk; no warm token survives.
|
|
272
|
+
const loaded = await createWebAuthStateStore().load();
|
|
273
|
+
expect(loaded?.deviceId).toBe('dev-abc');
|
|
274
|
+
expect(loaded?.deviceSecret).toBe('ds-secret-xyz');
|
|
275
|
+
expect(loaded?.accessToken).toBeUndefined();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('load() reads an old combined oxy.auth.v1 blob (pre-split back-compat)', async () => {
|
|
279
|
+
const storage = makeFakeStorage();
|
|
280
|
+
installLocalStorage(storage);
|
|
281
|
+
// A user upgraded from the pre-split build: the WHOLE state (incl. the token)
|
|
282
|
+
// lives in the single durable key; the warm key does not exist yet.
|
|
283
|
+
storage.setItem(
|
|
284
|
+
AUTH_STATE_STORAGE_KEY,
|
|
285
|
+
JSON.stringify({ ...SAMPLE, deviceId: 'dev-old', deviceSecret: 'ds-old' }),
|
|
286
|
+
);
|
|
287
|
+
expect(storage.getItem(AUTH_STATE_TOKEN_STORAGE_KEY)).toBeNull();
|
|
288
|
+
|
|
289
|
+
const store = createWebAuthStateStore();
|
|
290
|
+
const loaded = await store.load();
|
|
291
|
+
// The token is read back from the combined blob (no one is logged out).
|
|
292
|
+
expect(loaded).toEqual({ ...SAMPLE, deviceId: 'dev-old', deviceSecret: 'ds-old' });
|
|
293
|
+
expect(loaded?.accessToken).toBe('a-jwt');
|
|
294
|
+
expect(loaded?.expiresAt).toBe('2030-01-01T00:00:00.000Z');
|
|
295
|
+
});
|
|
164
296
|
});
|
|
165
297
|
|
|
166
298
|
describe('createNativeAuthStateStore', () => {
|
|
@@ -206,10 +338,106 @@ describe('createNativeAuthStateStore', () => {
|
|
|
206
338
|
throw new Error('secure store locked');
|
|
207
339
|
},
|
|
208
340
|
});
|
|
209
|
-
|
|
210
|
-
|
|
341
|
+
// The durable write threw → reports the persist did NOT land…
|
|
342
|
+
await expect(store.save(SAMPLE)).resolves.toBe(false);
|
|
343
|
+
// …but the in-memory mirror preserves the session for this app run.
|
|
211
344
|
expect(await store.load()).toEqual(SAMPLE);
|
|
212
345
|
});
|
|
346
|
+
|
|
347
|
+
it('reports false when the durable SecureStore write SILENTLY no-ops (oversize value, no throw)', async () => {
|
|
348
|
+
// Android SecureStore can resolve a write WITHOUT throwing yet not persist an
|
|
349
|
+
// oversize value — the read-back is the only reliable proof. save() must
|
|
350
|
+
// report false so a rotating-secret lane refuses to plant on it.
|
|
351
|
+
const map = new Map<string, string>();
|
|
352
|
+
const storage: NativeKeyValueStorage = {
|
|
353
|
+
getItem: async (k) => map.get(k) ?? null,
|
|
354
|
+
setItem: async (k, v) => {
|
|
355
|
+
if (k === AUTH_STATE_TOKEN_STORAGE_KEY) map.set(k, v); // durable silently dropped
|
|
356
|
+
},
|
|
357
|
+
removeItem: async (k) => {
|
|
358
|
+
map.delete(k);
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
const store = createNativeAuthStateStore(storage);
|
|
362
|
+
|
|
363
|
+
await expect(
|
|
364
|
+
store.save({ ...SAMPLE, deviceId: 'dev-x', deviceSecret: 'ds-x' }),
|
|
365
|
+
).resolves.toBe(false);
|
|
366
|
+
expect(map.get(AUTH_STATE_STORAGE_KEY)).toBeUndefined();
|
|
367
|
+
expect(await store.load()).toMatchObject({ deviceId: 'dev-x', deviceSecret: 'ds-x' });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('persists the durable credential even when the warm-token write fails (oversize SecureStore value)', async () => {
|
|
371
|
+
const map = new Map<string, string>();
|
|
372
|
+
const storage: NativeKeyValueStorage = {
|
|
373
|
+
getItem: async (k) => map.get(k) ?? null,
|
|
374
|
+
// The large JWT exceeds the SecureStore value limit; the small durable blob
|
|
375
|
+
// writes fine.
|
|
376
|
+
setItem: async (k, v) => {
|
|
377
|
+
if (k === AUTH_STATE_TOKEN_STORAGE_KEY) {
|
|
378
|
+
throw new Error('Value too large for SecureStore');
|
|
379
|
+
}
|
|
380
|
+
map.set(k, v);
|
|
381
|
+
},
|
|
382
|
+
removeItem: async (k) => {
|
|
383
|
+
map.delete(k);
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
const store = createNativeAuthStateStore(storage);
|
|
387
|
+
|
|
388
|
+
await expect(
|
|
389
|
+
store.save({ ...SAMPLE, deviceId: 'dev-n', deviceSecret: 'ds-n' }),
|
|
390
|
+
).resolves.toBe(true);
|
|
391
|
+
|
|
392
|
+
// The durable mint credential landed to disk.
|
|
393
|
+
expect(map.get(AUTH_STATE_STORAGE_KEY)).toBeTruthy();
|
|
394
|
+
const durable = JSON.parse(map.get(AUTH_STATE_STORAGE_KEY) ?? '{}');
|
|
395
|
+
expect(durable.deviceId).toBe('dev-n');
|
|
396
|
+
expect(durable.deviceSecret).toBe('ds-n');
|
|
397
|
+
expect(durable.accessToken).toBeUndefined();
|
|
398
|
+
expect(map.get(AUTH_STATE_TOKEN_STORAGE_KEY)).toBeUndefined();
|
|
399
|
+
|
|
400
|
+
// A FRESH store (empty mirror) restores the mint credential from disk.
|
|
401
|
+
const loaded = await createNativeAuthStateStore(storage).load();
|
|
402
|
+
expect(loaded?.deviceId).toBe('dev-n');
|
|
403
|
+
expect(loaded?.deviceSecret).toBe('ds-n');
|
|
404
|
+
expect(loaded?.accessToken).toBeUndefined();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('load() reads an old combined blob (pre-split back-compat)', async () => {
|
|
408
|
+
const map = new Map<string, string>();
|
|
409
|
+
const storage: NativeKeyValueStorage = {
|
|
410
|
+
getItem: async (k) => map.get(k) ?? null,
|
|
411
|
+
setItem: async (k, v) => {
|
|
412
|
+
map.set(k, v);
|
|
413
|
+
},
|
|
414
|
+
removeItem: async (k) => {
|
|
415
|
+
map.delete(k);
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
// Pre-split combined blob in the single durable key; no warm key.
|
|
419
|
+
map.set(
|
|
420
|
+
AUTH_STATE_STORAGE_KEY,
|
|
421
|
+
JSON.stringify({ ...SAMPLE, deviceId: 'dev-old', deviceSecret: 'ds-old' }),
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
const store = createNativeAuthStateStore(storage);
|
|
425
|
+
expect(await store.load()).toEqual({ ...SAMPLE, deviceId: 'dev-old', deviceSecret: 'ds-old' });
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('clear() wipes BOTH the durable and warm keys', async () => {
|
|
429
|
+
const storage = makeNativeStorage();
|
|
430
|
+
const store = createNativeAuthStateStore(storage);
|
|
431
|
+
await store.save({ ...SAMPLE, deviceId: 'dev-1', deviceSecret: 'ds-1' });
|
|
432
|
+
// Both keys were written by the split save.
|
|
433
|
+
expect(storage.map.get(AUTH_STATE_STORAGE_KEY)).toBeTruthy();
|
|
434
|
+
expect(storage.map.get(AUTH_STATE_TOKEN_STORAGE_KEY)).toBeTruthy();
|
|
435
|
+
|
|
436
|
+
await store.clear();
|
|
437
|
+
expect(storage.map.get(AUTH_STATE_STORAGE_KEY)).toBeUndefined();
|
|
438
|
+
expect(storage.map.get(AUTH_STATE_TOKEN_STORAGE_KEY)).toBeUndefined();
|
|
439
|
+
expect(await store.load()).toBeNull();
|
|
440
|
+
});
|
|
213
441
|
});
|
|
214
442
|
|
|
215
443
|
describe('createMemoryAuthStateStore', () => {
|
|
@@ -1,8 +1,34 @@
|
|
|
1
1
|
import type { OxyServices } from '../../OxyServices';
|
|
2
2
|
import type { DeviceTokenMintResponse } from '@oxyhq/contracts';
|
|
3
3
|
import type { SessionLoginResponse } from '../../models/session';
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
refreshPersistedSession,
|
|
6
|
+
startTokenRefreshScheduler,
|
|
7
|
+
type DeviceSecretMintOutcome,
|
|
8
|
+
} from '../refresh';
|
|
9
|
+
import {
|
|
10
|
+
createMemoryAuthStateStore,
|
|
11
|
+
type AuthStateStore,
|
|
12
|
+
type PersistedAuthState,
|
|
13
|
+
} from '../authStateStore';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A real, per-client device-secret mint single-flight matching
|
|
17
|
+
* `HttpService.runSingleFlightDeviceSecretMint`: concurrent callers await the
|
|
18
|
+
* SAME in-flight mint and all receive its result; a fresh call after it settles
|
|
19
|
+
* starts a new one.
|
|
20
|
+
*/
|
|
21
|
+
function makeMintSingleFlight(): (mint: () => Promise<DeviceSecretMintOutcome>) => Promise<DeviceSecretMintOutcome> {
|
|
22
|
+
let inFlight: Promise<DeviceSecretMintOutcome> | null = null;
|
|
23
|
+
return (mint) => {
|
|
24
|
+
if (!inFlight) {
|
|
25
|
+
inFlight = mint().finally(() => {
|
|
26
|
+
inFlight = null;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return inFlight;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
6
32
|
|
|
7
33
|
/** The persisted zero-cookie mint credential the refresh reads. */
|
|
8
34
|
const STORED: PersistedAuthState = {
|
|
@@ -37,6 +63,9 @@ function makeOxy(overrides: RefreshMockOverrides = {}): { oxy: OxyServices; setT
|
|
|
37
63
|
setTokens,
|
|
38
64
|
mintFromDeviceSecret: overrides.mintFromDeviceSecret ?? (async () => MINT),
|
|
39
65
|
signInWithSharedIdentity: overrides.signInWithSharedIdentity ?? (async () => null),
|
|
66
|
+
// The rotating mint runs under the client's process-wide single-flight; the
|
|
67
|
+
// arm reaches for it via `oxy.httpService.runSingleFlightDeviceSecretMint`.
|
|
68
|
+
httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight() },
|
|
40
69
|
} as unknown as OxyServices;
|
|
41
70
|
return { oxy, setTokens };
|
|
42
71
|
}
|
|
@@ -169,6 +198,116 @@ describe('refreshPersistedSession — arm 2 (native shared-key fallback)', () =>
|
|
|
169
198
|
expect(await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true })).toBeNull();
|
|
170
199
|
expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
|
|
171
200
|
});
|
|
201
|
+
|
|
202
|
+
it('persists the recovered credential from the shared-key re-mint (repopulates the fast lane)', async () => {
|
|
203
|
+
// Bug #3: an in-session shared-key recovery must repopulate the durable
|
|
204
|
+
// device credential, not leave the fast device-secret lane empty.
|
|
205
|
+
const store = createMemoryAuthStateStore(); // no persisted secret → arm 1 skips
|
|
206
|
+
const RECOVERED: SessionLoginResponse = {
|
|
207
|
+
sessionId: 'sess-shared',
|
|
208
|
+
deviceId: 'dev-shared',
|
|
209
|
+
deviceSecret: 'ds-shared-secret',
|
|
210
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
211
|
+
user: { id: 'user-shared', username: 'u', name: {}, avatar: undefined },
|
|
212
|
+
accessToken: 'access-shared',
|
|
213
|
+
};
|
|
214
|
+
const { oxy } = makeOxy({ signInWithSharedIdentity: jest.fn(async () => RECOVERED) });
|
|
215
|
+
|
|
216
|
+
const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true });
|
|
217
|
+
|
|
218
|
+
expect(token).toBe('access-shared');
|
|
219
|
+
expect(await store.load()).toEqual({
|
|
220
|
+
sessionId: 'sess-shared',
|
|
221
|
+
userId: 'user-shared',
|
|
222
|
+
deviceId: 'dev-shared',
|
|
223
|
+
deviceSecret: 'ds-shared-secret',
|
|
224
|
+
accessToken: 'access-shared',
|
|
225
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe('refreshPersistedSession — single-flight (no double-rotation)', () => {
|
|
231
|
+
it('coalesces two concurrent mints into ONE server rotation; the store holds the final current secret', async () => {
|
|
232
|
+
// The server rotates the secret on every mint. Two concurrent lanes must
|
|
233
|
+
// therefore share ONE in-flight mint (one rotation) or the store could
|
|
234
|
+
// converge on a superseded secret.
|
|
235
|
+
const store = createMemoryAuthStateStore();
|
|
236
|
+
await store.save(STORED);
|
|
237
|
+
|
|
238
|
+
let release: (() => void) | null = null;
|
|
239
|
+
const gate = new Promise<void>((resolve) => {
|
|
240
|
+
release = resolve;
|
|
241
|
+
});
|
|
242
|
+
const mintFromDeviceSecret = jest.fn(async (deviceId: string, deviceSecret: string) => {
|
|
243
|
+
// Block until BOTH callers have entered so the single-flight is exercised.
|
|
244
|
+
await gate;
|
|
245
|
+
expect(deviceId).toBe('dev-mint');
|
|
246
|
+
expect(deviceSecret).toBe('ds-secret-orig');
|
|
247
|
+
return MINT;
|
|
248
|
+
});
|
|
249
|
+
const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
|
|
250
|
+
|
|
251
|
+
const first = refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false });
|
|
252
|
+
const second = refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false });
|
|
253
|
+
// Both callers entered while the mint is in flight.
|
|
254
|
+
release?.();
|
|
255
|
+
const [t1, t2] = await Promise.all([first, second]);
|
|
256
|
+
|
|
257
|
+
// Exactly one server rotation despite two concurrent callers…
|
|
258
|
+
expect(mintFromDeviceSecret).toHaveBeenCalledTimes(1);
|
|
259
|
+
// …both callers received the same minted token…
|
|
260
|
+
expect(t1).toBe('access-new');
|
|
261
|
+
expect(t2).toBe('access-new');
|
|
262
|
+
// …and the durable store converged on the rotated (current) secret.
|
|
263
|
+
expect((await store.load())?.deviceSecret).toBe('ds-next-secret');
|
|
264
|
+
// The token was planted exactly once (inside the single-flighted mint).
|
|
265
|
+
expect(setTokens).toHaveBeenCalledTimes(1);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe('refreshPersistedSession — durable persist failure is fatal to the mint', () => {
|
|
270
|
+
it('does NOT plant a token when the rotated secret cannot be durably persisted', async () => {
|
|
271
|
+
// Bug #2: a mint that rotated the server secret but could not persist it must
|
|
272
|
+
// not leave the process advertising a session on an unsaved, soon-dead secret.
|
|
273
|
+
const failingStore: AuthStateStore = {
|
|
274
|
+
load: async () => STORED,
|
|
275
|
+
save: async () => false, // durable write did not land
|
|
276
|
+
clear: async () => undefined,
|
|
277
|
+
};
|
|
278
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT);
|
|
279
|
+
const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
|
|
280
|
+
|
|
281
|
+
const token = await refreshPersistedSession({
|
|
282
|
+
oxy,
|
|
283
|
+
store: failingStore,
|
|
284
|
+
allowSharedKeyFallback: false,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// The mint ran (the server rotated) but the token was NOT planted…
|
|
288
|
+
expect(mintFromDeviceSecret).toHaveBeenCalledTimes(1);
|
|
289
|
+
expect(setTokens).not.toHaveBeenCalled();
|
|
290
|
+
// …and the lane reports failure rather than a healthy session.
|
|
291
|
+
expect(token).toBeNull();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('does NOT fall through to the shared-key arm on a persist failure', async () => {
|
|
295
|
+
const failingStore: AuthStateStore = {
|
|
296
|
+
load: async () => STORED,
|
|
297
|
+
save: async () => false,
|
|
298
|
+
clear: async () => undefined,
|
|
299
|
+
};
|
|
300
|
+
const signInWithSharedIdentity = jest.fn(async () => null);
|
|
301
|
+
const { oxy } = makeOxy({
|
|
302
|
+
mintFromDeviceSecret: async () => MINT,
|
|
303
|
+
signInWithSharedIdentity,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
await refreshPersistedSession({ oxy, store: failingStore, allowSharedKeyFallback: true });
|
|
307
|
+
|
|
308
|
+
// A storage failure is not a bad-secret signal — the shared-key arm must not run.
|
|
309
|
+
expect(signInWithSharedIdentity).not.toHaveBeenCalled();
|
|
310
|
+
});
|
|
172
311
|
});
|
|
173
312
|
|
|
174
313
|
describe('startTokenRefreshScheduler', () => {
|