@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.
Files changed (30) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/index.js +7 -5
  3. package/dist/cjs/mixins/OxyServices.auth.js +6 -117
  4. package/dist/cjs/mixins/OxyServices.identity.js +16 -12
  5. package/dist/cjs/session/accountDialogController.js +84 -75
  6. package/dist/cjs/utils/officialOrigins.js +6 -0
  7. package/dist/esm/.tsbuildinfo +1 -1
  8. package/dist/esm/index.js +6 -5
  9. package/dist/esm/mixins/OxyServices.auth.js +6 -117
  10. package/dist/esm/mixins/OxyServices.identity.js +16 -12
  11. package/dist/esm/session/accountDialogController.js +84 -75
  12. package/dist/esm/utils/officialOrigins.js +6 -1
  13. package/dist/types/.tsbuildinfo +1 -1
  14. package/dist/types/index.d.ts +3 -3
  15. package/dist/types/mixins/OxyServices.auth.d.ts +4 -48
  16. package/dist/types/mixins/OxyServices.identity.d.ts +13 -9
  17. package/dist/types/session/accountDialogController.d.ts +84 -49
  18. package/dist/types/utils/officialOrigins.d.ts +6 -0
  19. package/package.json +2 -2
  20. package/src/index.ts +7 -4
  21. package/src/mixins/OxyServices.auth.ts +6 -144
  22. package/src/mixins/OxyServices.identity.ts +19 -15
  23. package/src/mixins/__tests__/OxyServices.identity.test.ts +26 -14
  24. package/src/mixins/__tests__/webauthnAuth.test.ts +1 -1
  25. package/src/session/__tests__/accountDialogController.test.ts +75 -64
  26. package/src/session/__tests__/accountProjection.test.ts +30 -0
  27. package/src/session/accountDialogController.ts +131 -104
  28. package/src/utils/__tests__/officialOrigins.test.ts +14 -0
  29. package/src/utils/officialOrigins.ts +6 -1
  30. 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
- * It deliberately owns NO password/2FA logic those live at the IdP
25
- * (auth.oxy.so). {@link AccountDialogController.openPasswordAtOxyAuth} only
26
- * builds the hand-off URL; device-first convergence syncs the session back.
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 / minted
110
- * graph switch) into the host's session set — device-first registration +
111
- * durable persist + profile hydration. The consumer supplies its provider's
112
- * commit path (`useOxy().handleWebSession` / the auth-sdk equivalent). Called
113
- * AFTER the SDK has planted the access token. When omitted the controller
114
- * falls back to `SessionClient.registerAndActivate` (registration + activation
115
- * only — no provider-side durable persist/hydration).
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
- * Registered OAuth redirect URI for this RP (exact match against
124
- * `Application.redirectUris`). When set, wins over `returnUrl` /
125
- * `location.origin` normalization in {@link openPasswordAtOxyAuth}.
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
- authRedirectUri?: string | null;
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, `openPasswordAtOxyAuth` invokes it with
144
- * the built URL in addition to returning it (web: `location.assign`; native:
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 only — `canOpenApp`
615
- // is undefined/false on web), deep-link straight into its approve screen with
616
- // the same `oxycommons://approve?...` payload the QR encodes. The QR + polling
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.maybeOpenCommons(handle.qrPayload);
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
- * When a `canOpenApp` probe is injected and reports Commons installed, open the
627
- * approve deep link via the injected `openUrl`. Best-effort and non-blocking: a
628
- * probe/open failure is logged and swallowed the QR/polling fallback remains.
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 onlya 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 maybeOpenCommons(qrPayload: string): Promise<void> {
631
- if (!this.canOpenApp || !this.openUrl) return;
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
- if (await this.canOpenApp(COMMONS_APP_SCHEME)) {
634
- this.openUrl(qrPayload);
635
- }
683
+ available = await this.canOpenApp(COMMONS_APP_SCHEME);
636
684
  } catch (error) {
637
685
  logger.debug(
638
- '[AccountDialogController] Commons deep-link probe failed (QR fallback active)',
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 `commitSession` (durable persist + hydration); falls back to
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
- if (this.commitSession) {
830
- await this.commitSession(session);
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
- function isLoopbackOrigin(origin: string): boolean {
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
- });