@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
package/dist/esm/index.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* ```ts
|
|
11
11
|
* import { OxyServices, oxyClient } from '@oxyhq/core';
|
|
12
12
|
*
|
|
13
|
-
* const user = await oxyClient.
|
|
13
|
+
* const user = await oxyClient.getCurrentUser();
|
|
14
14
|
* ```
|
|
15
15
|
*
|
|
16
16
|
* Every export below is NOMINAL — no `export *`, no barrels, no compat shims.
|
|
@@ -130,13 +130,17 @@ 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).
|
|
136
140
|
// Standard OAuth against auth.oxy.so/authorize — no FedCM/cookies/SSO bounce.
|
|
137
141
|
// ---------------------------------------------------------------------------
|
|
138
142
|
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.js';
|
|
139
|
-
export { buildIdpHubOrigin, buildHubSyncUrl, isIdpHubOrigin, isOfficialWebOrigin, isAllowedDeviceJoinOrigin, normalizeOfficialReturnOrigin, parseHubSyncReturnUrl, } from './utils/officialOrigins.js';
|
|
143
|
+
export { buildIdpHubOrigin, buildHubSyncUrl, isIdpHubOrigin, isLoopbackOrigin, isOfficialWebOrigin, isAllowedDeviceJoinOrigin, normalizeOfficialReturnOrigin, parseHubSyncReturnUrl, } from './utils/officialOrigins.js';
|
|
140
144
|
export { syncHubAfterSignIn, redeemHubTicketOnHub, } from './session/hubSync.js';
|
|
141
145
|
// ---------------------------------------------------------------------------
|
|
142
146
|
// Session sync (device-scoped multi-account session client)
|
|
@@ -157,9 +161,10 @@ export { deviceStateToClientSessions, activeSessionIdOf, activeUserOf, accountId
|
|
|
157
161
|
export { projectSwitchableAccounts, switchableAccountIds, } from './session/accountProjection.js';
|
|
158
162
|
// Headless controller for the unified account dialog. Framework-agnostic
|
|
159
163
|
// state machine + subscribe/getSnapshot store (bind via `useSyncExternalStore`)
|
|
160
|
-
// —
|
|
161
|
-
//
|
|
162
|
-
//
|
|
164
|
+
// — sign-in is passkey (WebAuthn) or the Commons QR / shared-keychain handoff;
|
|
165
|
+
// password, social login, and 2FA were removed ecosystem-wide. Reuses
|
|
166
|
+
// `SessionClient.switchAccount` / `oxyServices.switchToAccount` for the uniform
|
|
167
|
+
// switch and the existing device-flow methods for sign-in.
|
|
163
168
|
export { AccountDialogController, createAccountDialogController, } from './session/accountDialogController.js';
|
|
164
169
|
// ---------------------------------------------------------------------------
|
|
165
170
|
// Device-first session machinery (zero-cookie transport).
|
|
@@ -737,106 +737,97 @@ export function OxyServicesAuthMixin(Base) {
|
|
|
737
737
|
}
|
|
738
738
|
}
|
|
739
739
|
/**
|
|
740
|
-
*
|
|
740
|
+
* Begin a WebAuthn / passkey REGISTRATION ceremony. Requests the
|
|
741
|
+
* `PublicKeyCredentialCreationOptions` the browser's `navigator.credentials
|
|
742
|
+
* .create()` (or `@simplewebauthn/browser`'s `startRegistration`) needs.
|
|
743
|
+
*
|
|
744
|
+
* With a bearer token planted this links a passkey to the signed-in account
|
|
745
|
+
* (`username` ignored); without one it is a prospective signup and `username`
|
|
746
|
+
* is the desired handle. The returned options are OPAQUE — Oxy does not own
|
|
747
|
+
* their shape (the browser / `@simplewebauthn` does), so they pass through
|
|
748
|
+
* as `unknown` for the caller to hand straight to the ceremony.
|
|
741
749
|
*/
|
|
742
|
-
async
|
|
750
|
+
async webauthnRegisterOptions(username) {
|
|
743
751
|
try {
|
|
744
|
-
|
|
745
|
-
username,
|
|
746
|
-
email,
|
|
747
|
-
password,
|
|
748
|
-
deviceName,
|
|
749
|
-
deviceFingerprint,
|
|
750
|
-
}, { cache: false });
|
|
751
|
-
return {
|
|
752
|
-
...session,
|
|
753
|
-
user: normalizeUserIdentity(session.user),
|
|
754
|
-
};
|
|
752
|
+
return await this.makeRequest('POST', '/auth/webauthn/register/options', { ...(username !== undefined ? { username } : {}) }, { cache: false });
|
|
755
753
|
}
|
|
756
754
|
catch (error) {
|
|
757
755
|
throw this.handleError(error);
|
|
758
756
|
}
|
|
759
757
|
}
|
|
760
758
|
/**
|
|
761
|
-
*
|
|
762
|
-
|
|
763
|
-
|
|
759
|
+
* Finish a WebAuthn / passkey REGISTRATION ceremony. Forwards the opaque
|
|
760
|
+
* browser `RegistrationResponseJSON` (`response`) alongside the Oxy envelope
|
|
761
|
+
* (desired `username` for signup + the device-session naming fields).
|
|
762
|
+
*
|
|
763
|
+
* Two server branches, disambiguated by the response shape:
|
|
764
|
+
* - **Signup** (no bearer): the account is created and a session minted —
|
|
765
|
+
* the response carries `sessionId`, is the SAME {@link LoginResult}
|
|
766
|
+
* contract as `POST /auth/verify`, and its access token is planted here.
|
|
767
|
+
* - **Link** (bearer present): the passkey is attached to the signed-in
|
|
768
|
+
* account and the server returns `{ success, message }` with no session,
|
|
769
|
+
* which is returned verbatim (no token planting).
|
|
770
|
+
*/
|
|
771
|
+
async webauthnRegisterVerify(response, envelope = {}) {
|
|
764
772
|
try {
|
|
765
|
-
const
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
773
|
+
const res = await this.makeRequest('POST', '/auth/webauthn/register/verify', { response, ...envelope }, { cache: false });
|
|
774
|
+
if (res && typeof res === 'object') {
|
|
775
|
+
const record = res;
|
|
776
|
+
// Signup branch: mints a session (LoginSessionResult, carries
|
|
777
|
+
// `sessionId`). Parse against the login contract and plant the token.
|
|
778
|
+
if ('sessionId' in record) {
|
|
779
|
+
const parsed = safeParseContract(loginResultSchema, record);
|
|
780
|
+
if (!parsed) {
|
|
781
|
+
throw new Error('auth/webauthn/register/verify returned an unexpected response shape');
|
|
782
|
+
}
|
|
783
|
+
if (parsed.accessToken) {
|
|
784
|
+
this.setTokens(parsed.accessToken);
|
|
785
|
+
}
|
|
786
|
+
return parsed;
|
|
787
|
+
}
|
|
788
|
+
// Link branch: passkey attached to the signed-in account, no session.
|
|
789
|
+
if (record.success === true && typeof record.message === 'string') {
|
|
790
|
+
return { success: true, message: record.message };
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
throw new Error('auth/webauthn/register/verify returned an unexpected response shape');
|
|
775
794
|
}
|
|
776
795
|
catch (error) {
|
|
777
796
|
throw this.handleError(error);
|
|
778
797
|
}
|
|
779
798
|
}
|
|
780
799
|
/**
|
|
781
|
-
*
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
return this.signIn(email, password, deviceName, deviceFingerprint);
|
|
785
|
-
}
|
|
786
|
-
/**
|
|
787
|
-
* Device-first password sign-in. Unlike the legacy {@link signIn} (which
|
|
788
|
-
* assumes a one-step session and is kept intact for existing callers until
|
|
789
|
-
* the F4 cutover), this returns the FULL `POST /auth/login` contract — the
|
|
790
|
-
* discriminated {@link LoginResult}: either a 2FA challenge
|
|
791
|
-
* (`{ twoFactorRequired, loginToken }`) to complete via
|
|
792
|
-
* {@link completeTwoFactorSignIn}, or a session arm.
|
|
800
|
+
* Begin a WebAuthn / passkey AUTHENTICATION ceremony. Requests the
|
|
801
|
+
* `PublicKeyCredentialRequestOptions` the browser's `navigator.credentials
|
|
802
|
+
* .get()` (or `@simplewebauthn/browser`'s `startAuthentication`) needs.
|
|
793
803
|
*
|
|
794
|
-
*
|
|
795
|
-
*
|
|
796
|
-
*
|
|
797
|
-
*
|
|
804
|
+
* When `username` is present the server scopes `allowCredentials` to that
|
|
805
|
+
* user's passkeys (username-first); when omitted it returns an empty
|
|
806
|
+
* allow-list for the usernameless / discoverable-credential flow. The
|
|
807
|
+
* returned options are OPAQUE and pass through as `unknown`.
|
|
798
808
|
*/
|
|
799
|
-
async
|
|
809
|
+
async webauthnLoginOptions(username) {
|
|
800
810
|
try {
|
|
801
|
-
|
|
802
|
-
identifier,
|
|
803
|
-
password,
|
|
804
|
-
deviceName: options.deviceName,
|
|
805
|
-
deviceFingerprint: options.deviceFingerprint,
|
|
806
|
-
...(options.deviceId ? { deviceId: options.deviceId } : {}),
|
|
807
|
-
}, { cache: false });
|
|
808
|
-
const parsed = safeParseContract(loginResultSchema, res);
|
|
809
|
-
if (!parsed) {
|
|
810
|
-
throw new Error('auth/login returned an unexpected response shape');
|
|
811
|
-
}
|
|
812
|
-
if (!('twoFactorRequired' in parsed) && parsed.accessToken) {
|
|
813
|
-
this.setTokens(parsed.accessToken);
|
|
814
|
-
}
|
|
815
|
-
return parsed;
|
|
811
|
+
return await this.makeRequest('POST', '/auth/webauthn/login/options', { ...(username !== undefined ? { username } : {}) }, { cache: false });
|
|
816
812
|
}
|
|
817
813
|
catch (error) {
|
|
818
814
|
throw this.handleError(error);
|
|
819
815
|
}
|
|
820
816
|
}
|
|
821
817
|
/**
|
|
822
|
-
*
|
|
823
|
-
*
|
|
824
|
-
*
|
|
825
|
-
*
|
|
826
|
-
*
|
|
818
|
+
* Finish a WebAuthn / passkey AUTHENTICATION ceremony. Forwards the opaque
|
|
819
|
+
* browser `AuthenticationResponseJSON` (`response`) alongside the
|
|
820
|
+
* device-session envelope. Resolves to the SAME {@link LoginResult} contract
|
|
821
|
+
* as `POST /auth/verify`; the access token is planted immediately, and the
|
|
822
|
+
* response's `deviceId` + `deviceSecret` are the zero-cookie restore
|
|
823
|
+
* credential.
|
|
827
824
|
*/
|
|
828
|
-
async
|
|
825
|
+
async webauthnLoginVerify(response, envelope = {}) {
|
|
829
826
|
try {
|
|
830
|
-
const res = await this.makeRequest('POST', '/
|
|
831
|
-
loginToken: params.loginToken,
|
|
832
|
-
token: params.token,
|
|
833
|
-
backupCode: params.backupCode,
|
|
834
|
-
deviceName: params.deviceName,
|
|
835
|
-
...(params.deviceId ? { deviceId: params.deviceId } : {}),
|
|
836
|
-
}, { cache: false });
|
|
827
|
+
const res = await this.makeRequest('POST', '/auth/webauthn/login/verify', { response, ...envelope }, { cache: false });
|
|
837
828
|
const parsed = safeParseContract(loginResultSchema, res);
|
|
838
|
-
if (!parsed
|
|
839
|
-
throw new Error('
|
|
829
|
+
if (!parsed) {
|
|
830
|
+
throw new Error('auth/webauthn/login/verify returned an unexpected response shape');
|
|
840
831
|
}
|
|
841
832
|
if (parsed.accessToken) {
|
|
842
833
|
this.setTokens(parsed.accessToken);
|
|
@@ -849,7 +840,7 @@ export function OxyServicesAuthMixin(Base) {
|
|
|
849
840
|
}
|
|
850
841
|
/**
|
|
851
842
|
* Exchange an OAuth authorization code (returned to the RP redirect URI
|
|
852
|
-
* after
|
|
843
|
+
* after sign-in at auth.oxy.so) for a device-first session.
|
|
853
844
|
* Public first-party clients use PKCE (`codeVerifier`); the access token is
|
|
854
845
|
* planted immediately on success.
|
|
855
846
|
*/
|
|
@@ -100,15 +100,15 @@ export function OxyServicesIdentityMixin(Base) {
|
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
102
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
103
|
+
* Unlink an authentication method from the current account. The server
|
|
104
|
+
* refuses to remove the last remaining method (the account would become
|
|
105
|
+
* inaccessible). Unlinking `identity` downgrades the account to custodial.
|
|
105
106
|
*
|
|
106
|
-
* @param
|
|
107
|
-
* @param password - The new password (server enforces strength rules).
|
|
107
|
+
* @param type - The auth-method type to remove.
|
|
108
108
|
*/
|
|
109
|
-
async
|
|
109
|
+
async unlinkAuthMethod(type) {
|
|
110
110
|
try {
|
|
111
|
-
const result = await this.makeRequest('
|
|
111
|
+
const result = await this.makeRequest('DELETE', `/auth/link/${encodeURIComponent(type)}`, undefined, { cache: false });
|
|
112
112
|
this._invalidateIdentityCaches(this.getCurrentUserId());
|
|
113
113
|
return result;
|
|
114
114
|
}
|
|
@@ -117,15 +117,19 @@ export function OxyServicesIdentityMixin(Base) {
|
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
119
|
/**
|
|
120
|
-
*
|
|
121
|
-
* refuses to remove the last remaining method (the account would become
|
|
122
|
-
* inaccessible). Unlinking `identity` downgrades the account to custodial.
|
|
120
|
+
* Remove ONE passkey (WebAuthn credential) from the current account.
|
|
123
121
|
*
|
|
124
|
-
*
|
|
122
|
+
* Passkeys are per-credential, so unlike {@link unlinkAuthMethod} (which
|
|
123
|
+
* removes an auth method by type) this targets a specific credential id.
|
|
124
|
+
* The server refuses to remove the last remaining auth method (the account
|
|
125
|
+
* would become inaccessible) and deletes the stored `WebauthnCredential`.
|
|
126
|
+
*
|
|
127
|
+
* @param credentialId - The passkey's public credential id
|
|
128
|
+
* (`AuthMethodEntry.credentialId`).
|
|
125
129
|
*/
|
|
126
|
-
async
|
|
130
|
+
async removePasskey(credentialId) {
|
|
127
131
|
try {
|
|
128
|
-
const result = await this.makeRequest('DELETE', `/auth/link/${encodeURIComponent(
|
|
132
|
+
const result = await this.makeRequest('DELETE', `/auth/link/webauthn/${encodeURIComponent(credentialId)}`, undefined, { cache: false });
|
|
129
133
|
this._invalidateIdentityCaches(this.getCurrentUserId());
|
|
130
134
|
return result;
|
|
131
135
|
}
|
|
@@ -21,14 +21,11 @@
|
|
|
21
21
|
* `oxyServices.signInWithSharedIdentity`, else the cross-device QR handoff
|
|
22
22
|
* via `startCommonsSignIn` → poll → `claimSessionByToken`).
|
|
23
23
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* builds the hand-off URL; device-first convergence syncs the session back.
|
|
24
|
+
* Sign-in is passkey (WebAuthn) or the Commons QR / shared-keychain handoff —
|
|
25
|
+
* password, social login, and 2FA were removed ecosystem-wide.
|
|
27
26
|
*/
|
|
28
27
|
import { logger } from '../logger/index.js';
|
|
29
28
|
import { extractErrorStatus } from '../utils/errorUtils.js';
|
|
30
|
-
import { CENTRAL_IDP_APEX } from '../utils/authWebUrl.js';
|
|
31
|
-
import { generateOAuthState, generatePkcePair, normalizeOAuthRedirectUri, persistOAuthHandshake, } from '../utils/oauthPkce.js';
|
|
32
29
|
import { projectSwitchableAccounts, switchableAccountIds, } from './accountProjection.js';
|
|
33
30
|
/**
|
|
34
31
|
* Slow FALLBACK poll cadence for the QR flow. The `/auth-session` socket delivers
|
|
@@ -95,8 +92,6 @@ export class AccountDialogController {
|
|
|
95
92
|
this.locale = options.locale;
|
|
96
93
|
this.commitSession = options.commitSession;
|
|
97
94
|
this.onSignedIn = options.onSignedIn;
|
|
98
|
-
this.idpApex = options.idpApex ?? CENTRAL_IDP_APEX;
|
|
99
|
-
this.authRedirectUri = options.authRedirectUri ?? null;
|
|
100
95
|
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
101
96
|
this.openUrl = options.openUrl;
|
|
102
97
|
this.canOpenApp = options.canOpenApp;
|
|
@@ -485,46 +480,6 @@ export class AccountDialogController {
|
|
|
485
480
|
this.setSignIn(IDLE_SIGN_IN);
|
|
486
481
|
}
|
|
487
482
|
}
|
|
488
|
-
/**
|
|
489
|
-
* Build (and, when an `openUrl` handler was supplied, open) the auth.oxy.so
|
|
490
|
-
* password sign-in URL. Password + 2FA are NOT in the SDK — they live at the
|
|
491
|
-
* IdP; this only hands off. Device-first: after login at the IdP the device
|
|
492
|
-
* session converges and the caller is woken via the device socket /
|
|
493
|
-
* `BroadcastChannel`, so the URL only needs to point at the IdP sign-in with
|
|
494
|
-
* the right return.
|
|
495
|
-
*
|
|
496
|
-
* @param params.returnUrl - Where the IdP returns after login. Defaults to the
|
|
497
|
-
* current document URL on web (`globalThis.location.href`); pass explicitly
|
|
498
|
-
* on native (no `location`).
|
|
499
|
-
* @param params.state - Optional opaque state echoed back on return.
|
|
500
|
-
* @returns The absolute auth.oxy.so sign-in URL.
|
|
501
|
-
*/
|
|
502
|
-
async openPasswordAtOxyAuth(params = {}) {
|
|
503
|
-
const base = `https://auth.${this.idpApex}`;
|
|
504
|
-
const url = new URL('/login', base);
|
|
505
|
-
const rawRedirect = params.redirectUri ??
|
|
506
|
-
this.authRedirectUri ??
|
|
507
|
-
params.returnUrl ??
|
|
508
|
-
currentLocationOrigin();
|
|
509
|
-
const redirectUri = rawRedirect ? normalizeOAuthRedirectUri(rawRedirect) : '';
|
|
510
|
-
if (redirectUri) {
|
|
511
|
-
url.searchParams.set('redirect_uri', redirectUri);
|
|
512
|
-
}
|
|
513
|
-
if (this.clientId) {
|
|
514
|
-
url.searchParams.set('client_id', this.clientId);
|
|
515
|
-
}
|
|
516
|
-
const state = params.state ?? (await generateOAuthState());
|
|
517
|
-
url.searchParams.set('state', state);
|
|
518
|
-
const { codeChallenge, codeVerifier } = await generatePkcePair();
|
|
519
|
-
url.searchParams.set('code_challenge', codeChallenge);
|
|
520
|
-
url.searchParams.set('code_challenge_method', 'S256');
|
|
521
|
-
if (!persistOAuthHandshake(state, codeVerifier)) {
|
|
522
|
-
throw new Error('Could not persist OAuth handshake for password sign-in');
|
|
523
|
-
}
|
|
524
|
-
const href = url.toString();
|
|
525
|
-
this.openUrl?.(href);
|
|
526
|
-
return href;
|
|
527
|
-
}
|
|
528
483
|
// =========================================================================
|
|
529
484
|
// Internal sign-in helpers
|
|
530
485
|
// =========================================================================
|
|
@@ -772,11 +727,3 @@ export class AccountDialogController {
|
|
|
772
727
|
export function createAccountDialogController(options) {
|
|
773
728
|
return new AccountDialogController(options);
|
|
774
729
|
}
|
|
775
|
-
// ---------------------------------------------------------------------------
|
|
776
|
-
// Local helpers
|
|
777
|
-
// ---------------------------------------------------------------------------
|
|
778
|
-
/** Current document origin on web; empty string where `location` is absent (native/SSR). */
|
|
779
|
-
function currentLocationOrigin() {
|
|
780
|
-
const location = globalThis.location;
|
|
781
|
-
return typeof location?.origin === 'string' ? location.origin : '';
|
|
782
|
-
}
|
|
@@ -37,7 +37,12 @@ export function isIdpHubOrigin() {
|
|
|
37
37
|
return false;
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Whether an origin is a loopback / local-dev origin (`localhost`, `127.0.0.1`,
|
|
42
|
+
* or `[::1]` on any port, http or https). Local dev must never be bounced to a
|
|
43
|
+
* hosted IdP for cross-origin session restore.
|
|
44
|
+
*/
|
|
45
|
+
export function isLoopbackOrigin(origin) {
|
|
41
46
|
try {
|
|
42
47
|
const parsed = new URL(origin);
|
|
43
48
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
@@ -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
|
+
}
|