@oxyhq/core 11.0.1 → 12.0.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.
@@ -664,72 +664,6 @@ describe('AccountDialogController — Commons deep-link (canOpenApp)', () => {
664
664
  });
665
665
  });
666
666
 
667
- describe('AccountDialogController — openPasswordAtOxyAuth', () => {
668
- beforeEach(() => {
669
- const store = new Map<string, string>();
670
- Object.defineProperty(globalThis, 'sessionStorage', {
671
- value: {
672
- getItem: (key: string) => store.get(key) ?? null,
673
- setItem: (key: string, value: string) => {
674
- store.set(key, value);
675
- },
676
- removeItem: (key: string) => {
677
- store.delete(key);
678
- },
679
- },
680
- configurable: true,
681
- });
682
- });
683
-
684
- it('builds the IdP sign-in URL with redirect_uri + client_id and invokes openUrl', async () => {
685
- const oxy = makeOxy();
686
- const sc = new TestSessionClient(host());
687
- const openUrl = jest.fn();
688
- const controller = new AccountDialogController({
689
- oxyServices: oxy as unknown as OxyServices,
690
- sessionClient: sc,
691
- clientId: 'oxy_dk_test',
692
- openUrl,
693
- });
694
-
695
- const url = await controller.openPasswordAtOxyAuth({ returnUrl: 'https://mention.earth/dashboard' });
696
- const parsed = new URL(url);
697
- expect(parsed.origin).toBe('https://auth.oxy.so');
698
- expect(parsed.pathname).toBe('/login');
699
- expect(parsed.searchParams.get('redirect_uri')).toBe('https://mention.earth');
700
- expect(parsed.searchParams.get('client_id')).toBe('oxy_dk_test');
701
- expect(parsed.searchParams.get('state')).toBeTruthy();
702
- expect(parsed.searchParams.get('code_challenge')).toBeTruthy();
703
- expect(parsed.searchParams.get('code_challenge_method')).toBe('S256');
704
- expect(openUrl).toHaveBeenCalledWith(url);
705
- });
706
-
707
- it('honors authRedirectUri over returnUrl', async () => {
708
- const oxy = makeOxy();
709
- const sc = new TestSessionClient(host());
710
- const controller = new AccountDialogController({
711
- oxyServices: oxy as unknown as OxyServices,
712
- sessionClient: sc,
713
- authRedirectUri: 'https://inbox.oxy.so',
714
- });
715
-
716
- const url = await controller.openPasswordAtOxyAuth({ returnUrl: 'https://inbox.oxy.so/mail' });
717
- expect(new URL(url).searchParams.get('redirect_uri')).toBe('https://inbox.oxy.so');
718
- });
719
-
720
- it('honors an idpApex override', async () => {
721
- const oxy = makeOxy();
722
- const sc = new TestSessionClient(host());
723
- const controller = new AccountDialogController({
724
- oxyServices: oxy as unknown as OxyServices,
725
- sessionClient: sc,
726
- idpApex: 'alia.onl',
727
- });
728
- const url = await controller.openPasswordAtOxyAuth({ returnUrl: 'https://alia.onl/' });
729
- expect(new URL(url).origin).toBe('https://auth.alia.onl');
730
- });
731
- });
732
-
733
667
  describe('AccountDialogController — /auth-session socket (instant QR wake)', () => {
734
668
  type Handler = (...args: unknown[]) => void;
735
669
  class FakeAuthSocket implements MinimalSocket {
@@ -21,9 +21,8 @@
21
21
  * `oxyServices.signInWithSharedIdentity`, else the cross-device QR handoff
22
22
  * via `startCommonsSignIn` → poll → `claimSessionByToken`).
23
23
  *
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.
24
+ * Sign-in is passkey (WebAuthn) or the Commons QR / shared-keychain handoff
25
+ * password, social login, and 2FA were removed ecosystem-wide.
27
26
  */
28
27
 
29
28
  import type { OxyServices } from '../OxyServices';
@@ -31,13 +30,6 @@ import type { SessionLoginResponse, MinimalUserData } from '../models/session';
31
30
  import type { User } from '../models/interfaces';
32
31
  import { logger } from '../logger';
33
32
  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
33
  import type { SessionClient } from './SessionClient';
42
34
  import type { MinimalSocket, SocketIOFactory } from './socketLoader';
43
35
  import {
@@ -117,14 +109,6 @@ export interface AccountDialogControllerOptions {
117
109
  commitSession?: (session: SessionLoginResponse) => Promise<void>;
118
110
  /** Notified after a completed sign-in (bearer planted + session committed). */
119
111
  onSignedIn?: (user: MinimalUserData) => void;
120
- /** Central IdP apex for `openPasswordAtOxyAuth` (defaults to `CENTRAL_IDP_APEX`). */
121
- idpApex?: string;
122
- /**
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}.
126
- */
127
- authRedirectUri?: string | null;
128
112
  /**
129
113
  * QR device-flow FALLBACK poll interval in ms (default 12000). The primary
130
114
  * approval signal is the `/auth-session` socket's `auth_update` event (instant);
@@ -140,8 +124,8 @@ export interface AccountDialogControllerOptions {
140
124
  */
141
125
  socketFactory?: SocketIOFactory;
142
126
  /**
143
- * Optional URL opener. When provided, `openPasswordAtOxyAuth` invokes it with
144
- * the built URL in addition to returning it (web: `location.assign`; native:
127
+ * Optional URL opener. When provided, the controller invokes it to deep-link
128
+ * the Commons app for the QR handoff (web: `location.assign`; native:
145
129
  * `Linking.openURL`). Headless core never touches `window`/`Linking` itself.
146
130
  */
147
131
  openUrl?: (url: string) => void;
@@ -196,8 +180,6 @@ export class AccountDialogController {
196
180
  private readonly locale?: string;
197
181
  private readonly commitSession?: (session: SessionLoginResponse) => Promise<void>;
198
182
  private readonly onSignedIn?: (user: MinimalUserData) => void;
199
- private readonly idpApex: string;
200
- private readonly authRedirectUri: string | null;
201
183
  private readonly pollIntervalMs: number;
202
184
  private readonly openUrl?: (url: string) => void;
203
185
  private readonly canOpenApp?: (url: string) => Promise<boolean>;
@@ -247,8 +229,6 @@ export class AccountDialogController {
247
229
  this.locale = options.locale;
248
230
  this.commitSession = options.commitSession;
249
231
  this.onSignedIn = options.onSignedIn;
250
- this.idpApex = options.idpApex ?? CENTRAL_IDP_APEX;
251
- this.authRedirectUri = options.authRedirectUri ?? null;
252
232
  this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
253
233
  this.openUrl = options.openUrl;
254
234
  this.canOpenApp = options.canOpenApp;
@@ -652,50 +632,6 @@ export class AccountDialogController {
652
632
  }
653
633
  }
654
634
 
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
635
  // =========================================================================
700
636
  // Internal sign-in helpers
701
637
  // =========================================================================
@@ -961,13 +897,3 @@ export function createAccountDialogController(
961
897
  ): AccountDialogController {
962
898
  return new AccountDialogController(options);
963
899
  }
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
- });