@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +13 -6
- package/dist/cjs/mixins/OxyServices.auth.js +65 -74
- package/dist/cjs/mixins/OxyServices.identity.js +16 -12
- package/dist/cjs/session/accountDialogController.js +2 -55
- package/dist/cjs/utils/officialOrigins.js +6 -0
- package/dist/cjs/utils/webauthnOrigin.js +51 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/index.js +10 -5
- package/dist/esm/mixins/OxyServices.auth.js +65 -74
- package/dist/esm/mixins/OxyServices.identity.js +16 -12
- package/dist/esm/session/accountDialogController.js +2 -55
- package/dist/esm/utils/officialOrigins.js +6 -1
- package/dist/esm/utils/webauthnOrigin.js +48 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +3 -2
- package/dist/types/mixins/OxyServices.auth.d.ts +50 -36
- package/dist/types/mixins/OxyServices.identity.d.ts +13 -9
- package/dist/types/session/accountDialogController.d.ts +4 -34
- package/dist/types/utils/officialOrigins.d.ts +6 -0
- package/dist/types/utils/webauthnOrigin.d.ts +32 -0
- package/package.json +2 -2
- package/src/index.ts +11 -4
- package/src/mixins/OxyServices.auth.ts +95 -100
- 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 +206 -0
- package/src/session/__tests__/accountDialogController.test.ts +0 -66
- package/src/session/accountDialogController.ts +4 -78
- package/src/utils/__tests__/officialOrigins.test.ts +14 -0
- package/src/utils/__tests__/webauthnOrigin.test.ts +83 -0
- package/src/utils/officialOrigins.ts +6 -1
- package/src/utils/webauthnOrigin.ts +52 -0
- 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
|
-
});
|