@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.
package/dist/esm/index.js CHANGED
@@ -130,6 +130,10 @@ export { buildAccountsArray, createQuickAccount, getAccountDisplayName, getAccou
130
130
  // ---------------------------------------------------------------------------
131
131
  export { registrableApex } from './utils/registrableApex.js';
132
132
  export { CENTRAL_IDP_APEX } from './utils/authWebUrl.js';
133
+ // WebAuthn relying-party origin guard (client side). Mirrors the server's
134
+ // `isOxyApexOrigin` so consumers can decide whether to offer passkey UI on the
135
+ // current page (first-party Oxy origin / loopback only).
136
+ export { isOxyRpOrigin } from './utils/webauthnOrigin.js';
133
137
  export { runColdBoot } from './utils/coldBoot.js';
134
138
  // ---------------------------------------------------------------------------
135
139
  // OAuth 2.0 Authorization Code + PKCE helpers ("Sign in with Oxy" third party).
@@ -847,6 +847,108 @@ export function OxyServicesAuthMixin(Base) {
847
847
  throw this.handleError(error);
848
848
  }
849
849
  }
850
+ /**
851
+ * Begin a WebAuthn / passkey REGISTRATION ceremony. Requests the
852
+ * `PublicKeyCredentialCreationOptions` the browser's `navigator.credentials
853
+ * .create()` (or `@simplewebauthn/browser`'s `startRegistration`) needs.
854
+ *
855
+ * With a bearer token planted this links a passkey to the signed-in account
856
+ * (`username` ignored); without one it is a prospective signup and `username`
857
+ * is the desired handle. The returned options are OPAQUE — Oxy does not own
858
+ * their shape (the browser / `@simplewebauthn` does), so they pass through
859
+ * as `unknown` for the caller to hand straight to the ceremony.
860
+ */
861
+ async webauthnRegisterOptions(username) {
862
+ try {
863
+ return await this.makeRequest('POST', '/auth/webauthn/register/options', { ...(username !== undefined ? { username } : {}) }, { cache: false });
864
+ }
865
+ catch (error) {
866
+ throw this.handleError(error);
867
+ }
868
+ }
869
+ /**
870
+ * Finish a WebAuthn / passkey REGISTRATION ceremony. Forwards the opaque
871
+ * browser `RegistrationResponseJSON` (`response`) alongside the Oxy envelope
872
+ * (desired `username` for signup + the device-session naming fields).
873
+ *
874
+ * Two server branches, disambiguated by the response shape:
875
+ * - **Signup** (no bearer): the account is created and a session minted —
876
+ * the response carries `sessionId`, is the SAME {@link LoginResult}
877
+ * contract as `POST /auth/verify`, and its access token is planted here.
878
+ * - **Link** (bearer present): the passkey is attached to the signed-in
879
+ * account and the server returns `{ success, message }` with no session,
880
+ * which is returned verbatim (no token planting).
881
+ */
882
+ async webauthnRegisterVerify(response, envelope = {}) {
883
+ try {
884
+ const res = await this.makeRequest('POST', '/auth/webauthn/register/verify', { response, ...envelope }, { cache: false });
885
+ if (res && typeof res === 'object') {
886
+ const record = res;
887
+ // Signup branch: mints a session (LoginSessionResult, carries
888
+ // `sessionId`). Parse against the login contract and plant the token.
889
+ if ('sessionId' in record) {
890
+ const parsed = safeParseContract(loginResultSchema, record);
891
+ if (!parsed) {
892
+ throw new Error('auth/webauthn/register/verify returned an unexpected response shape');
893
+ }
894
+ if (!('twoFactorRequired' in parsed) && parsed.accessToken) {
895
+ this.setTokens(parsed.accessToken);
896
+ }
897
+ return parsed;
898
+ }
899
+ // Link branch: passkey attached to the signed-in account, no session.
900
+ if (record.success === true && typeof record.message === 'string') {
901
+ return { success: true, message: record.message };
902
+ }
903
+ }
904
+ throw new Error('auth/webauthn/register/verify returned an unexpected response shape');
905
+ }
906
+ catch (error) {
907
+ throw this.handleError(error);
908
+ }
909
+ }
910
+ /**
911
+ * Begin a WebAuthn / passkey AUTHENTICATION ceremony. Requests the
912
+ * `PublicKeyCredentialRequestOptions` the browser's `navigator.credentials
913
+ * .get()` (or `@simplewebauthn/browser`'s `startAuthentication`) needs.
914
+ *
915
+ * When `username` is present the server scopes `allowCredentials` to that
916
+ * user's passkeys (username-first); when omitted it returns an empty
917
+ * allow-list for the usernameless / discoverable-credential flow. The
918
+ * returned options are OPAQUE and pass through as `unknown`.
919
+ */
920
+ async webauthnLoginOptions(username) {
921
+ try {
922
+ return await this.makeRequest('POST', '/auth/webauthn/login/options', { ...(username !== undefined ? { username } : {}) }, { cache: false });
923
+ }
924
+ catch (error) {
925
+ throw this.handleError(error);
926
+ }
927
+ }
928
+ /**
929
+ * Finish a WebAuthn / passkey AUTHENTICATION ceremony. Forwards the opaque
930
+ * browser `AuthenticationResponseJSON` (`response`) alongside the
931
+ * device-session envelope. Resolves to the SAME {@link LoginResult} contract
932
+ * as `POST /auth/verify`; on the session arm the access token is planted
933
+ * immediately (mirroring {@link passwordSignIn}), and the response's
934
+ * `deviceId` + `deviceSecret` are the zero-cookie restore credential.
935
+ */
936
+ async webauthnLoginVerify(response, envelope = {}) {
937
+ try {
938
+ const res = await this.makeRequest('POST', '/auth/webauthn/login/verify', { response, ...envelope }, { cache: false });
939
+ const parsed = safeParseContract(loginResultSchema, res);
940
+ if (!parsed) {
941
+ throw new Error('auth/webauthn/login/verify returned an unexpected response shape');
942
+ }
943
+ if (!('twoFactorRequired' in parsed) && parsed.accessToken) {
944
+ this.setTokens(parsed.accessToken);
945
+ }
946
+ return parsed;
947
+ }
948
+ catch (error) {
949
+ throw this.handleError(error);
950
+ }
951
+ }
850
952
  /**
851
953
  * Exchange an OAuth authorization code (returned to the RP redirect URI
852
954
  * after password sign-in at auth.oxy.so) for a device-first session.
@@ -0,0 +1,48 @@
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 function isOxyRpOrigin() {
33
+ // `globalThis.location` is typed `Location` by the DOM lib, but is genuinely
34
+ // `undefined` on native/SSR — widen to a nullable local so the guard is a real
35
+ // runtime check, not a type-level no-op.
36
+ const location = globalThis.location;
37
+ if (!location || typeof location.hostname !== 'string') {
38
+ return false;
39
+ }
40
+ const hostname = location.hostname.toLowerCase();
41
+ if (hostname.length === 0) {
42
+ return false;
43
+ }
44
+ if (hostname === 'oxy.so' || hostname.endsWith('.oxy.so')) {
45
+ return true;
46
+ }
47
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
48
+ }