@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
@@ -0,0 +1,52 @@
1
+ /**
2
+ * WebAuthn relying-party origin guard (client side).
3
+ *
4
+ * The passkey ceremonies (`OxyServices.webauthn*`) are only meaningful when the
5
+ * page is served from a first-party Oxy web origin: a credential minted with
6
+ * `WEBAUTHN_RP_ID=oxy.so` can only be created/asserted from `oxy.so`, one of its
7
+ * subdomains, or a loopback dev server. This is the browser-side mirror of the
8
+ * server's `isOxyApexOrigin` (`packages/api/src/utils/origin.ts`), which forms
9
+ * the server's `expectedOrigin` allow-set — consumers use it to decide whether to
10
+ * even offer the passkey UI on the current page.
11
+ *
12
+ * It reads `globalThis.location` directly (no argument) because that is the only
13
+ * origin the browser will let a WebAuthn ceremony run against. On native / SSR /
14
+ * any environment without a DOM `location`, it returns `false` (there is no
15
+ * relying-party origin, so passkeys are not applicable).
16
+ */
17
+
18
+ /**
19
+ * True iff the current page's host is a first-party Oxy relying-party origin:
20
+ * `oxy.so`, any `*.oxy.so` subdomain, or a loopback dev host
21
+ * (`localhost` / `127.0.0.1` / `[::1]`).
22
+ *
23
+ * Fails closed: no `location` (native/SSR), a non-string/empty hostname, or a
24
+ * host that merely ends in the literal `oxy.so` without the dot boundary
25
+ * (`evil-oxy.so`, `oxy.so.evil.com`) all return `false`.
26
+ *
27
+ * @example
28
+ * isOxyRpOrigin() // true on https://accounts.oxy.so
29
+ * isOxyRpOrigin() // true on http://localhost:8081
30
+ * isOxyRpOrigin() // false on https://evil.com
31
+ * isOxyRpOrigin() // false in a React Native / SSR context (no location)
32
+ */
33
+ export function isOxyRpOrigin(): boolean {
34
+ // `globalThis.location` is typed `Location` by the DOM lib, but is genuinely
35
+ // `undefined` on native/SSR — widen to a nullable local so the guard is a real
36
+ // runtime check, not a type-level no-op.
37
+ const location: Location | undefined = globalThis.location;
38
+ if (!location || typeof location.hostname !== 'string') {
39
+ return false;
40
+ }
41
+
42
+ const hostname = location.hostname.toLowerCase();
43
+ if (hostname.length === 0) {
44
+ return false;
45
+ }
46
+
47
+ if (hostname === 'oxy.so' || hostname.endsWith('.oxy.so')) {
48
+ return true;
49
+ }
50
+
51
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
52
+ }
@@ -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
- });