@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/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +7 -2
- package/dist/cjs/mixins/OxyServices.auth.js +102 -0
- package/dist/cjs/utils/webauthnOrigin.js +51 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/index.js +4 -0
- package/dist/esm/mixins/OxyServices.auth.js +102 -0
- package/dist/esm/utils/webauthnOrigin.js +48 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/mixins/OxyServices.auth.d.ts +58 -0
- package/dist/types/utils/webauthnOrigin.d.ts +32 -0
- package/package.json +2 -2
- package/src/index.ts +5 -0
- package/src/mixins/OxyServices.auth.ts +133 -0
- package/src/mixins/__tests__/webauthnAuth.test.ts +206 -0
- package/src/utils/__tests__/webauthnOrigin.test.ts +83 -0
- package/src/utils/webauthnOrigin.ts +52 -0
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
|
+
}
|