@oxyhq/core 9.2.0 → 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/boot/sessionColdBoot.js +13 -0
- package/dist/cjs/index.js +6 -4
- package/dist/cjs/mixins/OxyServices.accounts.js +3 -0
- package/dist/cjs/mixins/OxyServices.utility.js +9 -5
- package/dist/cjs/session/SessionClient.js +45 -0
- package/dist/cjs/session/accountDialogController.js +31 -0
- package/dist/cjs/session/authStateStore.js +196 -16
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +13 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/mixins/OxyServices.accounts.js +1 -0
- package/dist/esm/mixins/OxyServices.utility.js +9 -5
- package/dist/esm/session/SessionClient.js +45 -0
- package/dist/esm/session/accountDialogController.js +31 -0
- package/dist/esm/session/authStateStore.js +195 -15
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +2 -1
- package/dist/types/mixins/OxyServices.accounts.d.ts +8 -0
- package/dist/types/mixins/OxyServices.auth.d.ts +1 -0
- package/dist/types/models/interfaces.d.ts +3 -1
- package/dist/types/models/session.d.ts +6 -0
- 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/boot/__tests__/sessionColdBoot.test.ts +20 -0
- package/src/boot/sessionColdBoot.ts +13 -0
- package/src/index.ts +3 -0
- package/src/mixins/OxyServices.accounts.ts +9 -0
- package/src/mixins/OxyServices.auth.ts +2 -0
- package/src/mixins/OxyServices.utility.ts +10 -9
- package/src/models/interfaces.ts +3 -1
- package/src/models/session.ts +6 -0
- package/src/session/SessionClient.ts +44 -0
- package/src/session/__tests__/SessionClient.serverEvents.test.ts +71 -0
- package/src/session/__tests__/accountDialogController.test.ts +93 -1
- package/src/session/__tests__/authStateStore.test.ts +170 -0
- package/src/session/accountDialogController.ts +54 -1
- package/src/session/authStateStore.ts +219 -15
|
@@ -461,6 +461,7 @@ describe('AccountDialogController — sign in with Oxy', () => {
|
|
|
461
461
|
accessToken: 'access-1',
|
|
462
462
|
sessionId: 'sess-1',
|
|
463
463
|
deviceId: 'device-1',
|
|
464
|
+
deviceSecret: 'claimed-secret',
|
|
464
465
|
expiresAt: '2030-01-01T00:00:00Z',
|
|
465
466
|
user: user('a1'),
|
|
466
467
|
});
|
|
@@ -473,7 +474,13 @@ describe('AccountDialogController — sign in with Oxy', () => {
|
|
|
473
474
|
|
|
474
475
|
await jest.advanceTimersByTimeAsync(1000); // second poll → authorized → claim
|
|
475
476
|
expect(oxy.claimSessionByToken).toHaveBeenCalledWith('secret-tok');
|
|
476
|
-
expect(commitSession).toHaveBeenCalledWith(
|
|
477
|
+
expect(commitSession).toHaveBeenCalledWith(
|
|
478
|
+
expect.objectContaining({
|
|
479
|
+
sessionId: 'sess-1',
|
|
480
|
+
accessToken: 'access-1',
|
|
481
|
+
deviceSecret: 'claimed-secret',
|
|
482
|
+
}),
|
|
483
|
+
);
|
|
477
484
|
expect(onSignedIn).toHaveBeenCalledWith(expect.objectContaining({ id: 'a1' }));
|
|
478
485
|
expect(controller.getSnapshot().view).toBe('accounts');
|
|
479
486
|
} finally {
|
|
@@ -533,6 +540,91 @@ describe('AccountDialogController — sign in with Oxy', () => {
|
|
|
533
540
|
});
|
|
534
541
|
});
|
|
535
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
|
+
|
|
536
628
|
describe('AccountDialogController — openPasswordAtOxyAuth', () => {
|
|
537
629
|
beforeEach(() => {
|
|
538
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', () => {
|
|
@@ -131,10 +131,27 @@ export interface AccountDialogControllerOptions {
|
|
|
131
131
|
* `Linking.openURL`). Headless core never touches `window`/`Linking` itself.
|
|
132
132
|
*/
|
|
133
133
|
openUrl?: (url: string) => void;
|
|
134
|
+
/**
|
|
135
|
+
* Optional "can this app open this URL scheme?" probe, symmetric to
|
|
136
|
+
* {@link openUrl}. When provided, `showQr` uses it to detect an installed
|
|
137
|
+
* Commons (`oxycommons://`) and, if present, deep-links straight into its
|
|
138
|
+
* approve screen via {@link openUrl} — while KEEPING the QR/polling active as
|
|
139
|
+
* the fallback. Injected by the provider (native: `Linking.canOpenURL`; web:
|
|
140
|
+
* absent/false). Headless core never touches `Linking` itself; when absent
|
|
141
|
+
* `showQr` behaves exactly as before (render QR only).
|
|
142
|
+
*/
|
|
143
|
+
canOpenApp?: (url: string) => Promise<boolean>;
|
|
134
144
|
}
|
|
135
145
|
|
|
136
146
|
const DEFAULT_POLL_INTERVAL_MS = 3000;
|
|
137
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Commons's custom URL scheme. Probed via the injected `canOpenApp` to detect an
|
|
150
|
+
* installed Commons on the same device; the `oxycommons://approve?...` deep link
|
|
151
|
+
* itself is the flow's `qrPayload`.
|
|
152
|
+
*/
|
|
153
|
+
const COMMONS_APP_SCHEME = 'oxycommons://';
|
|
154
|
+
|
|
138
155
|
const IDLE_SIGN_IN: SignInFlowState = {
|
|
139
156
|
phase: 'idle',
|
|
140
157
|
authorizeCode: null,
|
|
@@ -160,6 +177,7 @@ export class AccountDialogController {
|
|
|
160
177
|
private readonly authRedirectUri: string | null;
|
|
161
178
|
private readonly pollIntervalMs: number;
|
|
162
179
|
private readonly openUrl?: (url: string) => void;
|
|
180
|
+
private readonly canOpenApp?: (url: string) => Promise<boolean>;
|
|
163
181
|
|
|
164
182
|
private readonly listeners = new Set<SnapshotListener>();
|
|
165
183
|
|
|
@@ -197,6 +215,7 @@ export class AccountDialogController {
|
|
|
197
215
|
this.authRedirectUri = options.authRedirectUri ?? null;
|
|
198
216
|
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
199
217
|
this.openUrl = options.openUrl;
|
|
218
|
+
this.canOpenApp = options.canOpenApp;
|
|
200
219
|
this.snapshot = this.computeSnapshot();
|
|
201
220
|
}
|
|
202
221
|
|
|
@@ -534,11 +553,37 @@ export class AccountDialogController {
|
|
|
534
553
|
error: null,
|
|
535
554
|
});
|
|
536
555
|
this.scheduleNextPoll(handle.sessionToken);
|
|
556
|
+
// Same-device convenience: if Commons is installed (native only — `canOpenApp`
|
|
557
|
+
// is undefined/false on web), deep-link straight into its approve screen with
|
|
558
|
+
// the same `oxycommons://approve?...` payload the QR encodes. The QR + polling
|
|
559
|
+
// stay live as the fallback, so a user who dismisses the app-open still
|
|
560
|
+
// completes the sign-in by scanning.
|
|
561
|
+
void this.maybeOpenCommons(handle.qrPayload);
|
|
537
562
|
} catch (error) {
|
|
538
563
|
this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: errorMessage(error) });
|
|
539
564
|
}
|
|
540
565
|
}
|
|
541
566
|
|
|
567
|
+
/**
|
|
568
|
+
* When a `canOpenApp` probe is injected and reports Commons installed, open the
|
|
569
|
+
* approve deep link via the injected `openUrl`. Best-effort and non-blocking: a
|
|
570
|
+
* probe/open failure is logged and swallowed — the QR/polling fallback remains.
|
|
571
|
+
*/
|
|
572
|
+
private async maybeOpenCommons(qrPayload: string): Promise<void> {
|
|
573
|
+
if (!this.canOpenApp || !this.openUrl) return;
|
|
574
|
+
try {
|
|
575
|
+
if (await this.canOpenApp(COMMONS_APP_SCHEME)) {
|
|
576
|
+
this.openUrl(qrPayload);
|
|
577
|
+
}
|
|
578
|
+
} catch (error) {
|
|
579
|
+
logger.debug(
|
|
580
|
+
'[AccountDialogController] Commons deep-link probe failed (QR fallback active)',
|
|
581
|
+
{ component: 'AccountDialogController' },
|
|
582
|
+
error,
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
542
587
|
/** Tear down the active sign-in device flow (timers + token) and reset to idle. */
|
|
543
588
|
cancelSignIn(): void {
|
|
544
589
|
this.clearPollTimer();
|
|
@@ -638,7 +683,14 @@ export class AccountDialogController {
|
|
|
638
683
|
|
|
639
684
|
private async claimAndComplete(sessionId: string, sessionToken: string): Promise<void> {
|
|
640
685
|
this.setSignIn({ ...this.signIn, phase: 'authorized' });
|
|
641
|
-
let claimed: {
|
|
686
|
+
let claimed: {
|
|
687
|
+
accessToken: string;
|
|
688
|
+
sessionId: string;
|
|
689
|
+
deviceId: string;
|
|
690
|
+
expiresAt: string;
|
|
691
|
+
user: User;
|
|
692
|
+
deviceSecret?: string;
|
|
693
|
+
};
|
|
642
694
|
try {
|
|
643
695
|
claimed = await this.oxyServices.claimSessionByToken(sessionToken);
|
|
644
696
|
} catch (error) {
|
|
@@ -666,6 +718,7 @@ export class AccountDialogController {
|
|
|
666
718
|
expiresAt: claimed.expiresAt ?? '',
|
|
667
719
|
user: minimalUser,
|
|
668
720
|
accessToken: claimed.accessToken,
|
|
721
|
+
...(claimed.deviceSecret ? { deviceSecret: claimed.deviceSecret } : {}),
|
|
669
722
|
},
|
|
670
723
|
minimalUser,
|
|
671
724
|
);
|