@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/i18n/locales/ar-SA.json +4 -1
- package/dist/cjs/i18n/locales/ca-ES.json +4 -1
- package/dist/cjs/i18n/locales/de-DE.json +4 -1
- package/dist/cjs/i18n/locales/en-US.json +4 -1
- package/dist/cjs/i18n/locales/es-ES.json +4 -1
- package/dist/cjs/i18n/locales/fr-FR.json +4 -1
- package/dist/cjs/i18n/locales/it-IT.json +4 -1
- package/dist/cjs/i18n/locales/ja-JP.json +4 -1
- package/dist/cjs/i18n/locales/ko-KR.json +4 -1
- package/dist/cjs/i18n/locales/locales/ar-SA.json +4 -1
- package/dist/cjs/i18n/locales/locales/ca-ES.json +4 -1
- package/dist/cjs/i18n/locales/locales/de-DE.json +4 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +4 -1
- package/dist/cjs/i18n/locales/locales/es-ES.json +4 -1
- package/dist/cjs/i18n/locales/locales/fr-FR.json +4 -1
- package/dist/cjs/i18n/locales/locales/it-IT.json +4 -1
- package/dist/cjs/i18n/locales/locales/ja-JP.json +4 -1
- package/dist/cjs/i18n/locales/locales/ko-KR.json +4 -1
- package/dist/cjs/i18n/locales/locales/pt-PT.json +4 -1
- package/dist/cjs/i18n/locales/locales/zh-CN.json +4 -1
- package/dist/cjs/i18n/locales/pt-PT.json +4 -1
- package/dist/cjs/i18n/locales/zh-CN.json +4 -1
- package/dist/cjs/session/accountDialogController.js +112 -2
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/i18n/locales/ar-SA.json +4 -1
- package/dist/esm/i18n/locales/ca-ES.json +4 -1
- package/dist/esm/i18n/locales/de-DE.json +4 -1
- package/dist/esm/i18n/locales/en-US.json +4 -1
- package/dist/esm/i18n/locales/es-ES.json +4 -1
- package/dist/esm/i18n/locales/fr-FR.json +4 -1
- package/dist/esm/i18n/locales/it-IT.json +4 -1
- package/dist/esm/i18n/locales/ja-JP.json +4 -1
- package/dist/esm/i18n/locales/ko-KR.json +4 -1
- package/dist/esm/i18n/locales/locales/ar-SA.json +4 -1
- package/dist/esm/i18n/locales/locales/ca-ES.json +4 -1
- package/dist/esm/i18n/locales/locales/de-DE.json +4 -1
- package/dist/esm/i18n/locales/locales/en-US.json +4 -1
- package/dist/esm/i18n/locales/locales/es-ES.json +4 -1
- package/dist/esm/i18n/locales/locales/fr-FR.json +4 -1
- package/dist/esm/i18n/locales/locales/it-IT.json +4 -1
- package/dist/esm/i18n/locales/locales/ja-JP.json +4 -1
- package/dist/esm/i18n/locales/locales/ko-KR.json +4 -1
- package/dist/esm/i18n/locales/locales/pt-PT.json +4 -1
- package/dist/esm/i18n/locales/locales/zh-CN.json +4 -1
- package/dist/esm/i18n/locales/pt-PT.json +4 -1
- package/dist/esm/i18n/locales/zh-CN.json +4 -1
- package/dist/esm/session/accountDialogController.js +112 -2
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/session/accountDialogController.d.ts +81 -1
- package/package.json +4 -4
- package/src/i18n/locales/ar-SA.json +4 -1
- package/src/i18n/locales/ca-ES.json +4 -1
- package/src/i18n/locales/de-DE.json +4 -1
- package/src/i18n/locales/en-US.json +4 -1
- package/src/i18n/locales/es-ES.json +4 -1
- package/src/i18n/locales/fr-FR.json +4 -1
- package/src/i18n/locales/it-IT.json +4 -1
- package/src/i18n/locales/ja-JP.json +4 -1
- package/src/i18n/locales/ko-KR.json +4 -1
- package/src/i18n/locales/pt-PT.json +4 -1
- package/src/i18n/locales/zh-CN.json +4 -1
- package/src/index.ts +1 -0
- package/src/session/__tests__/accountDialogController.test.ts +133 -1
- 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
|
}
|