@oxyhq/core 11.0.1 → 12.1.0
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/index.js +7 -5
- package/dist/cjs/mixins/OxyServices.auth.js +6 -117
- package/dist/cjs/mixins/OxyServices.identity.js +16 -12
- package/dist/cjs/session/accountDialogController.js +84 -75
- package/dist/cjs/utils/officialOrigins.js +6 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/index.js +6 -5
- package/dist/esm/mixins/OxyServices.auth.js +6 -117
- package/dist/esm/mixins/OxyServices.identity.js +16 -12
- package/dist/esm/session/accountDialogController.js +84 -75
- package/dist/esm/utils/officialOrigins.js +6 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +3 -3
- package/dist/types/mixins/OxyServices.auth.d.ts +4 -48
- package/dist/types/mixins/OxyServices.identity.d.ts +13 -9
- package/dist/types/session/accountDialogController.d.ts +84 -49
- package/dist/types/utils/officialOrigins.d.ts +6 -0
- package/package.json +2 -2
- package/src/index.ts +7 -4
- package/src/mixins/OxyServices.auth.ts +6 -144
- package/src/mixins/OxyServices.identity.ts +19 -15
- package/src/mixins/__tests__/OxyServices.identity.test.ts +26 -14
- package/src/mixins/__tests__/webauthnAuth.test.ts +1 -1
- package/src/session/__tests__/accountDialogController.test.ts +75 -64
- package/src/session/__tests__/accountProjection.test.ts +30 -0
- package/src/session/accountDialogController.ts +131 -104
- package/src/utils/__tests__/officialOrigins.test.ts +14 -0
- package/src/utils/officialOrigins.ts +6 -1
- package/src/mixins/__tests__/passwordSignIn.test.ts +0 -115
|
@@ -12,18 +12,23 @@
|
|
|
12
12
|
* - the unified account list (via {@link projectSwitchableAccounts}), fetched
|
|
13
13
|
* from `SessionClient` state ∪ `oxyServices.listAccounts()` and hydrated
|
|
14
14
|
* with `oxyServices.getUsersByIds()`;
|
|
15
|
-
* - the dialog `view` state machine (`accounts` | `signin` | `qr` | `add`
|
|
15
|
+
* - the dialog `view` state machine (`accounts` | `signin` | `qr` | `add` |
|
|
16
|
+
* `signup`);
|
|
16
17
|
* - `switchTo` (the uniform switch: `SessionClient.switchAccount` for an
|
|
17
18
|
* account already on the device, `oxyServices.switchToAccount` to mint on
|
|
18
19
|
* first entry into a graph account — reusing the existing SDK primitives, no
|
|
19
20
|
* new switch path);
|
|
20
21
|
* - the "Sign in with Oxy" device flow (same-device shared-keychain via
|
|
21
22
|
* `oxyServices.signInWithSharedIdentity`, else the cross-device QR handoff
|
|
22
|
-
* via `startCommonsSignIn` → poll → `claimSessionByToken`)
|
|
23
|
+
* via `startCommonsSignIn` → poll → `claimSessionByToken`);
|
|
24
|
+
* - `commonsAvailability` — whether Commons is installed on this device
|
|
25
|
+
* (native only, via the injected `canOpenApp` probe), so the QR view can
|
|
26
|
+
* offer a "Get Commons" fallback instead of a same-device dead end.
|
|
23
27
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
28
|
+
* Sign-in is passkey (WebAuthn) or the Commons QR / shared-keychain handoff —
|
|
29
|
+
* password, social login, and 2FA were removed ecosystem-wide. Account
|
|
30
|
+
* creation (`signup` view) is the same two identity backends: a passkey
|
|
31
|
+
* ceremony on web, or a Commons-created identity.
|
|
27
32
|
*/
|
|
28
33
|
|
|
29
34
|
import type { OxyServices } from '../OxyServices';
|
|
@@ -31,13 +36,6 @@ import type { SessionLoginResponse, MinimalUserData } from '../models/session';
|
|
|
31
36
|
import type { User } from '../models/interfaces';
|
|
32
37
|
import { logger } from '../logger';
|
|
33
38
|
import { extractErrorStatus } from '../utils/errorUtils';
|
|
34
|
-
import { CENTRAL_IDP_APEX } from '../utils/authWebUrl';
|
|
35
|
-
import {
|
|
36
|
-
generateOAuthState,
|
|
37
|
-
generatePkcePair,
|
|
38
|
-
normalizeOAuthRedirectUri,
|
|
39
|
-
persistOAuthHandshake,
|
|
40
|
-
} from '../utils/oauthPkce';
|
|
41
39
|
import type { SessionClient } from './SessionClient';
|
|
42
40
|
import type { MinimalSocket, SocketIOFactory } from './socketLoader';
|
|
43
41
|
import {
|
|
@@ -48,7 +46,20 @@ import {
|
|
|
48
46
|
import type { AccountNode } from '../mixins/OxyServices.accounts';
|
|
49
47
|
|
|
50
48
|
/** The dialog's top-level view. */
|
|
51
|
-
export type AccountDialogView = 'accounts' | 'signin' | 'qr' | 'add';
|
|
49
|
+
export type AccountDialogView = 'accounts' | 'signin' | 'qr' | 'add' | 'signup';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Whether Commons is installed on this device, as resolved by the injected
|
|
53
|
+
* `canOpenApp` probe:
|
|
54
|
+
* - `'unknown'` — not yet probed, OR no probe was injected (web — there is
|
|
55
|
+
* no API to ask a browser whether a custom URL scheme is registered, so
|
|
56
|
+
* this stays `'unknown'` forever there and the QR view renders
|
|
57
|
+
* unconditionally, no gating).
|
|
58
|
+
* - `'checking'` — the probe is in flight.
|
|
59
|
+
* - `'available'` / `'unavailable'` — the probe's resolved terminal answer
|
|
60
|
+
* (native only). A probe error is treated as `'unavailable'` (fail-closed).
|
|
61
|
+
*/
|
|
62
|
+
export type CommonsAvailability = 'unknown' | 'checking' | 'available' | 'unavailable';
|
|
52
63
|
|
|
53
64
|
/** Lifecycle phase of the "Sign in with Oxy" device flow. */
|
|
54
65
|
export type SignInFlowPhase = 'idle' | 'starting' | 'waiting' | 'authorized' | 'error';
|
|
@@ -88,6 +99,8 @@ export interface AccountDialogSnapshot {
|
|
|
88
99
|
switchingAccountId: string | null;
|
|
89
100
|
/** The "Sign in with Oxy" device-flow state. */
|
|
90
101
|
signIn: SignInFlowState;
|
|
102
|
+
/** Whether Commons is installed on this device. See {@link CommonsAvailability}. */
|
|
103
|
+
commonsAvailability: CommonsAvailability;
|
|
91
104
|
}
|
|
92
105
|
|
|
93
106
|
/** Construction options for {@link AccountDialogController}. */
|
|
@@ -106,25 +119,38 @@ export interface AccountDialogControllerOptions {
|
|
|
106
119
|
/** Locale for display-name resolution. */
|
|
107
120
|
locale?: string;
|
|
108
121
|
/**
|
|
109
|
-
* Commit a freshly-authorized session (device flow / shared identity
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
122
|
+
* Commit a freshly-authorized SIGN-IN session (device flow / shared identity)
|
|
123
|
+
* into the host's session set — device-first registration + durable persist +
|
|
124
|
+
* profile hydration. The consumer supplies its provider's commit path
|
|
125
|
+
* (`useOxy().handleWebSession` / the auth-sdk equivalent). Called AFTER the SDK
|
|
126
|
+
* has planted the access token. When omitted the controller falls back to
|
|
127
|
+
* `SessionClient.registerAndActivate` (registration + activation only — no
|
|
128
|
+
* provider-side durable persist/hydration).
|
|
129
|
+
*
|
|
130
|
+
* This is the SIGN-IN commit: on an official web origin it may run the
|
|
131
|
+
* cross-origin hub-sync (a full-page redirect to `auth.oxy.so/sync`) that
|
|
132
|
+
* bootstraps silent OAuth restore on OTHER origins. A first sign-in on a web
|
|
133
|
+
* origin legitimately needs that. An account SWITCH does NOT — see
|
|
134
|
+
* {@link commitSwitchedSession}.
|
|
116
135
|
*/
|
|
117
136
|
commitSession?: (session: SessionLoginResponse) => Promise<void>;
|
|
118
|
-
/** Notified after a completed sign-in (bearer planted + session committed). */
|
|
119
|
-
onSignedIn?: (user: MinimalUserData) => void;
|
|
120
|
-
/** Central IdP apex for `openPasswordAtOxyAuth` (defaults to `CENTRAL_IDP_APEX`). */
|
|
121
|
-
idpApex?: string;
|
|
122
137
|
/**
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
138
|
+
* Commit a minted graph SWITCH session into the host's session set — same
|
|
139
|
+
* device-first registration + durable persist + profile hydration as
|
|
140
|
+
* {@link commitSession}, but IN-PLACE: it must NOT trigger the cross-origin
|
|
141
|
+
* hub-sync redirect. Switching into an account you already operate reuses the
|
|
142
|
+
* device credential that was already hub-synced at the original sign-in, so
|
|
143
|
+
* re-syncing is redundant and a full-page redirect on switch is the exact
|
|
144
|
+
* regression this separation prevents. Cross-tab/app propagation of the switch
|
|
145
|
+
* still happens instantly via the server's device-scoped `session_state` /
|
|
146
|
+
* `session_accounts_changed` socket broadcast — no navigation required.
|
|
147
|
+
*
|
|
148
|
+
* When omitted the controller falls back to {@link commitSession} (if wired)
|
|
149
|
+
* and then to `SessionClient.registerAndActivate`.
|
|
126
150
|
*/
|
|
127
|
-
|
|
151
|
+
commitSwitchedSession?: (session: SessionLoginResponse) => Promise<void>;
|
|
152
|
+
/** Notified after a completed sign-in (bearer planted + session committed). */
|
|
153
|
+
onSignedIn?: (user: MinimalUserData) => void;
|
|
128
154
|
/**
|
|
129
155
|
* QR device-flow FALLBACK poll interval in ms (default 12000). The primary
|
|
130
156
|
* approval signal is the `/auth-session` socket's `auth_update` event (instant);
|
|
@@ -140,8 +166,8 @@ export interface AccountDialogControllerOptions {
|
|
|
140
166
|
*/
|
|
141
167
|
socketFactory?: SocketIOFactory;
|
|
142
168
|
/**
|
|
143
|
-
* Optional URL opener. When provided,
|
|
144
|
-
* the
|
|
169
|
+
* Optional URL opener. When provided, the controller invokes it to deep-link
|
|
170
|
+
* the Commons app for the QR handoff (web: `location.assign`; native:
|
|
145
171
|
* `Linking.openURL`). Headless core never touches `window`/`Linking` itself.
|
|
146
172
|
*/
|
|
147
173
|
openUrl?: (url: string) => void;
|
|
@@ -195,9 +221,8 @@ export class AccountDialogController {
|
|
|
195
221
|
private readonly clientId: string | null;
|
|
196
222
|
private readonly locale?: string;
|
|
197
223
|
private readonly commitSession?: (session: SessionLoginResponse) => Promise<void>;
|
|
224
|
+
private readonly commitSwitchedSession?: (session: SessionLoginResponse) => Promise<void>;
|
|
198
225
|
private readonly onSignedIn?: (user: MinimalUserData) => void;
|
|
199
|
-
private readonly idpApex: string;
|
|
200
|
-
private readonly authRedirectUri: string | null;
|
|
201
226
|
private readonly pollIntervalMs: number;
|
|
202
227
|
private readonly openUrl?: (url: string) => void;
|
|
203
228
|
private readonly canOpenApp?: (url: string) => Promise<boolean>;
|
|
@@ -213,6 +238,7 @@ export class AccountDialogController {
|
|
|
213
238
|
private error: string | null = null;
|
|
214
239
|
private switchingAccountId: string | null = null;
|
|
215
240
|
private signIn: SignInFlowState = IDLE_SIGN_IN;
|
|
241
|
+
private commonsAvailability: CommonsAvailability = 'unknown';
|
|
216
242
|
|
|
217
243
|
// --- Sign-in device-flow bookkeeping ---
|
|
218
244
|
/** The secret device-flow token of the active QR flow (never surfaced). */
|
|
@@ -246,9 +272,8 @@ export class AccountDialogController {
|
|
|
246
272
|
this.clientId = options.clientId ?? null;
|
|
247
273
|
this.locale = options.locale;
|
|
248
274
|
this.commitSession = options.commitSession;
|
|
275
|
+
this.commitSwitchedSession = options.commitSwitchedSession;
|
|
249
276
|
this.onSignedIn = options.onSignedIn;
|
|
250
|
-
this.idpApex = options.idpApex ?? CENTRAL_IDP_APEX;
|
|
251
|
-
this.authRedirectUri = options.authRedirectUri ?? null;
|
|
252
277
|
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
253
278
|
this.openUrl = options.openUrl;
|
|
254
279
|
this.canOpenApp = options.canOpenApp;
|
|
@@ -308,6 +333,11 @@ export class AccountDialogController {
|
|
|
308
333
|
// bearer is already planted (warm start); when signed out (cold boot before
|
|
309
334
|
// restore) it re-projects from device state and makes NO private call.
|
|
310
335
|
void this.refresh();
|
|
336
|
+
// Eager, cached Commons-availability probe (native only — a no-op when no
|
|
337
|
+
// `canOpenApp` was injected). It's a cheap local OS check, so by the time a
|
|
338
|
+
// user actually opens the sign-in entry it has almost always resolved —
|
|
339
|
+
// `showQr`'s own lazy probe below is only the safety net for the rare race.
|
|
340
|
+
void this.resolveCommonsAvailability();
|
|
311
341
|
}
|
|
312
342
|
|
|
313
343
|
/**
|
|
@@ -398,6 +428,11 @@ export class AccountDialogController {
|
|
|
398
428
|
this.setView('add');
|
|
399
429
|
}
|
|
400
430
|
|
|
431
|
+
/** Switch to the "create account" view (passkey / Commons signup entry). */
|
|
432
|
+
startSignup(): void {
|
|
433
|
+
this.setView('signup');
|
|
434
|
+
}
|
|
435
|
+
|
|
401
436
|
// =========================================================================
|
|
402
437
|
// Account list
|
|
403
438
|
// =========================================================================
|
|
@@ -543,6 +578,10 @@ export class AccountDialogController {
|
|
|
543
578
|
accessToken: result.accessToken,
|
|
544
579
|
},
|
|
545
580
|
result.user,
|
|
581
|
+
// A switch is IN-PLACE: commit without the hub-sync redirect (the
|
|
582
|
+
// device is already known/synced). Cross-tab/app propagation rides the
|
|
583
|
+
// server's `session_state` socket broadcast, not a navigation.
|
|
584
|
+
{ fromSwitch: true },
|
|
546
585
|
);
|
|
547
586
|
}
|
|
548
587
|
// Re-project + refetch immediately; the subscription also fires.
|
|
@@ -611,34 +650,65 @@ export class AccountDialogController {
|
|
|
611
650
|
// connect, so it now runs at the slow fallback cadence.
|
|
612
651
|
this.openAuthSessionSocket(handle.sessionToken);
|
|
613
652
|
this.scheduleNextPoll(handle.sessionToken);
|
|
614
|
-
// Same-device convenience: if Commons is installed (native
|
|
615
|
-
//
|
|
616
|
-
//
|
|
653
|
+
// Same-device convenience: if Commons is confirmed installed (native
|
|
654
|
+
// only — stays `'unknown'` on web, where this never opens anything),
|
|
655
|
+
// deep-link straight into its approve screen with the same
|
|
656
|
+
// `oxycommons://approve?...` payload the QR encodes. The QR + polling
|
|
617
657
|
// stay live as the fallback, so a user who dismisses the app-open still
|
|
618
658
|
// completes the sign-in by scanning.
|
|
619
|
-
void this.
|
|
659
|
+
void this.deepLinkIntoCommonsIfAvailable(handle.qrPayload);
|
|
620
660
|
} catch (error) {
|
|
621
661
|
this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: errorMessage(error) });
|
|
622
662
|
}
|
|
623
663
|
}
|
|
624
664
|
|
|
625
665
|
/**
|
|
626
|
-
*
|
|
627
|
-
*
|
|
628
|
-
*
|
|
666
|
+
* Resolve whether Commons is installed on this device via the injected
|
|
667
|
+
* `canOpenApp` probe, updating {@link commonsAvailability} as durable,
|
|
668
|
+
* observable snapshot state. Native only — a no-op when `canOpenApp` was
|
|
669
|
+
* not injected (web), where `commonsAvailability` stays `'unknown'` forever
|
|
670
|
+
* and the QR view renders unconditionally (no gating).
|
|
671
|
+
*
|
|
672
|
+
* Replaces the old `maybeOpenCommons` fire-and-forget probe, whose outcome
|
|
673
|
+
* was only ever reflected by whether Commons silently opened — a probe
|
|
674
|
+
* failure or "not installed" answer was swallowed into a debug log with no
|
|
675
|
+
* way for the UI to react. `commonsAvailability` fixes that.
|
|
629
676
|
*/
|
|
630
|
-
private async
|
|
631
|
-
if (!this.canOpenApp
|
|
677
|
+
private async resolveCommonsAvailability(): Promise<void> {
|
|
678
|
+
if (!this.canOpenApp) return;
|
|
679
|
+
this.commonsAvailability = 'checking';
|
|
680
|
+
this.emit();
|
|
681
|
+
let available = false;
|
|
632
682
|
try {
|
|
633
|
-
|
|
634
|
-
this.openUrl(qrPayload);
|
|
635
|
-
}
|
|
683
|
+
available = await this.canOpenApp(COMMONS_APP_SCHEME);
|
|
636
684
|
} catch (error) {
|
|
637
685
|
logger.debug(
|
|
638
|
-
'[AccountDialogController] Commons
|
|
686
|
+
'[AccountDialogController] Commons availability probe failed',
|
|
639
687
|
{ component: 'AccountDialogController' },
|
|
640
688
|
error,
|
|
641
689
|
);
|
|
690
|
+
available = false; // fail-closed — treat a probe error as "not installed"
|
|
691
|
+
}
|
|
692
|
+
this.commonsAvailability = available ? 'available' : 'unavailable';
|
|
693
|
+
this.emit();
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* When Commons is confirmed installed, deep-link straight into its approve
|
|
698
|
+
* screen via the injected `openUrl` with the same `oxycommons://approve?...`
|
|
699
|
+
* payload the QR encodes. Best-effort and non-blocking — the QR/polling
|
|
700
|
+
* fallback stays live regardless of the outcome here.
|
|
701
|
+
*/
|
|
702
|
+
private async deepLinkIntoCommonsIfAvailable(qrPayload: string): Promise<void> {
|
|
703
|
+
if (!this.openUrl) return;
|
|
704
|
+
if (this.commonsAvailability === 'unknown' || this.commonsAvailability === 'checking') {
|
|
705
|
+
// The eager `start()` probe hasn't resolved yet (or was never run, e.g.
|
|
706
|
+
// `showQr` called without a prior `start()`) — resolve it now rather
|
|
707
|
+
// than skipping the deep link.
|
|
708
|
+
await this.resolveCommonsAvailability();
|
|
709
|
+
}
|
|
710
|
+
if (this.commonsAvailability === 'available') {
|
|
711
|
+
this.openUrl(qrPayload);
|
|
642
712
|
}
|
|
643
713
|
}
|
|
644
714
|
|
|
@@ -652,50 +722,6 @@ export class AccountDialogController {
|
|
|
652
722
|
}
|
|
653
723
|
}
|
|
654
724
|
|
|
655
|
-
/**
|
|
656
|
-
* Build (and, when an `openUrl` handler was supplied, open) the auth.oxy.so
|
|
657
|
-
* password sign-in URL. Password + 2FA are NOT in the SDK — they live at the
|
|
658
|
-
* IdP; this only hands off. Device-first: after login at the IdP the device
|
|
659
|
-
* session converges and the caller is woken via the device socket /
|
|
660
|
-
* `BroadcastChannel`, so the URL only needs to point at the IdP sign-in with
|
|
661
|
-
* the right return.
|
|
662
|
-
*
|
|
663
|
-
* @param params.returnUrl - Where the IdP returns after login. Defaults to the
|
|
664
|
-
* current document URL on web (`globalThis.location.href`); pass explicitly
|
|
665
|
-
* on native (no `location`).
|
|
666
|
-
* @param params.state - Optional opaque state echoed back on return.
|
|
667
|
-
* @returns The absolute auth.oxy.so sign-in URL.
|
|
668
|
-
*/
|
|
669
|
-
async openPasswordAtOxyAuth(
|
|
670
|
-
params: { returnUrl?: string; state?: string; redirectUri?: string } = {},
|
|
671
|
-
): Promise<string> {
|
|
672
|
-
const base = `https://auth.${this.idpApex}`;
|
|
673
|
-
const url = new URL('/login', base);
|
|
674
|
-
const rawRedirect =
|
|
675
|
-
params.redirectUri ??
|
|
676
|
-
this.authRedirectUri ??
|
|
677
|
-
params.returnUrl ??
|
|
678
|
-
currentLocationOrigin();
|
|
679
|
-
const redirectUri = rawRedirect ? normalizeOAuthRedirectUri(rawRedirect) : '';
|
|
680
|
-
if (redirectUri) {
|
|
681
|
-
url.searchParams.set('redirect_uri', redirectUri);
|
|
682
|
-
}
|
|
683
|
-
if (this.clientId) {
|
|
684
|
-
url.searchParams.set('client_id', this.clientId);
|
|
685
|
-
}
|
|
686
|
-
const state = params.state ?? (await generateOAuthState());
|
|
687
|
-
url.searchParams.set('state', state);
|
|
688
|
-
const { codeChallenge, codeVerifier } = await generatePkcePair();
|
|
689
|
-
url.searchParams.set('code_challenge', codeChallenge);
|
|
690
|
-
url.searchParams.set('code_challenge_method', 'S256');
|
|
691
|
-
if (!persistOAuthHandshake(state, codeVerifier)) {
|
|
692
|
-
throw new Error('Could not persist OAuth handshake for password sign-in');
|
|
693
|
-
}
|
|
694
|
-
const href = url.toString();
|
|
695
|
-
this.openUrl?.(href);
|
|
696
|
-
return href;
|
|
697
|
-
}
|
|
698
|
-
|
|
699
725
|
// =========================================================================
|
|
700
726
|
// Internal sign-in helpers
|
|
701
727
|
// =========================================================================
|
|
@@ -819,15 +845,25 @@ export class AccountDialogController {
|
|
|
819
845
|
|
|
820
846
|
/**
|
|
821
847
|
* Register a token-planted session into the device set. Prefers the
|
|
822
|
-
* consumer's
|
|
848
|
+
* consumer's commit funnel (durable persist + hydration); falls back to
|
|
823
849
|
* `SessionClient.registerAndActivate` (registration + activation only).
|
|
850
|
+
*
|
|
851
|
+
* A SWITCH (`opts.fromSwitch`) uses the IN-PLACE `commitSwitchedSession` funnel
|
|
852
|
+
* so it never runs the cross-origin hub-sync redirect; a SIGN-IN uses
|
|
853
|
+
* `commitSession` (which may hub-sync on an official web origin). When the
|
|
854
|
+
* switch funnel is not wired it falls back to the sign-in funnel, then to
|
|
855
|
+
* `registerAndActivate`.
|
|
824
856
|
*/
|
|
825
857
|
private async commitAuthorizedSession(
|
|
826
858
|
session: SessionLoginResponse,
|
|
827
859
|
user: MinimalUserData,
|
|
860
|
+
opts?: { fromSwitch?: boolean },
|
|
828
861
|
): Promise<void> {
|
|
829
|
-
|
|
830
|
-
|
|
862
|
+
const commit = opts?.fromSwitch
|
|
863
|
+
? this.commitSwitchedSession ?? this.commitSession
|
|
864
|
+
: this.commitSession;
|
|
865
|
+
if (commit) {
|
|
866
|
+
await commit(session);
|
|
831
867
|
} else {
|
|
832
868
|
await this.sessionClient.registerAndActivate(user.id);
|
|
833
869
|
}
|
|
@@ -939,6 +975,7 @@ export class AccountDialogController {
|
|
|
939
975
|
error: this.error,
|
|
940
976
|
switchingAccountId: this.switchingAccountId,
|
|
941
977
|
signIn: this.signIn,
|
|
978
|
+
commonsAvailability: this.commonsAvailability,
|
|
942
979
|
};
|
|
943
980
|
}
|
|
944
981
|
|
|
@@ -961,13 +998,3 @@ export function createAccountDialogController(
|
|
|
961
998
|
): AccountDialogController {
|
|
962
999
|
return new AccountDialogController(options);
|
|
963
1000
|
}
|
|
964
|
-
|
|
965
|
-
// ---------------------------------------------------------------------------
|
|
966
|
-
// Local helpers
|
|
967
|
-
// ---------------------------------------------------------------------------
|
|
968
|
-
|
|
969
|
-
/** Current document origin on web; empty string where `location` is absent (native/SSR). */
|
|
970
|
-
function currentLocationOrigin(): string {
|
|
971
|
-
const location = (globalThis as { location?: { origin?: string } }).location;
|
|
972
|
-
return typeof location?.origin === 'string' ? location.origin : '';
|
|
973
|
-
}
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
buildIdpHubOrigin,
|
|
4
4
|
isAllowedDeviceJoinOrigin,
|
|
5
5
|
isIdpHubOrigin,
|
|
6
|
+
isLoopbackOrigin,
|
|
6
7
|
isOfficialWebOrigin,
|
|
7
8
|
normalizeOfficialReturnOrigin,
|
|
8
9
|
parseHubSyncReturnUrl,
|
|
@@ -19,6 +20,19 @@ describe('officialOrigins', () => {
|
|
|
19
20
|
expect(isOfficialWebOrigin('https://evil.example')).toBe(false);
|
|
20
21
|
});
|
|
21
22
|
|
|
23
|
+
it('flags loopback / local-dev origins on any port', () => {
|
|
24
|
+
expect(isLoopbackOrigin('http://localhost:3000')).toBe(true);
|
|
25
|
+
expect(isLoopbackOrigin('http://127.0.0.1:8081')).toBe(true);
|
|
26
|
+
expect(isLoopbackOrigin('http://[::1]:19006')).toBe(true);
|
|
27
|
+
expect(isLoopbackOrigin('https://localhost')).toBe(true);
|
|
28
|
+
expect(isLoopbackOrigin('https://accounts.oxy.so')).toBe(false);
|
|
29
|
+
expect(isLoopbackOrigin('https://evil.example')).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('treats loopback as an official origin (loopback dev is trusted)', () => {
|
|
33
|
+
expect(isOfficialWebOrigin('http://localhost:3000')).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
22
36
|
it('keeps the deprecated alias in sync with isOfficialWebOrigin', () => {
|
|
23
37
|
expect(isAllowedDeviceJoinOrigin('https://accounts.oxy.so')).toBe(true);
|
|
24
38
|
expect(isAllowedDeviceJoinOrigin('https://evil.example')).toBe(false);
|
|
@@ -41,7 +41,12 @@ export function isIdpHubOrigin(): boolean {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Whether an origin is a loopback / local-dev origin (`localhost`, `127.0.0.1`,
|
|
46
|
+
* or `[::1]` on any port, http or https). Local dev must never be bounced to a
|
|
47
|
+
* hosted IdP for cross-origin session restore.
|
|
48
|
+
*/
|
|
49
|
+
export function isLoopbackOrigin(origin: string): boolean {
|
|
45
50
|
try {
|
|
46
51
|
const parsed = new URL(origin);
|
|
47
52
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Device-first password sign-in (`passwordSignIn` + `completeTwoFactorSignIn`).
|
|
3
|
-
* Stubs `makeRequest` and asserts the login-result contract handling: the 2FA
|
|
4
|
-
* arm passes through un-planted, the session arm plants its access token and
|
|
5
|
-
* carries the zero-cookie `deviceId` + `deviceSecret` restore credential,
|
|
6
|
-
* `deviceName` / `deviceFingerprint` are threaded into the request, and a 2FA
|
|
7
|
-
* arm returned from verify-login is a protocol error.
|
|
8
|
-
*/
|
|
9
|
-
import type { LoginResult } from '@oxyhq/contracts';
|
|
10
|
-
import { OxyServices } from '../../OxyServices';
|
|
11
|
-
|
|
12
|
-
const SESSION_ARM: LoginResult = {
|
|
13
|
-
sessionId: 'sess-1',
|
|
14
|
-
deviceId: 'dev-1',
|
|
15
|
-
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
16
|
-
accessToken: 'access-1',
|
|
17
|
-
deviceSecret: 'ds-secret-1',
|
|
18
|
-
user: { id: 'user-1', username: 'u' },
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
const TWO_FACTOR_ARM: LoginResult = { twoFactorRequired: true, loginToken: 'login-token-1' };
|
|
22
|
-
|
|
23
|
-
describe('passwordSignIn', () => {
|
|
24
|
-
let oxy: OxyServices;
|
|
25
|
-
let makeRequest: jest.SpyInstance;
|
|
26
|
-
let setTokens: jest.SpyInstance;
|
|
27
|
-
|
|
28
|
-
beforeEach(() => {
|
|
29
|
-
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
30
|
-
makeRequest = jest.spyOn(oxy, 'makeRequest');
|
|
31
|
-
setTokens = jest.spyOn(oxy, 'setTokens').mockImplementation(() => undefined);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
afterEach(() => jest.restoreAllMocks());
|
|
35
|
-
|
|
36
|
-
it('returns the 2FA arm without planting a token', async () => {
|
|
37
|
-
makeRequest.mockResolvedValueOnce(TWO_FACTOR_ARM);
|
|
38
|
-
const result = await oxy.passwordSignIn('alice', 'pw');
|
|
39
|
-
expect(result).toEqual(TWO_FACTOR_ARM);
|
|
40
|
-
expect(setTokens).not.toHaveBeenCalled();
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it('plants the access token on the session arm, exposes the mint credential, and threads deviceName + deviceFingerprint', async () => {
|
|
44
|
-
makeRequest.mockResolvedValueOnce(SESSION_ARM);
|
|
45
|
-
const result = await oxy.passwordSignIn('alice', 'pw', { deviceName: 'Phone', deviceFingerprint: 'fp-1' });
|
|
46
|
-
expect(result).toEqual(SESSION_ARM);
|
|
47
|
-
// The zero-cookie restore credential is on the session arm the caller persists.
|
|
48
|
-
expect('twoFactorRequired' in result).toBe(false);
|
|
49
|
-
if (!('twoFactorRequired' in result)) {
|
|
50
|
-
expect(result.deviceId).toBe('dev-1');
|
|
51
|
-
expect(result.deviceSecret).toBe('ds-secret-1');
|
|
52
|
-
}
|
|
53
|
-
expect(setTokens).toHaveBeenCalledWith('access-1');
|
|
54
|
-
expect(makeRequest).toHaveBeenCalledWith(
|
|
55
|
-
'POST',
|
|
56
|
-
'/auth/login',
|
|
57
|
-
{ identifier: 'alice', password: 'pw', deviceName: 'Phone', deviceFingerprint: 'fp-1' },
|
|
58
|
-
{ cache: false },
|
|
59
|
-
);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it('preserves the securityAlert on the session arm (contract parse must not strip it)', async () => {
|
|
63
|
-
const withAlert: LoginResult = {
|
|
64
|
-
...SESSION_ARM,
|
|
65
|
-
securityAlert: {
|
|
66
|
-
message: 'Unusual activity detected on your account',
|
|
67
|
-
anomalies: [{ type: 'new_device', reason: 'first seen', details: 'Chrome / macOS' }],
|
|
68
|
-
},
|
|
69
|
-
};
|
|
70
|
-
makeRequest.mockResolvedValueOnce(withAlert);
|
|
71
|
-
const result = await oxy.passwordSignIn('alice', 'pw');
|
|
72
|
-
expect('twoFactorRequired' in result).toBe(false);
|
|
73
|
-
if (!('twoFactorRequired' in result)) {
|
|
74
|
-
expect(result.securityAlert?.message).toBe('Unusual activity detected on your account');
|
|
75
|
-
expect(result.securityAlert?.anomalies[0]?.type).toBe('new_device');
|
|
76
|
-
}
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it('throws on an unexpected response shape', async () => {
|
|
80
|
-
makeRequest.mockResolvedValueOnce({ nope: true });
|
|
81
|
-
await expect(oxy.passwordSignIn('alice', 'pw')).rejects.toThrow();
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
describe('completeTwoFactorSignIn', () => {
|
|
86
|
-
let oxy: OxyServices;
|
|
87
|
-
let makeRequest: jest.SpyInstance;
|
|
88
|
-
let setTokens: jest.SpyInstance;
|
|
89
|
-
|
|
90
|
-
beforeEach(() => {
|
|
91
|
-
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
92
|
-
makeRequest = jest.spyOn(oxy, 'makeRequest');
|
|
93
|
-
setTokens = jest.spyOn(oxy, 'setTokens').mockImplementation(() => undefined);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
afterEach(() => jest.restoreAllMocks());
|
|
97
|
-
|
|
98
|
-
it('verifies the login token, plants the session, and POSTs to /security/2fa/verify-login', async () => {
|
|
99
|
-
makeRequest.mockResolvedValueOnce(SESSION_ARM);
|
|
100
|
-
const result = await oxy.completeTwoFactorSignIn({ loginToken: 'lt', token: '123456', deviceName: 'Phone' });
|
|
101
|
-
expect(result).toEqual(SESSION_ARM);
|
|
102
|
-
expect(setTokens).toHaveBeenCalledWith('access-1');
|
|
103
|
-
expect(makeRequest).toHaveBeenCalledWith(
|
|
104
|
-
'POST',
|
|
105
|
-
'/security/2fa/verify-login',
|
|
106
|
-
{ loginToken: 'lt', token: '123456', backupCode: undefined, deviceName: 'Phone' },
|
|
107
|
-
{ cache: false },
|
|
108
|
-
);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it('throws when verify-login unexpectedly returns another 2FA challenge', async () => {
|
|
112
|
-
makeRequest.mockResolvedValueOnce(TWO_FACTOR_ARM);
|
|
113
|
-
await expect(oxy.completeTwoFactorSignIn({ loginToken: 'lt', token: '123456' })).rejects.toThrow();
|
|
114
|
-
});
|
|
115
|
-
});
|