@oxyhq/core 11.0.0 → 11.0.1

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.
@@ -82,6 +82,7 @@ export { buildAccountsArray, createQuickAccount, getAccountDisplayName, getAccou
82
82
  export type { QuickAccount, DisplayNameUserShape } from './utils/accountUtils';
83
83
  export { registrableApex } from './utils/registrableApex';
84
84
  export { CENTRAL_IDP_APEX } from './utils/authWebUrl';
85
+ export { isOxyRpOrigin } from './utils/webauthnOrigin';
85
86
  export { runColdBoot } from './utils/coldBoot';
86
87
  export type { ColdBootStep, ColdBootStepResult, ColdBootSession, ColdBootSkip, ColdBootOutcome, RunColdBootOptions, } from './utils/coldBoot';
87
88
  export { buildOAuthAuthorizeUrl, computeCodeChallenge, generateOAuthState, generatePkcePair, DEFAULT_OAUTH_SCOPE, OXY_AUTHORIZE_URL, OXY_OAUTH_STATE_STORAGE_KEY, OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY, OXY_SILENT_OAUTH_ATTEMPTED_KEY, OXY_CROSS_ORIGIN_RESTORE_ATTEMPTED_KEY, normalizeOAuthRedirectUri, persistOAuthHandshake, readOAuthHandshake, clearOAuthHandshake, } from './utils/oauthPkce';
@@ -494,6 +494,64 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
494
494
  deviceName?: string;
495
495
  deviceId?: string;
496
496
  }): Promise<LoginSessionResult>;
497
+ /**
498
+ * Begin a WebAuthn / passkey REGISTRATION ceremony. Requests the
499
+ * `PublicKeyCredentialCreationOptions` the browser's `navigator.credentials
500
+ * .create()` (or `@simplewebauthn/browser`'s `startRegistration`) needs.
501
+ *
502
+ * With a bearer token planted this links a passkey to the signed-in account
503
+ * (`username` ignored); without one it is a prospective signup and `username`
504
+ * is the desired handle. The returned options are OPAQUE — Oxy does not own
505
+ * their shape (the browser / `@simplewebauthn` does), so they pass through
506
+ * as `unknown` for the caller to hand straight to the ceremony.
507
+ */
508
+ webauthnRegisterOptions(username?: string): Promise<unknown>;
509
+ /**
510
+ * Finish a WebAuthn / passkey REGISTRATION ceremony. Forwards the opaque
511
+ * browser `RegistrationResponseJSON` (`response`) alongside the Oxy envelope
512
+ * (desired `username` for signup + the device-session naming fields).
513
+ *
514
+ * Two server branches, disambiguated by the response shape:
515
+ * - **Signup** (no bearer): the account is created and a session minted —
516
+ * the response carries `sessionId`, is the SAME {@link LoginResult}
517
+ * contract as `POST /auth/verify`, and its access token is planted here.
518
+ * - **Link** (bearer present): the passkey is attached to the signed-in
519
+ * account and the server returns `{ success, message }` with no session,
520
+ * which is returned verbatim (no token planting).
521
+ */
522
+ webauthnRegisterVerify(response: unknown, envelope?: {
523
+ username?: string;
524
+ deviceName?: string;
525
+ deviceFingerprint?: string;
526
+ deviceId?: string;
527
+ }): Promise<{
528
+ success: true;
529
+ message: string;
530
+ } | LoginResult>;
531
+ /**
532
+ * Begin a WebAuthn / passkey AUTHENTICATION ceremony. Requests the
533
+ * `PublicKeyCredentialRequestOptions` the browser's `navigator.credentials
534
+ * .get()` (or `@simplewebauthn/browser`'s `startAuthentication`) needs.
535
+ *
536
+ * When `username` is present the server scopes `allowCredentials` to that
537
+ * user's passkeys (username-first); when omitted it returns an empty
538
+ * allow-list for the usernameless / discoverable-credential flow. The
539
+ * returned options are OPAQUE and pass through as `unknown`.
540
+ */
541
+ webauthnLoginOptions(username?: string): Promise<unknown>;
542
+ /**
543
+ * Finish a WebAuthn / passkey AUTHENTICATION ceremony. Forwards the opaque
544
+ * browser `AuthenticationResponseJSON` (`response`) alongside the
545
+ * device-session envelope. Resolves to the SAME {@link LoginResult} contract
546
+ * as `POST /auth/verify`; on the session arm the access token is planted
547
+ * immediately (mirroring {@link passwordSignIn}), and the response's
548
+ * `deviceId` + `deviceSecret` are the zero-cookie restore credential.
549
+ */
550
+ webauthnLoginVerify(response: unknown, envelope?: {
551
+ deviceName?: string;
552
+ deviceFingerprint?: string;
553
+ deviceId?: string;
554
+ }): Promise<LoginResult>;
497
555
  /**
498
556
  * Exchange an OAuth authorization code (returned to the RP redirect URI
499
557
  * after password sign-in at auth.oxy.so) for a device-first session.
@@ -0,0 +1,32 @@
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
+ * True iff the current page's host is a first-party Oxy relying-party origin:
19
+ * `oxy.so`, any `*.oxy.so` subdomain, or a loopback dev host
20
+ * (`localhost` / `127.0.0.1` / `[::1]`).
21
+ *
22
+ * Fails closed: no `location` (native/SSR), a non-string/empty hostname, or a
23
+ * host that merely ends in the literal `oxy.so` without the dot boundary
24
+ * (`evil-oxy.so`, `oxy.so.evil.com`) all return `false`.
25
+ *
26
+ * @example
27
+ * isOxyRpOrigin() // true on https://accounts.oxy.so
28
+ * isOxyRpOrigin() // true on http://localhost:8081
29
+ * isOxyRpOrigin() // false on https://evil.com
30
+ * isOxyRpOrigin() // false in a React Native / SSR context (no location)
31
+ */
32
+ export declare function isOxyRpOrigin(): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "11.0.0",
3
+ "version": "11.0.1",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -109,7 +109,7 @@
109
109
  }
110
110
  },
111
111
  "dependencies": {
112
- "@oxyhq/contracts": "^0.14.1",
112
+ "@oxyhq/contracts": "^0.14.2",
113
113
  "@oxyhq/protocol": "^0.1.5",
114
114
  "bip39": "^3.1.0",
115
115
  "buffer": "^6.0.3",
package/src/index.ts CHANGED
@@ -517,6 +517,11 @@ export type { QuickAccount, DisplayNameUserShape } from './utils/accountUtils';
517
517
  export { registrableApex } from './utils/registrableApex';
518
518
  export { CENTRAL_IDP_APEX } from './utils/authWebUrl';
519
519
 
520
+ // WebAuthn relying-party origin guard (client side). Mirrors the server's
521
+ // `isOxyApexOrigin` so consumers can decide whether to offer passkey UI on the
522
+ // current page (first-party Oxy origin / loopback only).
523
+ export { isOxyRpOrigin } from './utils/webauthnOrigin';
524
+
520
525
  export { runColdBoot } from './utils/coldBoot';
521
526
  export type {
522
527
  ColdBootStep,
@@ -1196,6 +1196,139 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1196
1196
  }
1197
1197
  }
1198
1198
 
1199
+ /**
1200
+ * Begin a WebAuthn / passkey REGISTRATION ceremony. Requests the
1201
+ * `PublicKeyCredentialCreationOptions` the browser's `navigator.credentials
1202
+ * .create()` (or `@simplewebauthn/browser`'s `startRegistration`) needs.
1203
+ *
1204
+ * With a bearer token planted this links a passkey to the signed-in account
1205
+ * (`username` ignored); without one it is a prospective signup and `username`
1206
+ * is the desired handle. The returned options are OPAQUE — Oxy does not own
1207
+ * their shape (the browser / `@simplewebauthn` does), so they pass through
1208
+ * as `unknown` for the caller to hand straight to the ceremony.
1209
+ */
1210
+ async webauthnRegisterOptions(username?: string): Promise<unknown> {
1211
+ try {
1212
+ return await this.makeRequest<unknown>(
1213
+ 'POST',
1214
+ '/auth/webauthn/register/options',
1215
+ { ...(username !== undefined ? { username } : {}) },
1216
+ { cache: false },
1217
+ );
1218
+ } catch (error) {
1219
+ throw this.handleError(error);
1220
+ }
1221
+ }
1222
+
1223
+ /**
1224
+ * Finish a WebAuthn / passkey REGISTRATION ceremony. Forwards the opaque
1225
+ * browser `RegistrationResponseJSON` (`response`) alongside the Oxy envelope
1226
+ * (desired `username` for signup + the device-session naming fields).
1227
+ *
1228
+ * Two server branches, disambiguated by the response shape:
1229
+ * - **Signup** (no bearer): the account is created and a session minted —
1230
+ * the response carries `sessionId`, is the SAME {@link LoginResult}
1231
+ * contract as `POST /auth/verify`, and its access token is planted here.
1232
+ * - **Link** (bearer present): the passkey is attached to the signed-in
1233
+ * account and the server returns `{ success, message }` with no session,
1234
+ * which is returned verbatim (no token planting).
1235
+ */
1236
+ async webauthnRegisterVerify(
1237
+ response: unknown,
1238
+ envelope: {
1239
+ username?: string;
1240
+ deviceName?: string;
1241
+ deviceFingerprint?: string;
1242
+ deviceId?: string;
1243
+ } = {},
1244
+ ): Promise<{ success: true; message: string } | LoginResult> {
1245
+ try {
1246
+ const res = await this.makeRequest<unknown>(
1247
+ 'POST',
1248
+ '/auth/webauthn/register/verify',
1249
+ { response, ...envelope },
1250
+ { cache: false },
1251
+ );
1252
+ if (res && typeof res === 'object') {
1253
+ const record = res as Record<string, unknown>;
1254
+ // Signup branch: mints a session (LoginSessionResult, carries
1255
+ // `sessionId`). Parse against the login contract and plant the token.
1256
+ if ('sessionId' in record) {
1257
+ const parsed = safeParseContract(loginResultSchema, record);
1258
+ if (!parsed) {
1259
+ throw new Error('auth/webauthn/register/verify returned an unexpected response shape');
1260
+ }
1261
+ if (!('twoFactorRequired' in parsed) && parsed.accessToken) {
1262
+ this.setTokens(parsed.accessToken);
1263
+ }
1264
+ return parsed;
1265
+ }
1266
+ // Link branch: passkey attached to the signed-in account, no session.
1267
+ if (record.success === true && typeof record.message === 'string') {
1268
+ return { success: true, message: record.message };
1269
+ }
1270
+ }
1271
+ throw new Error('auth/webauthn/register/verify returned an unexpected response shape');
1272
+ } catch (error) {
1273
+ throw this.handleError(error);
1274
+ }
1275
+ }
1276
+
1277
+ /**
1278
+ * Begin a WebAuthn / passkey AUTHENTICATION ceremony. Requests the
1279
+ * `PublicKeyCredentialRequestOptions` the browser's `navigator.credentials
1280
+ * .get()` (or `@simplewebauthn/browser`'s `startAuthentication`) needs.
1281
+ *
1282
+ * When `username` is present the server scopes `allowCredentials` to that
1283
+ * user's passkeys (username-first); when omitted it returns an empty
1284
+ * allow-list for the usernameless / discoverable-credential flow. The
1285
+ * returned options are OPAQUE and pass through as `unknown`.
1286
+ */
1287
+ async webauthnLoginOptions(username?: string): Promise<unknown> {
1288
+ try {
1289
+ return await this.makeRequest<unknown>(
1290
+ 'POST',
1291
+ '/auth/webauthn/login/options',
1292
+ { ...(username !== undefined ? { username } : {}) },
1293
+ { cache: false },
1294
+ );
1295
+ } catch (error) {
1296
+ throw this.handleError(error);
1297
+ }
1298
+ }
1299
+
1300
+ /**
1301
+ * Finish a WebAuthn / passkey AUTHENTICATION ceremony. Forwards the opaque
1302
+ * browser `AuthenticationResponseJSON` (`response`) alongside the
1303
+ * device-session envelope. Resolves to the SAME {@link LoginResult} contract
1304
+ * as `POST /auth/verify`; on the session arm the access token is planted
1305
+ * immediately (mirroring {@link passwordSignIn}), and the response's
1306
+ * `deviceId` + `deviceSecret` are the zero-cookie restore credential.
1307
+ */
1308
+ async webauthnLoginVerify(
1309
+ response: unknown,
1310
+ envelope: { deviceName?: string; deviceFingerprint?: string; deviceId?: string } = {},
1311
+ ): Promise<LoginResult> {
1312
+ try {
1313
+ const res = await this.makeRequest<unknown>(
1314
+ 'POST',
1315
+ '/auth/webauthn/login/verify',
1316
+ { response, ...envelope },
1317
+ { cache: false },
1318
+ );
1319
+ const parsed = safeParseContract(loginResultSchema, res);
1320
+ if (!parsed) {
1321
+ throw new Error('auth/webauthn/login/verify returned an unexpected response shape');
1322
+ }
1323
+ if (!('twoFactorRequired' in parsed) && parsed.accessToken) {
1324
+ this.setTokens(parsed.accessToken);
1325
+ }
1326
+ return parsed;
1327
+ } catch (error) {
1328
+ throw this.handleError(error);
1329
+ }
1330
+ }
1331
+
1199
1332
  /**
1200
1333
  * Exchange an OAuth authorization code (returned to the RP redirect URI
1201
1334
  * after password sign-in at auth.oxy.so) for a device-first session.
@@ -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, like `passwordSignIn.test.ts`) 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
+ });
@@ -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
+ });
@@ -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
+ }