@oxyhq/core 9.2.1 → 9.2.2
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/mixins/OxyServices.utility.js +9 -5
- package/dist/cjs/session/SessionClient.js +45 -0
- package/dist/cjs/session/accountDialogController.js +30 -0
- package/dist/cjs/session/authStateStore.js +196 -16
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/mixins/OxyServices.utility.js +9 -5
- package/dist/esm/session/SessionClient.js +45 -0
- package/dist/esm/session/accountDialogController.js +30 -0
- package/dist/esm/session/authStateStore.js +195 -15
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/session/SessionClient.d.ts +11 -0
- package/dist/types/session/accountDialogController.d.ts +17 -0
- package/dist/types/session/authStateStore.d.ts +33 -8
- package/package.json +2 -2
- package/src/mixins/OxyServices.utility.ts +10 -9
- package/src/session/SessionClient.ts +44 -0
- package/src/session/__tests__/SessionClient.serverEvents.test.ts +71 -0
- package/src/session/__tests__/accountDialogController.test.ts +85 -0
- package/src/session/__tests__/authStateStore.test.ts +170 -0
- package/src/session/accountDialogController.ts +45 -0
- package/src/session/authStateStore.ts +219 -15
|
@@ -56,9 +56,20 @@ export declare class SessionClient {
|
|
|
56
56
|
private started;
|
|
57
57
|
/** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
|
|
58
58
|
private channel;
|
|
59
|
+
/** App-facing subscriptions to named server-pushed socket events. */
|
|
60
|
+
private readonly serverEvents;
|
|
61
|
+
/** Event names already bound on the CURRENT socket instance. */
|
|
62
|
+
private readonly boundServerEvents;
|
|
59
63
|
constructor(host: SessionClientHost, options?: SessionClientOptions);
|
|
60
64
|
getState(): DeviceSessionState | null;
|
|
61
65
|
subscribe(listener: StateListener): () => void;
|
|
66
|
+
/**
|
|
67
|
+
* Subscribe to a named server-pushed Socket.IO event (e.g. `civic:attested`).
|
|
68
|
+
* Listeners survive reconnects and socket re-creation; the returned function
|
|
69
|
+
* unsubscribes. Payloads are delivered as-is — callers validate shape.
|
|
70
|
+
*/
|
|
71
|
+
onServerEvent(event: string, listener: (payload: unknown) => void): () => void;
|
|
72
|
+
private bindServerEvent;
|
|
62
73
|
protected notify(): void;
|
|
63
74
|
/** Validate + last-writer-wins by revision. Returns true if applied. */
|
|
64
75
|
protected applyState(raw: unknown): boolean;
|
|
@@ -111,6 +111,16 @@ export interface AccountDialogControllerOptions {
|
|
|
111
111
|
* `Linking.openURL`). Headless core never touches `window`/`Linking` itself.
|
|
112
112
|
*/
|
|
113
113
|
openUrl?: (url: string) => void;
|
|
114
|
+
/**
|
|
115
|
+
* Optional "can this app open this URL scheme?" probe, symmetric to
|
|
116
|
+
* {@link openUrl}. When provided, `showQr` uses it to detect an installed
|
|
117
|
+
* Commons (`oxycommons://`) and, if present, deep-links straight into its
|
|
118
|
+
* approve screen via {@link openUrl} — while KEEPING the QR/polling active as
|
|
119
|
+
* the fallback. Injected by the provider (native: `Linking.canOpenURL`; web:
|
|
120
|
+
* absent/false). Headless core never touches `Linking` itself; when absent
|
|
121
|
+
* `showQr` behaves exactly as before (render QR only).
|
|
122
|
+
*/
|
|
123
|
+
canOpenApp?: (url: string) => Promise<boolean>;
|
|
114
124
|
}
|
|
115
125
|
type SnapshotListener = (snapshot: AccountDialogSnapshot) => void;
|
|
116
126
|
export declare class AccountDialogController {
|
|
@@ -124,6 +134,7 @@ export declare class AccountDialogController {
|
|
|
124
134
|
private readonly authRedirectUri;
|
|
125
135
|
private readonly pollIntervalMs;
|
|
126
136
|
private readonly openUrl?;
|
|
137
|
+
private readonly canOpenApp?;
|
|
127
138
|
private readonly listeners;
|
|
128
139
|
private view;
|
|
129
140
|
private graph;
|
|
@@ -232,6 +243,12 @@ export declare class AccountDialogController {
|
|
|
232
243
|
* session committed. Requires `clientId`.
|
|
233
244
|
*/
|
|
234
245
|
showQr(): Promise<void>;
|
|
246
|
+
/**
|
|
247
|
+
* When a `canOpenApp` probe is injected and reports Commons installed, open the
|
|
248
|
+
* approve deep link via the injected `openUrl`. Best-effort and non-blocking: a
|
|
249
|
+
* probe/open failure is logged and swallowed — the QR/polling fallback remains.
|
|
250
|
+
*/
|
|
251
|
+
private maybeOpenCommons;
|
|
235
252
|
/** Tear down the active sign-in device flow (timers + token) and reset to idle. */
|
|
236
253
|
cancelSignIn(): void;
|
|
237
254
|
/**
|
|
@@ -77,11 +77,32 @@ export interface NativeKeyValueStorage {
|
|
|
77
77
|
removeItem(key: string): Promise<void>;
|
|
78
78
|
}
|
|
79
79
|
/**
|
|
80
|
-
* Versioned storage key.
|
|
81
|
-
*
|
|
82
|
-
* `
|
|
80
|
+
* Versioned DURABLE storage key. Holds ONLY the small, re-mint-critical fields
|
|
81
|
+
* (`sessionId`, `userId`, `deviceId`, `deviceSecret`) — never the large JWT
|
|
82
|
+
* `accessToken`. Keeping this blob small (<2KB) matters on Android
|
|
83
|
+
* `expo-secure-store`, whose backing store can silently fail to persist an
|
|
84
|
+
* oversize value; bundling the token here previously took the mint credential
|
|
85
|
+
* down with it on every write, losing the session on cold restart.
|
|
86
|
+
*
|
|
87
|
+
* The `.v1` suffix lets a future shape change ship a `.v2` key without reading a
|
|
88
|
+
* stale/incompatible `.v1` blob. Distinct from the `oxy_shared_*` keychain keys
|
|
89
|
+
* in `KeyManager`, so it never collides.
|
|
90
|
+
*
|
|
91
|
+
* BACK-COMPAT: pre-split builds wrote the WHOLE state (including `accessToken` /
|
|
92
|
+
* `expiresAt`) into this single key. `load()` still reads those token fields
|
|
93
|
+
* from here when the warm key ({@link AUTH_STATE_TOKEN_STORAGE_KEY}) is absent,
|
|
94
|
+
* so upgrading users are not signed out; the next `save()` splits them apart.
|
|
83
95
|
*/
|
|
84
96
|
export declare const AUTH_STATE_STORAGE_KEY = "oxy.auth.v1";
|
|
97
|
+
/**
|
|
98
|
+
* Versioned BEST-EFFORT warm-token storage key. Holds the short-lived
|
|
99
|
+
* `{ accessToken, expiresAt }` pair only. Its write is genuinely non-fatal — a
|
|
100
|
+
* failure (quota / oversize keychain value) is swallowed because the session is
|
|
101
|
+
* fully re-mintable from the durable `deviceSecret`. Kept separate from
|
|
102
|
+
* {@link AUTH_STATE_STORAGE_KEY} so a failed token write can NEVER abort or
|
|
103
|
+
* corrupt the durable credential write.
|
|
104
|
+
*/
|
|
105
|
+
export declare const AUTH_STATE_TOKEN_STORAGE_KEY = "oxy.auth.token.v1";
|
|
85
106
|
/**
|
|
86
107
|
* A process-lifetime, in-memory {@link AuthStateStore}. Used directly for
|
|
87
108
|
* tests/SSR and as the degraded fallback of the web store when `localStorage`
|
|
@@ -90,8 +111,9 @@ export declare const AUTH_STATE_STORAGE_KEY = "oxy.auth.v1";
|
|
|
90
111
|
*/
|
|
91
112
|
export declare function createMemoryAuthStateStore(): AuthStateStore;
|
|
92
113
|
/**
|
|
93
|
-
* A `localStorage`-backed {@link AuthStateStore}
|
|
94
|
-
* {@link AUTH_STATE_STORAGE_KEY}
|
|
114
|
+
* A `localStorage`-backed {@link AuthStateStore} split across the durable
|
|
115
|
+
* {@link AUTH_STATE_STORAGE_KEY} (mint credential) and the best-effort
|
|
116
|
+
* {@link AUTH_STATE_TOKEN_STORAGE_KEY} (warm access token).
|
|
95
117
|
*
|
|
96
118
|
* Resilience:
|
|
97
119
|
* - If `localStorage` is unreachable (sandboxed-iframe `SecurityError`, SSR),
|
|
@@ -107,8 +129,11 @@ export declare function createWebAuthStateStore(): AuthStateStore;
|
|
|
107
129
|
* A native {@link AuthStateStore} over an injected async key/value store.
|
|
108
130
|
*
|
|
109
131
|
* `@oxyhq/core` never imports `expo-secure-store`; `@oxyhq/services` constructs
|
|
110
|
-
* the SecureStore-backed adapter and passes it here.
|
|
111
|
-
*
|
|
112
|
-
*
|
|
132
|
+
* the SecureStore-backed adapter and passes it here. Persistence is split across
|
|
133
|
+
* the durable {@link AUTH_STATE_STORAGE_KEY} (mint credential) and the
|
|
134
|
+
* best-effort {@link AUTH_STATE_TOKEN_STORAGE_KEY} (warm access token) — the
|
|
135
|
+
* durable write is read-back-verified and its failure surfaced (not swallowed),
|
|
136
|
+
* while the warm-token write and all reads degrade gracefully exactly like the
|
|
137
|
+
* web store.
|
|
113
138
|
*/
|
|
114
139
|
export declare function createNativeAuthStateStore(storage: NativeKeyValueStorage): AuthStateStore;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxyhq/core",
|
|
3
|
-
"version": "9.2.
|
|
3
|
+
"version": "9.2.2",
|
|
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.13.
|
|
97
|
+
"@oxyhq/contracts": "^0.13.2",
|
|
98
98
|
"@oxyhq/protocol": "^0.1.3",
|
|
99
99
|
"bip39": "^3.1.0",
|
|
100
100
|
"buffer": "^6.0.3",
|
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
* and Express.js authentication middleware
|
|
6
6
|
*/
|
|
7
7
|
import { jwtDecode } from 'jwt-decode';
|
|
8
|
+
import type { LinkPreview } from '@oxyhq/contracts';
|
|
8
9
|
import type { ApiError, User } from '../models/interfaces';
|
|
9
10
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
10
11
|
import { loadNodeCrypto } from '@oxyhq/protocol';
|
|
12
|
+
import { buildUrl } from '../utils/apiUtils';
|
|
11
13
|
import { logger } from '../utils/loggerUtils';
|
|
12
14
|
import { CACHE_TIMES } from './mixinHelpers';
|
|
13
15
|
|
|
@@ -217,15 +219,14 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
217
219
|
image?: string;
|
|
218
220
|
}> {
|
|
219
221
|
try {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
});
|
|
222
|
+
const path = buildUrl('/links/preview', { url, wait: 1 });
|
|
223
|
+
const preview = await this.makeRequest<LinkPreview>('GET', path, undefined, { cache: false });
|
|
224
|
+
return {
|
|
225
|
+
url: preview.url,
|
|
226
|
+
title: preview.title?.trim() || preview.url.replace(/^https?:\/\//, '').replace(/\/$/, ''),
|
|
227
|
+
description: preview.description?.trim() || 'Link',
|
|
228
|
+
image: preview.image,
|
|
229
|
+
};
|
|
229
230
|
} catch (error) {
|
|
230
231
|
throw this.handleError(error);
|
|
231
232
|
}
|
|
@@ -82,6 +82,10 @@ export class SessionClient {
|
|
|
82
82
|
private started = false;
|
|
83
83
|
/** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
|
|
84
84
|
private channel: SessionBroadcastChannel | null = null;
|
|
85
|
+
/** App-facing subscriptions to named server-pushed socket events. */
|
|
86
|
+
private readonly serverEvents = new Map<string, Set<(payload: unknown) => void>>();
|
|
87
|
+
/** Event names already bound on the CURRENT socket instance. */
|
|
88
|
+
private readonly boundServerEvents = new Set<string>();
|
|
85
89
|
|
|
86
90
|
constructor(
|
|
87
91
|
protected readonly host: SessionClientHost,
|
|
@@ -99,6 +103,40 @@ export class SessionClient {
|
|
|
99
103
|
};
|
|
100
104
|
}
|
|
101
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Subscribe to a named server-pushed Socket.IO event (e.g. `civic:attested`).
|
|
108
|
+
* Listeners survive reconnects and socket re-creation; the returned function
|
|
109
|
+
* unsubscribes. Payloads are delivered as-is — callers validate shape.
|
|
110
|
+
*/
|
|
111
|
+
onServerEvent(event: string, listener: (payload: unknown) => void): () => void {
|
|
112
|
+
let listeners = this.serverEvents.get(event);
|
|
113
|
+
if (!listeners) {
|
|
114
|
+
listeners = new Set();
|
|
115
|
+
this.serverEvents.set(event, listeners);
|
|
116
|
+
}
|
|
117
|
+
listeners.add(listener);
|
|
118
|
+
this.bindServerEvent(event);
|
|
119
|
+
return () => {
|
|
120
|
+
listeners.delete(listener);
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private bindServerEvent(event: string): void {
|
|
125
|
+
if (!this.socket || this.boundServerEvents.has(event)) return;
|
|
126
|
+
this.boundServerEvents.add(event);
|
|
127
|
+
this.socket.on(event, (payload: unknown) => {
|
|
128
|
+
const listeners = this.serverEvents.get(event);
|
|
129
|
+
if (!listeners) return;
|
|
130
|
+
for (const listener of [...listeners]) {
|
|
131
|
+
try {
|
|
132
|
+
listener(payload);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
logger.warn('[SessionClient] server-event listener threw', { component: 'SessionClient' }, error);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
102
140
|
protected notify(): void {
|
|
103
141
|
for (const listener of this.listeners) {
|
|
104
142
|
try {
|
|
@@ -287,6 +325,7 @@ export class SessionClient {
|
|
|
287
325
|
if (this.socket) {
|
|
288
326
|
this.socket.disconnect();
|
|
289
327
|
this.socket = null;
|
|
328
|
+
this.boundServerEvents.clear();
|
|
290
329
|
}
|
|
291
330
|
}
|
|
292
331
|
|
|
@@ -341,6 +380,11 @@ export class SessionClient {
|
|
|
341
380
|
}
|
|
342
381
|
});
|
|
343
382
|
this.socket = socket;
|
|
383
|
+
// (Re)bind app-facing server-event subscriptions on the fresh socket.
|
|
384
|
+
this.boundServerEvents.clear();
|
|
385
|
+
for (const event of this.serverEvents.keys()) {
|
|
386
|
+
this.bindServerEvent(event);
|
|
387
|
+
}
|
|
344
388
|
}
|
|
345
389
|
|
|
346
390
|
/**
|
|
@@ -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
|
+
});
|
|
@@ -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';
|
|
@@ -161,6 +162,103 @@ describe('createWebAuthStateStore', () => {
|
|
|
161
162
|
// The authoritative in-memory mirror still reports the cleared state.
|
|
162
163
|
expect(await store.load()).toBeNull();
|
|
163
164
|
});
|
|
165
|
+
|
|
166
|
+
it('splits the token into the warm key and keeps the durable blob token-free', async () => {
|
|
167
|
+
const storage = makeFakeStorage();
|
|
168
|
+
installLocalStorage(storage);
|
|
169
|
+
const store = createWebAuthStateStore();
|
|
170
|
+
|
|
171
|
+
await store.save({ ...SAMPLE, deviceId: 'dev-1', deviceSecret: 'ds-1' });
|
|
172
|
+
|
|
173
|
+
// Durable key holds ONLY the small mint-critical fields — never the JWT.
|
|
174
|
+
const durableRaw = storage.getItem(AUTH_STATE_STORAGE_KEY);
|
|
175
|
+
expect(durableRaw).toBeTruthy();
|
|
176
|
+
expect(JSON.parse(durableRaw ?? '{}')).toEqual({
|
|
177
|
+
sessionId: 's-1',
|
|
178
|
+
userId: 'u-1',
|
|
179
|
+
deviceId: 'dev-1',
|
|
180
|
+
deviceSecret: 'ds-1',
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Warm key holds ONLY the short-lived token pair.
|
|
184
|
+
const warmRaw = storage.getItem(AUTH_STATE_TOKEN_STORAGE_KEY);
|
|
185
|
+
expect(warmRaw).toBeTruthy();
|
|
186
|
+
expect(JSON.parse(warmRaw ?? '{}')).toEqual({
|
|
187
|
+
accessToken: 'a-jwt',
|
|
188
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// A FRESH store (empty mirror) composes both keys back into the same shape.
|
|
192
|
+
expect(await createWebAuthStateStore().load()).toEqual({
|
|
193
|
+
...SAMPLE,
|
|
194
|
+
deviceId: 'dev-1',
|
|
195
|
+
deviceSecret: 'ds-1',
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('persists the durable credential even when the warm-token write fails', async () => {
|
|
200
|
+
const map = new Map<string, string>();
|
|
201
|
+
const storage = {
|
|
202
|
+
getItem: (k: string) => map.get(k) ?? null,
|
|
203
|
+
setItem: (k: string, v: string) => {
|
|
204
|
+
// Simulate the warm token exceeding the store's capacity while the small
|
|
205
|
+
// durable blob writes fine.
|
|
206
|
+
if (k === AUTH_STATE_TOKEN_STORAGE_KEY) {
|
|
207
|
+
throw new DOMException('QuotaExceededError', 'QuotaExceededError');
|
|
208
|
+
}
|
|
209
|
+
map.set(k, v);
|
|
210
|
+
},
|
|
211
|
+
removeItem: (k: string) => {
|
|
212
|
+
map.delete(k);
|
|
213
|
+
},
|
|
214
|
+
clear: () => map.clear(),
|
|
215
|
+
key: (i: number) => Array.from(map.keys())[i] ?? null,
|
|
216
|
+
get length() {
|
|
217
|
+
return map.size;
|
|
218
|
+
},
|
|
219
|
+
} as Storage;
|
|
220
|
+
installLocalStorage(storage);
|
|
221
|
+
const store = createWebAuthStateStore();
|
|
222
|
+
|
|
223
|
+
await expect(
|
|
224
|
+
store.save({ ...SAMPLE, deviceId: 'dev-abc', deviceSecret: 'ds-secret-xyz' }),
|
|
225
|
+
).resolves.toBeUndefined();
|
|
226
|
+
|
|
227
|
+
// The durable mint credential landed despite the warm-token write throwing.
|
|
228
|
+
const durableRaw = storage.getItem(AUTH_STATE_STORAGE_KEY);
|
|
229
|
+
expect(durableRaw).toBeTruthy();
|
|
230
|
+
const durable = JSON.parse(durableRaw ?? '{}');
|
|
231
|
+
expect(durable.deviceId).toBe('dev-abc');
|
|
232
|
+
expect(durable.deviceSecret).toBe('ds-secret-xyz');
|
|
233
|
+
expect(durable.accessToken).toBeUndefined();
|
|
234
|
+
// The warm-token key never persisted.
|
|
235
|
+
expect(storage.getItem(AUTH_STATE_TOKEN_STORAGE_KEY)).toBeNull();
|
|
236
|
+
|
|
237
|
+
// A fresh store restores the mint credential from disk; no warm token survives.
|
|
238
|
+
const loaded = await createWebAuthStateStore().load();
|
|
239
|
+
expect(loaded?.deviceId).toBe('dev-abc');
|
|
240
|
+
expect(loaded?.deviceSecret).toBe('ds-secret-xyz');
|
|
241
|
+
expect(loaded?.accessToken).toBeUndefined();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('load() reads an old combined oxy.auth.v1 blob (pre-split back-compat)', async () => {
|
|
245
|
+
const storage = makeFakeStorage();
|
|
246
|
+
installLocalStorage(storage);
|
|
247
|
+
// A user upgraded from the pre-split build: the WHOLE state (incl. the token)
|
|
248
|
+
// lives in the single durable key; the warm key does not exist yet.
|
|
249
|
+
storage.setItem(
|
|
250
|
+
AUTH_STATE_STORAGE_KEY,
|
|
251
|
+
JSON.stringify({ ...SAMPLE, deviceId: 'dev-old', deviceSecret: 'ds-old' }),
|
|
252
|
+
);
|
|
253
|
+
expect(storage.getItem(AUTH_STATE_TOKEN_STORAGE_KEY)).toBeNull();
|
|
254
|
+
|
|
255
|
+
const store = createWebAuthStateStore();
|
|
256
|
+
const loaded = await store.load();
|
|
257
|
+
// The token is read back from the combined blob (no one is logged out).
|
|
258
|
+
expect(loaded).toEqual({ ...SAMPLE, deviceId: 'dev-old', deviceSecret: 'ds-old' });
|
|
259
|
+
expect(loaded?.accessToken).toBe('a-jwt');
|
|
260
|
+
expect(loaded?.expiresAt).toBe('2030-01-01T00:00:00.000Z');
|
|
261
|
+
});
|
|
164
262
|
});
|
|
165
263
|
|
|
166
264
|
describe('createNativeAuthStateStore', () => {
|
|
@@ -210,6 +308,78 @@ describe('createNativeAuthStateStore', () => {
|
|
|
210
308
|
// The write threw, but the in-memory mirror preserves the session.
|
|
211
309
|
expect(await store.load()).toEqual(SAMPLE);
|
|
212
310
|
});
|
|
311
|
+
|
|
312
|
+
it('persists the durable credential even when the warm-token write fails (oversize SecureStore value)', async () => {
|
|
313
|
+
const map = new Map<string, string>();
|
|
314
|
+
const storage: NativeKeyValueStorage = {
|
|
315
|
+
getItem: async (k) => map.get(k) ?? null,
|
|
316
|
+
// The large JWT exceeds the SecureStore value limit; the small durable blob
|
|
317
|
+
// writes fine.
|
|
318
|
+
setItem: async (k, v) => {
|
|
319
|
+
if (k === AUTH_STATE_TOKEN_STORAGE_KEY) {
|
|
320
|
+
throw new Error('Value too large for SecureStore');
|
|
321
|
+
}
|
|
322
|
+
map.set(k, v);
|
|
323
|
+
},
|
|
324
|
+
removeItem: async (k) => {
|
|
325
|
+
map.delete(k);
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
const store = createNativeAuthStateStore(storage);
|
|
329
|
+
|
|
330
|
+
await expect(
|
|
331
|
+
store.save({ ...SAMPLE, deviceId: 'dev-n', deviceSecret: 'ds-n' }),
|
|
332
|
+
).resolves.toBeUndefined();
|
|
333
|
+
|
|
334
|
+
// The durable mint credential landed to disk.
|
|
335
|
+
expect(map.get(AUTH_STATE_STORAGE_KEY)).toBeTruthy();
|
|
336
|
+
const durable = JSON.parse(map.get(AUTH_STATE_STORAGE_KEY) ?? '{}');
|
|
337
|
+
expect(durable.deviceId).toBe('dev-n');
|
|
338
|
+
expect(durable.deviceSecret).toBe('ds-n');
|
|
339
|
+
expect(durable.accessToken).toBeUndefined();
|
|
340
|
+
expect(map.get(AUTH_STATE_TOKEN_STORAGE_KEY)).toBeUndefined();
|
|
341
|
+
|
|
342
|
+
// A FRESH store (empty mirror) restores the mint credential from disk.
|
|
343
|
+
const loaded = await createNativeAuthStateStore(storage).load();
|
|
344
|
+
expect(loaded?.deviceId).toBe('dev-n');
|
|
345
|
+
expect(loaded?.deviceSecret).toBe('ds-n');
|
|
346
|
+
expect(loaded?.accessToken).toBeUndefined();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('load() reads an old combined blob (pre-split back-compat)', async () => {
|
|
350
|
+
const map = new Map<string, string>();
|
|
351
|
+
const storage: NativeKeyValueStorage = {
|
|
352
|
+
getItem: async (k) => map.get(k) ?? null,
|
|
353
|
+
setItem: async (k, v) => {
|
|
354
|
+
map.set(k, v);
|
|
355
|
+
},
|
|
356
|
+
removeItem: async (k) => {
|
|
357
|
+
map.delete(k);
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
// Pre-split combined blob in the single durable key; no warm key.
|
|
361
|
+
map.set(
|
|
362
|
+
AUTH_STATE_STORAGE_KEY,
|
|
363
|
+
JSON.stringify({ ...SAMPLE, deviceId: 'dev-old', deviceSecret: 'ds-old' }),
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
const store = createNativeAuthStateStore(storage);
|
|
367
|
+
expect(await store.load()).toEqual({ ...SAMPLE, deviceId: 'dev-old', deviceSecret: 'ds-old' });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('clear() wipes BOTH the durable and warm keys', async () => {
|
|
371
|
+
const storage = makeNativeStorage();
|
|
372
|
+
const store = createNativeAuthStateStore(storage);
|
|
373
|
+
await store.save({ ...SAMPLE, deviceId: 'dev-1', deviceSecret: 'ds-1' });
|
|
374
|
+
// Both keys were written by the split save.
|
|
375
|
+
expect(storage.map.get(AUTH_STATE_STORAGE_KEY)).toBeTruthy();
|
|
376
|
+
expect(storage.map.get(AUTH_STATE_TOKEN_STORAGE_KEY)).toBeTruthy();
|
|
377
|
+
|
|
378
|
+
await store.clear();
|
|
379
|
+
expect(storage.map.get(AUTH_STATE_STORAGE_KEY)).toBeUndefined();
|
|
380
|
+
expect(storage.map.get(AUTH_STATE_TOKEN_STORAGE_KEY)).toBeUndefined();
|
|
381
|
+
expect(await store.load()).toBeNull();
|
|
382
|
+
});
|
|
213
383
|
});
|
|
214
384
|
|
|
215
385
|
describe('createMemoryAuthStateStore', () => {
|