@oxyhq/core 12.1.1 → 12.2.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.
Files changed (66) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/i18n/locales/ar-SA.json +4 -1
  3. package/dist/cjs/i18n/locales/ca-ES.json +4 -1
  4. package/dist/cjs/i18n/locales/de-DE.json +4 -1
  5. package/dist/cjs/i18n/locales/en-US.json +4 -1
  6. package/dist/cjs/i18n/locales/es-ES.json +4 -1
  7. package/dist/cjs/i18n/locales/fr-FR.json +4 -1
  8. package/dist/cjs/i18n/locales/it-IT.json +4 -1
  9. package/dist/cjs/i18n/locales/ja-JP.json +4 -1
  10. package/dist/cjs/i18n/locales/ko-KR.json +4 -1
  11. package/dist/cjs/i18n/locales/locales/ar-SA.json +4 -1
  12. package/dist/cjs/i18n/locales/locales/ca-ES.json +4 -1
  13. package/dist/cjs/i18n/locales/locales/de-DE.json +4 -1
  14. package/dist/cjs/i18n/locales/locales/en-US.json +4 -1
  15. package/dist/cjs/i18n/locales/locales/es-ES.json +4 -1
  16. package/dist/cjs/i18n/locales/locales/fr-FR.json +4 -1
  17. package/dist/cjs/i18n/locales/locales/it-IT.json +4 -1
  18. package/dist/cjs/i18n/locales/locales/ja-JP.json +4 -1
  19. package/dist/cjs/i18n/locales/locales/ko-KR.json +4 -1
  20. package/dist/cjs/i18n/locales/locales/pt-PT.json +4 -1
  21. package/dist/cjs/i18n/locales/locales/zh-CN.json +4 -1
  22. package/dist/cjs/i18n/locales/pt-PT.json +4 -1
  23. package/dist/cjs/i18n/locales/zh-CN.json +4 -1
  24. package/dist/cjs/session/accountDialogController.js +112 -2
  25. package/dist/esm/.tsbuildinfo +1 -1
  26. package/dist/esm/i18n/locales/ar-SA.json +4 -1
  27. package/dist/esm/i18n/locales/ca-ES.json +4 -1
  28. package/dist/esm/i18n/locales/de-DE.json +4 -1
  29. package/dist/esm/i18n/locales/en-US.json +4 -1
  30. package/dist/esm/i18n/locales/es-ES.json +4 -1
  31. package/dist/esm/i18n/locales/fr-FR.json +4 -1
  32. package/dist/esm/i18n/locales/it-IT.json +4 -1
  33. package/dist/esm/i18n/locales/ja-JP.json +4 -1
  34. package/dist/esm/i18n/locales/ko-KR.json +4 -1
  35. package/dist/esm/i18n/locales/locales/ar-SA.json +4 -1
  36. package/dist/esm/i18n/locales/locales/ca-ES.json +4 -1
  37. package/dist/esm/i18n/locales/locales/de-DE.json +4 -1
  38. package/dist/esm/i18n/locales/locales/en-US.json +4 -1
  39. package/dist/esm/i18n/locales/locales/es-ES.json +4 -1
  40. package/dist/esm/i18n/locales/locales/fr-FR.json +4 -1
  41. package/dist/esm/i18n/locales/locales/it-IT.json +4 -1
  42. package/dist/esm/i18n/locales/locales/ja-JP.json +4 -1
  43. package/dist/esm/i18n/locales/locales/ko-KR.json +4 -1
  44. package/dist/esm/i18n/locales/locales/pt-PT.json +4 -1
  45. package/dist/esm/i18n/locales/locales/zh-CN.json +4 -1
  46. package/dist/esm/i18n/locales/pt-PT.json +4 -1
  47. package/dist/esm/i18n/locales/zh-CN.json +4 -1
  48. package/dist/esm/session/accountDialogController.js +112 -2
  49. package/dist/types/.tsbuildinfo +1 -1
  50. package/dist/types/index.d.ts +1 -1
  51. package/dist/types/session/accountDialogController.d.ts +81 -1
  52. package/package.json +4 -4
  53. package/src/i18n/locales/ar-SA.json +4 -1
  54. package/src/i18n/locales/ca-ES.json +4 -1
  55. package/src/i18n/locales/de-DE.json +4 -1
  56. package/src/i18n/locales/en-US.json +4 -1
  57. package/src/i18n/locales/es-ES.json +4 -1
  58. package/src/i18n/locales/fr-FR.json +4 -1
  59. package/src/i18n/locales/it-IT.json +4 -1
  60. package/src/i18n/locales/ja-JP.json +4 -1
  61. package/src/i18n/locales/ko-KR.json +4 -1
  62. package/src/i18n/locales/pt-PT.json +4 -1
  63. package/src/i18n/locales/zh-CN.json +4 -1
  64. package/src/index.ts +1 -0
  65. package/src/session/__tests__/accountDialogController.test.ts +133 -1
  66. package/src/session/accountDialogController.ts +145 -2
@@ -24,6 +24,12 @@
24
24
  * - `commonsAvailability` — whether Commons is installed on this device
25
25
  * (native only, via the injected `canOpenApp` probe), so the QR view can
26
26
  * offer a "Get Commons" fallback instead of a same-device dead end.
27
+ * - `startPasskeyHubSignIn` — on a non-Oxy web origin (where a WebAuthn
28
+ * ceremony can't run locally, the RP-ID is bound to oxy.so), open a popup
29
+ * at the auth.oxy.so passkey hub instead, scoped to the SAME device-flow
30
+ * session `showQr` creates, and complete it via the SAME poll/socket/claim
31
+ * engine. Falls back to `showQr`'s plain QR rendering if the popup is
32
+ * blocked.
27
33
  *
28
34
  * Sign-in is passkey (WebAuthn) or the Commons QR / shared-keychain handoff —
29
35
  * password, social login, and 2FA were removed ecosystem-wide. Account
@@ -36,6 +42,7 @@ import type { SessionLoginResponse, MinimalUserData } from '../models/session';
36
42
  import type { User } from '../models/interfaces';
37
43
  import { logger } from '../logger';
38
44
  import { extractErrorStatus } from '../utils/errorUtils';
45
+ import { CENTRAL_IDP_APEX } from '../utils/authWebUrl';
39
46
  import type { SessionClient } from './SessionClient';
40
47
  import type { MinimalSocket, SocketIOFactory } from './socketLoader';
41
48
  import {
@@ -44,6 +51,7 @@ import {
44
51
  type SwitchableAccount,
45
52
  } from './accountProjection';
46
53
  import type { AccountNode } from '../mixins/OxyServices.accounts';
54
+ import type { CommonsSignInHandle } from '../mixins/OxyServices.auth';
47
55
 
48
56
  /** The dialog's top-level view. */
49
57
  export type AccountDialogView = 'accounts' | 'signin' | 'qr' | 'add' | 'signup';
@@ -64,6 +72,20 @@ export type CommonsAvailability = 'unknown' | 'checking' | 'available' | 'unavai
64
72
  /** Lifecycle phase of the "Sign in with Oxy" device flow. */
65
73
  export type SignInFlowPhase = 'idle' | 'starting' | 'waiting' | 'authorized' | 'error';
66
74
 
75
+ /**
76
+ * Minimal structural handle over a popup `Window` — just enough for the
77
+ * cross-origin passkey hub flow ({@link AccountDialogController.startPasskeyHubSignIn}):
78
+ * navigate it once the device-flow session's `authorizeCode` is known, detect
79
+ * the user closing it early (`closed`), and close it programmatically on
80
+ * completion/failure. Satisfied directly by `window.open()`'s return value on
81
+ * web; native has no popup concept and never injects an opener.
82
+ */
83
+ export interface PopupWindowHandle {
84
+ readonly closed: boolean;
85
+ close(): void;
86
+ location: { href: string };
87
+ }
88
+
67
89
  /** State of the "Sign in with Oxy" (shared-key / QR) device flow. */
68
90
  export interface SignInFlowState {
69
91
  phase: SignInFlowPhase;
@@ -181,6 +203,20 @@ export interface AccountDialogControllerOptions {
181
203
  * `showQr` behaves exactly as before (render QR only).
182
204
  */
183
205
  canOpenApp?: (url: string) => Promise<boolean>;
206
+ /**
207
+ * Open a new, empty popup window SYNCHRONOUSLY (before any `await`) so the
208
+ * browser attributes it to the click gesture that triggered it, returning a
209
+ * handle to navigate once the device-flow session is known — or `null` if
210
+ * the popup was blocked. Injected by the provider (web only:
211
+ * `window.open('', ...)`; absent on native, where the hub-popup flow does
212
+ * not apply). Used by {@link AccountDialogController.startPasskeyHubSignIn}.
213
+ */
214
+ openPopup?: () => PopupWindowHandle | null;
215
+ /**
216
+ * Base origin of the auth.oxy.so passkey hub (defaults to
217
+ * `https://auth.${CENTRAL_IDP_APEX}`). Overridable for local/staging testing.
218
+ */
219
+ hubBaseUrl?: string;
184
220
  }
185
221
 
186
222
  /**
@@ -227,6 +263,8 @@ export class AccountDialogController {
227
263
  private readonly openUrl?: (url: string) => void;
228
264
  private readonly canOpenApp?: (url: string) => Promise<boolean>;
229
265
  private readonly socketFactory?: SocketIOFactory;
266
+ private readonly openPopup?: () => PopupWindowHandle | null;
267
+ private readonly hubBaseUrl: string;
230
268
 
231
269
  private readonly listeners = new Set<SnapshotListener>();
232
270
 
@@ -256,6 +294,10 @@ export class AccountDialogController {
256
294
  * single-use token concurrently.
257
295
  */
258
296
  private pollInFlight = false;
297
+ /** The popup opened by {@link startPasskeyHubSignIn}, or `null`. */
298
+ private activePopup: PopupWindowHandle | null = null;
299
+ /** Watches {@link activePopup}'s `closed` state; see {@link watchPopup}. */
300
+ private popupWatchTimer: ReturnType<typeof setInterval> | null = null;
259
301
 
260
302
  // --- Store plumbing ---
261
303
  private unsubscribeSession: (() => void) | null = null;
@@ -278,6 +320,8 @@ export class AccountDialogController {
278
320
  this.openUrl = options.openUrl;
279
321
  this.canOpenApp = options.canOpenApp;
280
322
  this.socketFactory = options.socketFactory;
323
+ this.openPopup = options.openPopup;
324
+ this.hubBaseUrl = options.hubBaseUrl ?? `https://auth.${CENTRAL_IDP_APEX}`;
281
325
  this.snapshot = this.computeSnapshot();
282
326
  }
283
327
 
@@ -356,6 +400,7 @@ export class AccountDialogController {
356
400
  }
357
401
  this.clearPollTimer();
358
402
  this.closeAuthSessionSocket();
403
+ this.closeActivePopup();
359
404
  this.signInToken = null;
360
405
  this.listeners.clear();
361
406
  }
@@ -630,9 +675,64 @@ export class AccountDialogController {
630
675
  async showQr(): Promise<void> {
631
676
  this.cancelSignIn();
632
677
  this.setView('qr');
678
+ await this.startDeviceFlowSession();
679
+ }
680
+
681
+ /**
682
+ * Web-only: "Sign in with a passkey" on a non-Oxy origin cannot run the
683
+ * WebAuthn ceremony locally — a credential minted with `WEBAUTHN_RP_ID=oxy.so`
684
+ * can only be asserted from `oxy.so`/a subdomain/loopback, a browser-enforced
685
+ * boundary `isOxyRpOrigin()` (consumer-side) already gates on. Instead, open
686
+ * a popup at the auth.oxy.so passkey hub, scoped to the SAME device-flow
687
+ * session {@link showQr} would create (same `authorizeCode`/`sessionToken`
688
+ * pair), and let the SAME poll/socket/claim engine complete it once the hub
689
+ * authorizes the session (`POST /auth/session/authorize-code/:authorizeCode`,
690
+ * bearer-authed, server-side — this method never sees or transmits the
691
+ * secret `sessionToken`).
692
+ *
693
+ * The popup MUST be opened synchronously, before any `await`, or the
694
+ * browser's popup blocker loses the click-gesture attribution and silently
695
+ * blocks it — so {@link openPopup} is invoked as the very first step, before
696
+ * the device-flow session (which needs an async call) even exists. If the
697
+ * popup is blocked (or no `openPopup` was injected), this falls back to
698
+ * `showQr`'s plain QR rendering — no dead end.
699
+ */
700
+ async startPasskeyHubSignIn(): Promise<void> {
701
+ this.cancelSignIn();
702
+ this.setView('qr');
703
+ const popup = this.openPopup?.() ?? null;
704
+ if (!popup) {
705
+ await this.startDeviceFlowSession();
706
+ return;
707
+ }
708
+ const handle = await this.startDeviceFlowSession();
709
+ if (!handle) {
710
+ popup.close();
711
+ return;
712
+ }
713
+ // The user may have closed the popup during the async session creation
714
+ // above, before any watcher was attached to catch it — guard rather than
715
+ // navigate a dead window (browsers vary on whether that throws).
716
+ if (popup.closed) {
717
+ this.failSignIn('Sign-in was cancelled.');
718
+ return;
719
+ }
720
+ this.activePopup = popup;
721
+ popup.location.href = `${this.hubBaseUrl}/hub-passkey?code=${encodeURIComponent(handle.authorizeCode)}`;
722
+ this.watchPopup(popup);
723
+ }
724
+
725
+ /**
726
+ * Shared device-flow session creation for both {@link showQr} (renders the
727
+ * QR) and {@link startPasskeyHubSignIn} (also opens the hub popup) — the
728
+ * same `startCommonsSignIn` → poll/socket wiring either way. Returns the
729
+ * handle on success (already reflected in `signIn`), or `null` on failure
730
+ * (already set as `signIn.error`).
731
+ */
732
+ private async startDeviceFlowSession(): Promise<CommonsSignInHandle | null> {
633
733
  if (!this.clientId) {
634
734
  this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: 'This app is not configured for sign-in (missing clientId).' });
635
- return;
735
+ return null;
636
736
  }
637
737
  this.setSignIn({ ...IDLE_SIGN_IN, phase: 'starting' });
638
738
  try {
@@ -657,8 +757,48 @@ export class AccountDialogController {
657
757
  // stay live as the fallback, so a user who dismisses the app-open still
658
758
  // completes the sign-in by scanning.
659
759
  void this.deepLinkIntoCommonsIfAvailable(handle.qrPayload);
760
+ return handle;
660
761
  } catch (error) {
661
762
  this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: errorMessage(error) });
763
+ return null;
764
+ }
765
+ }
766
+
767
+ /**
768
+ * Poll {@link PopupWindowHandle.closed} so a user who dismisses the hub
769
+ * popup without completing sign-in gets prompt feedback (there is no DOM
770
+ * event for a cross-origin popup closing — polling is the only mechanism).
771
+ * Cleared by {@link closeActivePopup}, which every sign-in exit path already
772
+ * calls.
773
+ */
774
+ private watchPopup(popup: PopupWindowHandle): void {
775
+ this.clearPopupWatchTimer();
776
+ this.popupWatchTimer = setInterval(() => {
777
+ if (!popup.closed) return;
778
+ this.clearPopupWatchTimer();
779
+ if (this.signIn.phase === 'starting' || this.signIn.phase === 'waiting') {
780
+ this.failSignIn('Sign-in was cancelled.');
781
+ }
782
+ }, 1000);
783
+ }
784
+
785
+ private clearPopupWatchTimer(): void {
786
+ if (this.popupWatchTimer !== null) {
787
+ clearInterval(this.popupWatchTimer);
788
+ this.popupWatchTimer = null;
789
+ }
790
+ }
791
+
792
+ /** Tear down the popup watch timer and close the popup, if any. Idempotent. */
793
+ private closeActivePopup(): void {
794
+ this.clearPopupWatchTimer();
795
+ const popup = this.activePopup;
796
+ this.activePopup = null;
797
+ if (!popup || popup.closed) return;
798
+ try {
799
+ popup.close();
800
+ } catch (error) {
801
+ logger.debug('[AccountDialogController] popup close failed', { component: 'AccountDialogController' }, error);
662
802
  }
663
803
  }
664
804
 
@@ -712,10 +852,11 @@ export class AccountDialogController {
712
852
  }
713
853
  }
714
854
 
715
- /** Tear down the active sign-in device flow (timers + socket + token) and reset to idle. */
855
+ /** Tear down the active sign-in device flow (timers + socket + popup + token) and reset to idle. */
716
856
  cancelSignIn(): void {
717
857
  this.clearPollTimer();
718
858
  this.closeAuthSessionSocket();
859
+ this.closeActivePopup();
719
860
  this.signInToken = null;
720
861
  if (this.signIn !== IDLE_SIGN_IN) {
721
862
  this.setSignIn(IDLE_SIGN_IN);
@@ -836,6 +977,7 @@ export class AccountDialogController {
836
977
  this.signInToken = null;
837
978
  this.clearPollTimer();
838
979
  this.closeAuthSessionSocket();
980
+ this.closeActivePopup();
839
981
  this.signIn = IDLE_SIGN_IN;
840
982
  this.view = 'accounts';
841
983
  this.emit();
@@ -872,6 +1014,7 @@ export class AccountDialogController {
872
1014
  private failSignIn(message: string): void {
873
1015
  this.clearPollTimer();
874
1016
  this.closeAuthSessionSocket();
1017
+ this.closeActivePopup();
875
1018
  this.signInToken = null;
876
1019
  this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: message });
877
1020
  }