@oxyhq/core 11.0.0 → 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.
Files changed (34) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/index.js +13 -6
  3. package/dist/cjs/mixins/OxyServices.auth.js +65 -74
  4. package/dist/cjs/mixins/OxyServices.identity.js +16 -12
  5. package/dist/cjs/session/accountDialogController.js +2 -55
  6. package/dist/cjs/utils/officialOrigins.js +6 -0
  7. package/dist/cjs/utils/webauthnOrigin.js +51 -0
  8. package/dist/esm/.tsbuildinfo +1 -1
  9. package/dist/esm/index.js +10 -5
  10. package/dist/esm/mixins/OxyServices.auth.js +65 -74
  11. package/dist/esm/mixins/OxyServices.identity.js +16 -12
  12. package/dist/esm/session/accountDialogController.js +2 -55
  13. package/dist/esm/utils/officialOrigins.js +6 -1
  14. package/dist/esm/utils/webauthnOrigin.js +48 -0
  15. package/dist/types/.tsbuildinfo +1 -1
  16. package/dist/types/index.d.ts +3 -2
  17. package/dist/types/mixins/OxyServices.auth.d.ts +50 -36
  18. package/dist/types/mixins/OxyServices.identity.d.ts +13 -9
  19. package/dist/types/session/accountDialogController.d.ts +4 -34
  20. package/dist/types/utils/officialOrigins.d.ts +6 -0
  21. package/dist/types/utils/webauthnOrigin.d.ts +32 -0
  22. package/package.json +2 -2
  23. package/src/index.ts +11 -4
  24. package/src/mixins/OxyServices.auth.ts +95 -100
  25. package/src/mixins/OxyServices.identity.ts +19 -15
  26. package/src/mixins/__tests__/OxyServices.identity.test.ts +26 -14
  27. package/src/mixins/__tests__/webauthnAuth.test.ts +206 -0
  28. package/src/session/__tests__/accountDialogController.test.ts +0 -66
  29. package/src/session/accountDialogController.ts +4 -78
  30. package/src/utils/__tests__/officialOrigins.test.ts +14 -0
  31. package/src/utils/__tests__/webauthnOrigin.test.ts +83 -0
  32. package/src/utils/officialOrigins.ts +6 -1
  33. package/src/utils/webauthnOrigin.ts +52 -0
  34. package/src/mixins/__tests__/passwordSignIn.test.ts +0 -115
@@ -53,7 +53,7 @@ const OXY_IDENTITY_APEX = 'oxy.so';
53
53
  export type IdentityRecordType = OxySignedRecordType;
54
54
 
55
55
  /** Auth-method types that can be unlinked via {@link OxyServicesIdentityMixin}. */
56
- export type UnlinkableAuthMethodType = 'identity' | 'password' | 'google' | 'apple' | 'github';
56
+ export type UnlinkableAuthMethodType = 'identity' | 'webauthn';
57
57
 
58
58
  /**
59
59
  * Result of a link/unlink auth-method mutation (`POST /auth/link`,
@@ -207,18 +207,18 @@ export function OxyServicesIdentityMixin<T extends typeof OxyServicesBase>(Base:
207
207
  }
208
208
 
209
209
  /**
210
- * Link password authentication to the current account. Adds a `password`
211
- * auth method (does not remove existing methods).
210
+ * Unlink an authentication method from the current account. The server
211
+ * refuses to remove the last remaining method (the account would become
212
+ * inaccessible). Unlinking `identity` downgrades the account to custodial.
212
213
  *
213
- * @param email - The email to associate with password auth.
214
- * @param password - The new password (server enforces strength rules).
214
+ * @param type - The auth-method type to remove.
215
215
  */
216
- async linkPassword(email: string, password: string): Promise<LinkAuthMethodResult> {
216
+ async unlinkAuthMethod(type: UnlinkableAuthMethodType): Promise<LinkAuthMethodResult> {
217
217
  try {
218
218
  const result = await this.makeRequest<LinkAuthMethodResult>(
219
- 'POST',
220
- '/auth/link',
221
- { type: 'password', email, password },
219
+ 'DELETE',
220
+ `/auth/link/${encodeURIComponent(type)}`,
221
+ undefined,
222
222
  { cache: false },
223
223
  );
224
224
  this._invalidateIdentityCaches(this.getCurrentUserId());
@@ -229,17 +229,21 @@ export function OxyServicesIdentityMixin<T extends typeof OxyServicesBase>(Base:
229
229
  }
230
230
 
231
231
  /**
232
- * Unlink an authentication method from the current account. The server
233
- * refuses to remove the last remaining method (the account would become
234
- * inaccessible). Unlinking `identity` downgrades the account to custodial.
232
+ * Remove ONE passkey (WebAuthn credential) from the current account.
235
233
  *
236
- * @param type - The auth-method type to remove.
234
+ * Passkeys are per-credential, so unlike {@link unlinkAuthMethod} (which
235
+ * removes an auth method by type) this targets a specific credential id.
236
+ * The server refuses to remove the last remaining auth method (the account
237
+ * would become inaccessible) and deletes the stored `WebauthnCredential`.
238
+ *
239
+ * @param credentialId - The passkey's public credential id
240
+ * (`AuthMethodEntry.credentialId`).
237
241
  */
238
- async unlinkAuthMethod(type: UnlinkableAuthMethodType): Promise<LinkAuthMethodResult> {
242
+ async removePasskey(credentialId: string): Promise<LinkAuthMethodResult> {
239
243
  try {
240
244
  const result = await this.makeRequest<LinkAuthMethodResult>(
241
245
  'DELETE',
242
- `/auth/link/${encodeURIComponent(type)}`,
246
+ `/auth/link/webauthn/${encodeURIComponent(credentialId)}`,
243
247
  undefined,
244
248
  { cache: false },
245
249
  );
@@ -186,37 +186,49 @@ describe('OxyServices.identity', () => {
186
186
  });
187
187
  });
188
188
 
189
- describe('linkPassword', () => {
190
- it('POSTs /auth/link with the password method and sweeps cache', async () => {
191
- makeRequestSpy.mockResolvedValue({ success: true, message: 'Password auth linked successfully' });
189
+ describe('unlinkAuthMethod', () => {
190
+ it('DELETEs /auth/link/:type and sweeps cache', async () => {
191
+ makeRequestSpy.mockResolvedValue({ success: true, message: 'identity auth unlinked successfully' });
192
192
 
193
- await oxy.linkPassword('nate@oxy.so', 'sup3r-secret');
193
+ await oxy.unlinkAuthMethod('identity');
194
194
 
195
195
  expect(makeRequestSpy).toHaveBeenCalledWith(
196
- 'POST',
197
- '/auth/link',
198
- { type: 'password', email: 'nate@oxy.so', password: 'sup3r-secret' },
196
+ 'DELETE',
197
+ '/auth/link/identity',
198
+ undefined,
199
199
  expect.objectContaining({ cache: false }),
200
200
  );
201
- expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/users/me');
202
- expect(clearEntrySpy).toHaveBeenCalledWith('GET:/auth/methods');
201
+ expect(clearEntrySpy).toHaveBeenCalledWith('GET:/u/user-123/did.json');
203
202
  });
204
203
  });
205
204
 
206
- describe('unlinkAuthMethod', () => {
207
- it('DELETEs /auth/link/:type and sweeps cache', async () => {
208
- makeRequestSpy.mockResolvedValue({ success: true, message: 'identity auth unlinked successfully' });
205
+ describe('removePasskey', () => {
206
+ it('DELETEs /auth/link/webauthn/:credentialID and sweeps cache', async () => {
207
+ makeRequestSpy.mockResolvedValue({ success: true, message: 'Passkey unlinked successfully' });
209
208
 
210
- await oxy.unlinkAuthMethod('identity');
209
+ await oxy.removePasskey('cred-abc');
211
210
 
212
211
  expect(makeRequestSpy).toHaveBeenCalledWith(
213
212
  'DELETE',
214
- '/auth/link/identity',
213
+ '/auth/link/webauthn/cred-abc',
215
214
  undefined,
216
215
  expect.objectContaining({ cache: false }),
217
216
  );
218
217
  expect(clearEntrySpy).toHaveBeenCalledWith('GET:/u/user-123/did.json');
219
218
  });
219
+
220
+ it('URL-encodes the credential id', async () => {
221
+ makeRequestSpy.mockResolvedValue({ success: true, message: 'Passkey unlinked successfully' });
222
+
223
+ await oxy.removePasskey('a/b+c=');
224
+
225
+ expect(makeRequestSpy).toHaveBeenCalledWith(
226
+ 'DELETE',
227
+ '/auth/link/webauthn/a%2Fb%2Bc%3D',
228
+ undefined,
229
+ expect.objectContaining({ cache: false }),
230
+ );
231
+ });
220
232
  });
221
233
 
222
234
  describe('signRecord (client-only)', () => {
@@ -0,0 +1,206 @@
1
+ /**
2
+ * WebAuthn / passkey auth methods on OxyServices
3
+ * (`webauthnRegisterOptions` / `webauthnRegisterVerify` /
4
+ * `webauthnLoginOptions` / `webauthnLoginVerify`).
5
+ *
6
+ * Stubs `makeRequest` (HTTP-mock style) and
7
+ * asserts: each method hits the right endpoint with the right body; the opaque
8
+ * ceremony `response` is forwarded verbatim; the login/verify + register/verify
9
+ * (signup) paths parse the login contract and plant the access token on the
10
+ * session arm; register/verify (link) returns `{ success, message }` WITHOUT
11
+ * planting a token.
12
+ */
13
+ import type { LoginResult } from '@oxyhq/contracts';
14
+ import { OxyServices } from '../../OxyServices';
15
+
16
+ const SESSION_ARM: LoginResult = {
17
+ sessionId: 'sess-1',
18
+ deviceId: 'dev-1',
19
+ expiresAt: '2030-01-01T00:00:00.000Z',
20
+ accessToken: 'access-1',
21
+ deviceSecret: 'ds-secret-1',
22
+ user: { id: 'user-1', username: 'u' },
23
+ };
24
+
25
+ const LINK_RESULT = { success: true as const, message: 'Passkey registered successfully' };
26
+
27
+ // Opaque browser ceremony payloads — Oxy never inspects these, so shape is
28
+ // irrelevant beyond "forwarded verbatim under `response`".
29
+ const CREATE_RESPONSE = { id: 'cred-abc', rawId: 'cred-abc', response: { attestationObject: 'ao' }, type: 'public-key' };
30
+ const GET_RESPONSE = { id: 'cred-abc', rawId: 'cred-abc', response: { signature: 'sig' }, type: 'public-key' };
31
+
32
+ describe('webauthnRegisterOptions', () => {
33
+ let oxy: OxyServices;
34
+ let makeRequest: jest.SpyInstance;
35
+
36
+ beforeEach(() => {
37
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
38
+ makeRequest = jest.spyOn(oxy, 'makeRequest');
39
+ });
40
+ afterEach(() => jest.restoreAllMocks());
41
+
42
+ it('POSTs to /auth/webauthn/register/options with the username', async () => {
43
+ const options = { challenge: 'c', rp: { id: 'oxy.so' } };
44
+ makeRequest.mockResolvedValueOnce(options);
45
+ const result = await oxy.webauthnRegisterOptions('alice');
46
+ expect(result).toBe(options);
47
+ expect(makeRequest).toHaveBeenCalledWith(
48
+ 'POST',
49
+ '/auth/webauthn/register/options',
50
+ { username: 'alice' },
51
+ { cache: false },
52
+ );
53
+ });
54
+
55
+ it('omits username from the body when not provided (link flow)', async () => {
56
+ makeRequest.mockResolvedValueOnce({ challenge: 'c' });
57
+ await oxy.webauthnRegisterOptions();
58
+ expect(makeRequest).toHaveBeenCalledWith(
59
+ 'POST',
60
+ '/auth/webauthn/register/options',
61
+ {},
62
+ { cache: false },
63
+ );
64
+ });
65
+ });
66
+
67
+ describe('webauthnLoginOptions', () => {
68
+ let oxy: OxyServices;
69
+ let makeRequest: jest.SpyInstance;
70
+
71
+ beforeEach(() => {
72
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
73
+ makeRequest = jest.spyOn(oxy, 'makeRequest');
74
+ });
75
+ afterEach(() => jest.restoreAllMocks());
76
+
77
+ it('POSTs to /auth/webauthn/login/options with the username (username-first)', async () => {
78
+ const options = { challenge: 'c', allowCredentials: [{ id: 'cred-abc' }] };
79
+ makeRequest.mockResolvedValueOnce(options);
80
+ const result = await oxy.webauthnLoginOptions('alice');
81
+ expect(result).toBe(options);
82
+ expect(makeRequest).toHaveBeenCalledWith(
83
+ 'POST',
84
+ '/auth/webauthn/login/options',
85
+ { username: 'alice' },
86
+ { cache: false },
87
+ );
88
+ });
89
+
90
+ it('omits username for the usernameless / discoverable flow', async () => {
91
+ makeRequest.mockResolvedValueOnce({ challenge: 'c', allowCredentials: [] });
92
+ await oxy.webauthnLoginOptions();
93
+ expect(makeRequest).toHaveBeenCalledWith(
94
+ 'POST',
95
+ '/auth/webauthn/login/options',
96
+ {},
97
+ { cache: false },
98
+ );
99
+ });
100
+ });
101
+
102
+ describe('webauthnRegisterVerify', () => {
103
+ let oxy: OxyServices;
104
+ let makeRequest: jest.SpyInstance;
105
+ let setTokens: jest.SpyInstance;
106
+
107
+ beforeEach(() => {
108
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
109
+ makeRequest = jest.spyOn(oxy, 'makeRequest');
110
+ setTokens = jest.spyOn(oxy, 'setTokens').mockImplementation(() => undefined);
111
+ });
112
+ afterEach(() => jest.restoreAllMocks());
113
+
114
+ it('signup branch: parses LoginSessionResult, plants the token, threads the envelope', async () => {
115
+ makeRequest.mockResolvedValueOnce(SESSION_ARM);
116
+ const result = await oxy.webauthnRegisterVerify(CREATE_RESPONSE, {
117
+ username: 'alice',
118
+ deviceName: 'Phone',
119
+ deviceFingerprint: 'fp-1',
120
+ });
121
+ expect(result).toEqual(SESSION_ARM);
122
+ expect('twoFactorRequired' in result).toBe(false);
123
+ if (!('twoFactorRequired' in result) && 'sessionId' in result) {
124
+ expect(result.deviceId).toBe('dev-1');
125
+ expect(result.deviceSecret).toBe('ds-secret-1');
126
+ }
127
+ expect(setTokens).toHaveBeenCalledWith('access-1');
128
+ expect(makeRequest).toHaveBeenCalledWith(
129
+ 'POST',
130
+ '/auth/webauthn/register/verify',
131
+ { response: CREATE_RESPONSE, username: 'alice', deviceName: 'Phone', deviceFingerprint: 'fp-1' },
132
+ { cache: false },
133
+ );
134
+ });
135
+
136
+ it('link branch: returns { success, message } WITHOUT planting a token', async () => {
137
+ makeRequest.mockResolvedValueOnce(LINK_RESULT);
138
+ const result = await oxy.webauthnRegisterVerify(CREATE_RESPONSE, { deviceName: 'Laptop' });
139
+ expect(result).toEqual(LINK_RESULT);
140
+ expect(setTokens).not.toHaveBeenCalled();
141
+ expect(makeRequest).toHaveBeenCalledWith(
142
+ 'POST',
143
+ '/auth/webauthn/register/verify',
144
+ { response: CREATE_RESPONSE, deviceName: 'Laptop' },
145
+ { cache: false },
146
+ );
147
+ });
148
+
149
+ it('throws on an unexpected response shape', async () => {
150
+ makeRequest.mockResolvedValueOnce({ nope: true });
151
+ await expect(oxy.webauthnRegisterVerify(CREATE_RESPONSE)).rejects.toThrow();
152
+ expect(setTokens).not.toHaveBeenCalled();
153
+ });
154
+ });
155
+
156
+ describe('webauthnLoginVerify', () => {
157
+ let oxy: OxyServices;
158
+ let makeRequest: jest.SpyInstance;
159
+ let setTokens: jest.SpyInstance;
160
+
161
+ beforeEach(() => {
162
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
163
+ makeRequest = jest.spyOn(oxy, 'makeRequest');
164
+ setTokens = jest.spyOn(oxy, 'setTokens').mockImplementation(() => undefined);
165
+ });
166
+ afterEach(() => jest.restoreAllMocks());
167
+
168
+ it('parses LoginSessionResult, plants the token, and threads the device envelope', async () => {
169
+ makeRequest.mockResolvedValueOnce(SESSION_ARM);
170
+ const result = await oxy.webauthnLoginVerify(GET_RESPONSE, {
171
+ deviceName: 'Phone',
172
+ deviceFingerprint: 'fp-1',
173
+ deviceId: 'dev-persisted',
174
+ });
175
+ expect(result).toEqual(SESSION_ARM);
176
+ expect('twoFactorRequired' in result).toBe(false);
177
+ if (!('twoFactorRequired' in result)) {
178
+ expect(result.deviceId).toBe('dev-1');
179
+ expect(result.deviceSecret).toBe('ds-secret-1');
180
+ }
181
+ expect(setTokens).toHaveBeenCalledWith('access-1');
182
+ expect(makeRequest).toHaveBeenCalledWith(
183
+ 'POST',
184
+ '/auth/webauthn/login/verify',
185
+ { response: GET_RESPONSE, deviceName: 'Phone', deviceFingerprint: 'fp-1', deviceId: 'dev-persisted' },
186
+ { cache: false },
187
+ );
188
+ });
189
+
190
+ it('POSTs the bare response body when no envelope is passed', async () => {
191
+ makeRequest.mockResolvedValueOnce(SESSION_ARM);
192
+ await oxy.webauthnLoginVerify(GET_RESPONSE);
193
+ expect(makeRequest).toHaveBeenCalledWith(
194
+ 'POST',
195
+ '/auth/webauthn/login/verify',
196
+ { response: GET_RESPONSE },
197
+ { cache: false },
198
+ );
199
+ });
200
+
201
+ it('throws on an unexpected response shape', async () => {
202
+ makeRequest.mockResolvedValueOnce({ nope: true });
203
+ await expect(oxy.webauthnLoginVerify(GET_RESPONSE)).rejects.toThrow();
204
+ expect(setTokens).not.toHaveBeenCalled();
205
+ });
206
+ });
@@ -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);
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `isOxyRpOrigin()` — client-side WebAuthn relying-party origin guard.
3
+ *
4
+ * Runs under the `node` test environment, where `globalThis.location` is
5
+ * genuinely absent by default (mirroring native/SSR). Each case defines a fake
6
+ * `location` with the host under test and restores the original afterwards.
7
+ */
8
+ import { isOxyRpOrigin } from '../webauthnOrigin';
9
+
10
+ describe('isOxyRpOrigin', () => {
11
+ const originalLocation = globalThis.location;
12
+
13
+ const setHostname = (hostname: unknown): void => {
14
+ Object.defineProperty(globalThis, 'location', {
15
+ configurable: true,
16
+ value: hostname === undefined ? undefined : { hostname },
17
+ });
18
+ };
19
+
20
+ afterEach(() => {
21
+ Object.defineProperty(globalThis, 'location', {
22
+ configurable: true,
23
+ value: originalLocation,
24
+ });
25
+ });
26
+
27
+ it('returns true on the apex oxy.so', () => {
28
+ setHostname('oxy.so');
29
+ expect(isOxyRpOrigin()).toBe(true);
30
+ });
31
+
32
+ it('returns true on an oxy.so subdomain', () => {
33
+ setHostname('accounts.oxy.so');
34
+ expect(isOxyRpOrigin()).toBe(true);
35
+ });
36
+
37
+ it('returns true on a deep oxy.so subdomain', () => {
38
+ setHostname('sub.accounts.oxy.so');
39
+ expect(isOxyRpOrigin()).toBe(true);
40
+ });
41
+
42
+ it('returns true on loopback hosts', () => {
43
+ for (const host of ['localhost', '127.0.0.1', '[::1]']) {
44
+ setHostname(host);
45
+ expect(isOxyRpOrigin()).toBe(true);
46
+ }
47
+ });
48
+
49
+ it('is case-insensitive on the host', () => {
50
+ setHostname('Accounts.OXY.So');
51
+ expect(isOxyRpOrigin()).toBe(true);
52
+ });
53
+
54
+ it('returns false on an unrelated origin', () => {
55
+ setHostname('evil.com');
56
+ expect(isOxyRpOrigin()).toBe(false);
57
+ });
58
+
59
+ it('returns false on a look-alike suffix without the dot boundary', () => {
60
+ setHostname('evil-oxy.so');
61
+ expect(isOxyRpOrigin()).toBe(false);
62
+ });
63
+
64
+ it('returns false when oxy.so is only a subdomain label of an attacker apex', () => {
65
+ setHostname('oxy.so.evil.com');
66
+ expect(isOxyRpOrigin()).toBe(false);
67
+ });
68
+
69
+ it('returns false when there is no location (native / SSR)', () => {
70
+ setHostname(undefined);
71
+ expect(isOxyRpOrigin()).toBe(false);
72
+ });
73
+
74
+ it('returns false when the hostname is not a string', () => {
75
+ setHostname(123);
76
+ expect(isOxyRpOrigin()).toBe(false);
77
+ });
78
+
79
+ it('returns false when the hostname is empty', () => {
80
+ setHostname('');
81
+ expect(isOxyRpOrigin()).toBe(false);
82
+ });
83
+ });
@@ -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:') {