@oxyhq/core 11.0.1 → 12.1.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 +7 -5
- package/dist/cjs/mixins/OxyServices.auth.js +6 -117
- package/dist/cjs/mixins/OxyServices.identity.js +16 -12
- package/dist/cjs/session/accountDialogController.js +84 -75
- package/dist/cjs/utils/officialOrigins.js +6 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/index.js +6 -5
- package/dist/esm/mixins/OxyServices.auth.js +6 -117
- package/dist/esm/mixins/OxyServices.identity.js +16 -12
- package/dist/esm/session/accountDialogController.js +84 -75
- package/dist/esm/utils/officialOrigins.js +6 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +3 -3
- package/dist/types/mixins/OxyServices.auth.d.ts +4 -48
- package/dist/types/mixins/OxyServices.identity.d.ts +13 -9
- package/dist/types/session/accountDialogController.d.ts +84 -49
- package/dist/types/utils/officialOrigins.d.ts +6 -0
- package/package.json +2 -2
- package/src/index.ts +7 -4
- package/src/mixins/OxyServices.auth.ts +6 -144
- 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 +1 -1
- package/src/session/__tests__/accountDialogController.test.ts +75 -64
- package/src/session/__tests__/accountProjection.test.ts +30 -0
- package/src/session/accountDialogController.ts +131 -104
- package/src/utils/__tests__/officialOrigins.test.ts +14 -0
- package/src/utils/officialOrigins.ts +6 -1
- 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.
|
|
@@ -140,7 +140,7 @@ export { runColdBoot } from './utils/coldBoot.js';
|
|
|
140
140
|
// Standard OAuth against auth.oxy.so/authorize — no FedCM/cookies/SSO bounce.
|
|
141
141
|
// ---------------------------------------------------------------------------
|
|
142
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';
|
|
143
|
-
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';
|
|
144
144
|
export { syncHubAfterSignIn, redeemHubTicketOnHub, } from './session/hubSync.js';
|
|
145
145
|
// ---------------------------------------------------------------------------
|
|
146
146
|
// Session sync (device-scoped multi-account session client)
|
|
@@ -161,9 +161,10 @@ export { deviceStateToClientSessions, activeSessionIdOf, activeUserOf, accountId
|
|
|
161
161
|
export { projectSwitchableAccounts, switchableAccountIds, } from './session/accountProjection.js';
|
|
162
162
|
// Headless controller for the unified account dialog. Framework-agnostic
|
|
163
163
|
// state machine + subscribe/getSnapshot store (bind via `useSyncExternalStore`)
|
|
164
|
-
// —
|
|
165
|
-
//
|
|
166
|
-
//
|
|
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.
|
|
167
168
|
export { AccountDialogController, createAccountDialogController, } from './session/accountDialogController.js';
|
|
168
169
|
// ---------------------------------------------------------------------------
|
|
169
170
|
// Device-first session machinery (zero-cookie transport).
|
|
@@ -736,117 +736,6 @@ export function OxyServicesAuthMixin(Base) {
|
|
|
736
736
|
throw this.handleError(error);
|
|
737
737
|
}
|
|
738
738
|
}
|
|
739
|
-
/**
|
|
740
|
-
* Register a new user with email/username and password
|
|
741
|
-
*/
|
|
742
|
-
async signUp(username, email, password, deviceName, deviceFingerprint) {
|
|
743
|
-
try {
|
|
744
|
-
const session = await this.makeRequest('POST', '/auth/signup', {
|
|
745
|
-
username,
|
|
746
|
-
email,
|
|
747
|
-
password,
|
|
748
|
-
deviceName,
|
|
749
|
-
deviceFingerprint,
|
|
750
|
-
}, { cache: false });
|
|
751
|
-
return {
|
|
752
|
-
...session,
|
|
753
|
-
user: normalizeUserIdentity(session.user),
|
|
754
|
-
};
|
|
755
|
-
}
|
|
756
|
-
catch (error) {
|
|
757
|
-
throw this.handleError(error);
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
/**
|
|
761
|
-
* Sign in with email or username and password
|
|
762
|
-
*/
|
|
763
|
-
async signIn(identifier, password, deviceName, deviceFingerprint) {
|
|
764
|
-
try {
|
|
765
|
-
const session = await this.makeRequest('POST', '/auth/login', {
|
|
766
|
-
identifier,
|
|
767
|
-
password,
|
|
768
|
-
deviceName,
|
|
769
|
-
deviceFingerprint,
|
|
770
|
-
}, { cache: false });
|
|
771
|
-
return {
|
|
772
|
-
...session,
|
|
773
|
-
user: normalizeUserIdentity(session.user),
|
|
774
|
-
};
|
|
775
|
-
}
|
|
776
|
-
catch (error) {
|
|
777
|
-
throw this.handleError(error);
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
/**
|
|
781
|
-
* Convenience helper for email sign-in
|
|
782
|
-
*/
|
|
783
|
-
async signInWithEmail(email, password, deviceName, deviceFingerprint) {
|
|
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.
|
|
793
|
-
*
|
|
794
|
-
* On the session arm, a returned access token is planted immediately
|
|
795
|
-
* (mirroring {@link verifyChallenge}), so the caller has an authenticated
|
|
796
|
-
* client without a second round-trip. The response's `deviceId` +
|
|
797
|
-
* `deviceSecret` are the zero-cookie restore credential the caller persists.
|
|
798
|
-
*/
|
|
799
|
-
async passwordSignIn(identifier, password, options = {}) {
|
|
800
|
-
try {
|
|
801
|
-
const res = await this.makeRequest('POST', '/auth/login', {
|
|
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;
|
|
816
|
-
}
|
|
817
|
-
catch (error) {
|
|
818
|
-
throw this.handleError(error);
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
/**
|
|
822
|
-
* Complete a 2FA-gated sign-in started by {@link passwordSignIn}. Presents
|
|
823
|
-
* the short-lived `loginToken` with either a TOTP `token` or a `backupCode`
|
|
824
|
-
* to `POST /security/2fa/verify-login`, which must resolve to the session
|
|
825
|
-
* arm of {@link LoginResult} (a second 2FA challenge here is a protocol
|
|
826
|
-
* error). A returned access token is planted immediately.
|
|
827
|
-
*/
|
|
828
|
-
async completeTwoFactorSignIn(params) {
|
|
829
|
-
try {
|
|
830
|
-
const res = await this.makeRequest('POST', '/security/2fa/verify-login', {
|
|
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 });
|
|
837
|
-
const parsed = safeParseContract(loginResultSchema, res);
|
|
838
|
-
if (!parsed || 'twoFactorRequired' in parsed) {
|
|
839
|
-
throw new Error('security/2fa/verify-login returned an unexpected response shape');
|
|
840
|
-
}
|
|
841
|
-
if (parsed.accessToken) {
|
|
842
|
-
this.setTokens(parsed.accessToken);
|
|
843
|
-
}
|
|
844
|
-
return parsed;
|
|
845
|
-
}
|
|
846
|
-
catch (error) {
|
|
847
|
-
throw this.handleError(error);
|
|
848
|
-
}
|
|
849
|
-
}
|
|
850
739
|
/**
|
|
851
740
|
* Begin a WebAuthn / passkey REGISTRATION ceremony. Requests the
|
|
852
741
|
* `PublicKeyCredentialCreationOptions` the browser's `navigator.credentials
|
|
@@ -891,7 +780,7 @@ export function OxyServicesAuthMixin(Base) {
|
|
|
891
780
|
if (!parsed) {
|
|
892
781
|
throw new Error('auth/webauthn/register/verify returned an unexpected response shape');
|
|
893
782
|
}
|
|
894
|
-
if (
|
|
783
|
+
if (parsed.accessToken) {
|
|
895
784
|
this.setTokens(parsed.accessToken);
|
|
896
785
|
}
|
|
897
786
|
return parsed;
|
|
@@ -929,9 +818,9 @@ export function OxyServicesAuthMixin(Base) {
|
|
|
929
818
|
* Finish a WebAuthn / passkey AUTHENTICATION ceremony. Forwards the opaque
|
|
930
819
|
* browser `AuthenticationResponseJSON` (`response`) alongside the
|
|
931
820
|
* device-session envelope. Resolves to the SAME {@link LoginResult} contract
|
|
932
|
-
* as `POST /auth/verify`;
|
|
933
|
-
*
|
|
934
|
-
*
|
|
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.
|
|
935
824
|
*/
|
|
936
825
|
async webauthnLoginVerify(response, envelope = {}) {
|
|
937
826
|
try {
|
|
@@ -940,7 +829,7 @@ export function OxyServicesAuthMixin(Base) {
|
|
|
940
829
|
if (!parsed) {
|
|
941
830
|
throw new Error('auth/webauthn/login/verify returned an unexpected response shape');
|
|
942
831
|
}
|
|
943
|
-
if (
|
|
832
|
+
if (parsed.accessToken) {
|
|
944
833
|
this.setTokens(parsed.accessToken);
|
|
945
834
|
}
|
|
946
835
|
return parsed;
|
|
@@ -951,7 +840,7 @@ export function OxyServicesAuthMixin(Base) {
|
|
|
951
840
|
}
|
|
952
841
|
/**
|
|
953
842
|
* Exchange an OAuth authorization code (returned to the RP redirect URI
|
|
954
|
-
* after
|
|
843
|
+
* after sign-in at auth.oxy.so) for a device-first session.
|
|
955
844
|
* Public first-party clients use PKCE (`codeVerifier`); the access token is
|
|
956
845
|
* planted immediately on success.
|
|
957
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
|
}
|
|
@@ -12,23 +12,26 @@
|
|
|
12
12
|
* - the unified account list (via {@link projectSwitchableAccounts}), fetched
|
|
13
13
|
* from `SessionClient` state ∪ `oxyServices.listAccounts()` and hydrated
|
|
14
14
|
* with `oxyServices.getUsersByIds()`;
|
|
15
|
-
* - the dialog `view` state machine (`accounts` | `signin` | `qr` | `add`
|
|
15
|
+
* - the dialog `view` state machine (`accounts` | `signin` | `qr` | `add` |
|
|
16
|
+
* `signup`);
|
|
16
17
|
* - `switchTo` (the uniform switch: `SessionClient.switchAccount` for an
|
|
17
18
|
* account already on the device, `oxyServices.switchToAccount` to mint on
|
|
18
19
|
* first entry into a graph account — reusing the existing SDK primitives, no
|
|
19
20
|
* new switch path);
|
|
20
21
|
* - the "Sign in with Oxy" device flow (same-device shared-keychain via
|
|
21
22
|
* `oxyServices.signInWithSharedIdentity`, else the cross-device QR handoff
|
|
22
|
-
* via `startCommonsSignIn` → poll → `claimSessionByToken`)
|
|
23
|
+
* via `startCommonsSignIn` → poll → `claimSessionByToken`);
|
|
24
|
+
* - `commonsAvailability` — whether Commons is installed on this device
|
|
25
|
+
* (native only, via the injected `canOpenApp` probe), so the QR view can
|
|
26
|
+
* offer a "Get Commons" fallback instead of a same-device dead end.
|
|
23
27
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
28
|
+
* Sign-in is passkey (WebAuthn) or the Commons QR / shared-keychain handoff —
|
|
29
|
+
* password, social login, and 2FA were removed ecosystem-wide. Account
|
|
30
|
+
* creation (`signup` view) is the same two identity backends: a passkey
|
|
31
|
+
* ceremony on web, or a Commons-created identity.
|
|
27
32
|
*/
|
|
28
33
|
import { logger } from '../logger/index.js';
|
|
29
34
|
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
35
|
import { projectSwitchableAccounts, switchableAccountIds, } from './accountProjection.js';
|
|
33
36
|
/**
|
|
34
37
|
* Slow FALLBACK poll cadence for the QR flow. The `/auth-session` socket delivers
|
|
@@ -66,6 +69,7 @@ export class AccountDialogController {
|
|
|
66
69
|
this.error = null;
|
|
67
70
|
this.switchingAccountId = null;
|
|
68
71
|
this.signIn = IDLE_SIGN_IN;
|
|
72
|
+
this.commonsAvailability = 'unknown';
|
|
69
73
|
// --- Sign-in device-flow bookkeeping ---
|
|
70
74
|
/** The secret device-flow token of the active QR flow (never surfaced). */
|
|
71
75
|
this.signInToken = null;
|
|
@@ -94,9 +98,8 @@ export class AccountDialogController {
|
|
|
94
98
|
this.clientId = options.clientId ?? null;
|
|
95
99
|
this.locale = options.locale;
|
|
96
100
|
this.commitSession = options.commitSession;
|
|
101
|
+
this.commitSwitchedSession = options.commitSwitchedSession;
|
|
97
102
|
this.onSignedIn = options.onSignedIn;
|
|
98
|
-
this.idpApex = options.idpApex ?? CENTRAL_IDP_APEX;
|
|
99
|
-
this.authRedirectUri = options.authRedirectUri ?? null;
|
|
100
103
|
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
101
104
|
this.openUrl = options.openUrl;
|
|
102
105
|
this.canOpenApp = options.canOpenApp;
|
|
@@ -152,6 +155,11 @@ export class AccountDialogController {
|
|
|
152
155
|
// bearer is already planted (warm start); when signed out (cold boot before
|
|
153
156
|
// restore) it re-projects from device state and makes NO private call.
|
|
154
157
|
void this.refresh();
|
|
158
|
+
// Eager, cached Commons-availability probe (native only — a no-op when no
|
|
159
|
+
// `canOpenApp` was injected). It's a cheap local OS check, so by the time a
|
|
160
|
+
// user actually opens the sign-in entry it has almost always resolved —
|
|
161
|
+
// `showQr`'s own lazy probe below is only the safety net for the rare race.
|
|
162
|
+
void this.resolveCommonsAvailability();
|
|
155
163
|
}
|
|
156
164
|
/**
|
|
157
165
|
* Stop driving the dialog: unsubscribe from `SessionClient` and tear down the
|
|
@@ -235,6 +243,10 @@ export class AccountDialogController {
|
|
|
235
243
|
add() {
|
|
236
244
|
this.setView('add');
|
|
237
245
|
}
|
|
246
|
+
/** Switch to the "create account" view (passkey / Commons signup entry). */
|
|
247
|
+
startSignup() {
|
|
248
|
+
this.setView('signup');
|
|
249
|
+
}
|
|
238
250
|
// =========================================================================
|
|
239
251
|
// Account list
|
|
240
252
|
// =========================================================================
|
|
@@ -380,7 +392,11 @@ export class AccountDialogController {
|
|
|
380
392
|
expiresAt: result.expiresAt,
|
|
381
393
|
user: result.user,
|
|
382
394
|
accessToken: result.accessToken,
|
|
383
|
-
}, result.user
|
|
395
|
+
}, result.user,
|
|
396
|
+
// A switch is IN-PLACE: commit without the hub-sync redirect (the
|
|
397
|
+
// device is already known/synced). Cross-tab/app propagation rides the
|
|
398
|
+
// server's `session_state` socket broadcast, not a navigation.
|
|
399
|
+
{ fromSwitch: true });
|
|
384
400
|
}
|
|
385
401
|
// Re-project + refetch immediately; the subscription also fires.
|
|
386
402
|
await this.refresh();
|
|
@@ -448,32 +464,63 @@ export class AccountDialogController {
|
|
|
448
464
|
// connect, so it now runs at the slow fallback cadence.
|
|
449
465
|
this.openAuthSessionSocket(handle.sessionToken);
|
|
450
466
|
this.scheduleNextPoll(handle.sessionToken);
|
|
451
|
-
// Same-device convenience: if Commons is installed (native
|
|
452
|
-
//
|
|
453
|
-
//
|
|
467
|
+
// Same-device convenience: if Commons is confirmed installed (native
|
|
468
|
+
// only — stays `'unknown'` on web, where this never opens anything),
|
|
469
|
+
// deep-link straight into its approve screen with the same
|
|
470
|
+
// `oxycommons://approve?...` payload the QR encodes. The QR + polling
|
|
454
471
|
// stay live as the fallback, so a user who dismisses the app-open still
|
|
455
472
|
// completes the sign-in by scanning.
|
|
456
|
-
void this.
|
|
473
|
+
void this.deepLinkIntoCommonsIfAvailable(handle.qrPayload);
|
|
457
474
|
}
|
|
458
475
|
catch (error) {
|
|
459
476
|
this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: errorMessage(error) });
|
|
460
477
|
}
|
|
461
478
|
}
|
|
462
479
|
/**
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
480
|
+
* Resolve whether Commons is installed on this device via the injected
|
|
481
|
+
* `canOpenApp` probe, updating {@link commonsAvailability} as durable,
|
|
482
|
+
* observable snapshot state. Native only — a no-op when `canOpenApp` was
|
|
483
|
+
* not injected (web), where `commonsAvailability` stays `'unknown'` forever
|
|
484
|
+
* and the QR view renders unconditionally (no gating).
|
|
485
|
+
*
|
|
486
|
+
* Replaces the old `maybeOpenCommons` fire-and-forget probe, whose outcome
|
|
487
|
+
* was only ever reflected by whether Commons silently opened — a probe
|
|
488
|
+
* failure or "not installed" answer was swallowed into a debug log with no
|
|
489
|
+
* way for the UI to react. `commonsAvailability` fixes that.
|
|
466
490
|
*/
|
|
467
|
-
async
|
|
468
|
-
if (!this.canOpenApp
|
|
491
|
+
async resolveCommonsAvailability() {
|
|
492
|
+
if (!this.canOpenApp)
|
|
469
493
|
return;
|
|
494
|
+
this.commonsAvailability = 'checking';
|
|
495
|
+
this.emit();
|
|
496
|
+
let available = false;
|
|
470
497
|
try {
|
|
471
|
-
|
|
472
|
-
this.openUrl(qrPayload);
|
|
473
|
-
}
|
|
498
|
+
available = await this.canOpenApp(COMMONS_APP_SCHEME);
|
|
474
499
|
}
|
|
475
500
|
catch (error) {
|
|
476
|
-
logger.debug('[AccountDialogController] Commons
|
|
501
|
+
logger.debug('[AccountDialogController] Commons availability probe failed', { component: 'AccountDialogController' }, error);
|
|
502
|
+
available = false; // fail-closed — treat a probe error as "not installed"
|
|
503
|
+
}
|
|
504
|
+
this.commonsAvailability = available ? 'available' : 'unavailable';
|
|
505
|
+
this.emit();
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* When Commons is confirmed installed, deep-link straight into its approve
|
|
509
|
+
* screen via the injected `openUrl` with the same `oxycommons://approve?...`
|
|
510
|
+
* payload the QR encodes. Best-effort and non-blocking — the QR/polling
|
|
511
|
+
* fallback stays live regardless of the outcome here.
|
|
512
|
+
*/
|
|
513
|
+
async deepLinkIntoCommonsIfAvailable(qrPayload) {
|
|
514
|
+
if (!this.openUrl)
|
|
515
|
+
return;
|
|
516
|
+
if (this.commonsAvailability === 'unknown' || this.commonsAvailability === 'checking') {
|
|
517
|
+
// The eager `start()` probe hasn't resolved yet (or was never run, e.g.
|
|
518
|
+
// `showQr` called without a prior `start()`) — resolve it now rather
|
|
519
|
+
// than skipping the deep link.
|
|
520
|
+
await this.resolveCommonsAvailability();
|
|
521
|
+
}
|
|
522
|
+
if (this.commonsAvailability === 'available') {
|
|
523
|
+
this.openUrl(qrPayload);
|
|
477
524
|
}
|
|
478
525
|
}
|
|
479
526
|
/** Tear down the active sign-in device flow (timers + socket + token) and reset to idle. */
|
|
@@ -485,46 +532,6 @@ export class AccountDialogController {
|
|
|
485
532
|
this.setSignIn(IDLE_SIGN_IN);
|
|
486
533
|
}
|
|
487
534
|
}
|
|
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
535
|
// =========================================================================
|
|
529
536
|
// Internal sign-in helpers
|
|
530
537
|
// =========================================================================
|
|
@@ -636,12 +643,21 @@ export class AccountDialogController {
|
|
|
636
643
|
}
|
|
637
644
|
/**
|
|
638
645
|
* Register a token-planted session into the device set. Prefers the
|
|
639
|
-
* consumer's
|
|
646
|
+
* consumer's commit funnel (durable persist + hydration); falls back to
|
|
640
647
|
* `SessionClient.registerAndActivate` (registration + activation only).
|
|
648
|
+
*
|
|
649
|
+
* A SWITCH (`opts.fromSwitch`) uses the IN-PLACE `commitSwitchedSession` funnel
|
|
650
|
+
* so it never runs the cross-origin hub-sync redirect; a SIGN-IN uses
|
|
651
|
+
* `commitSession` (which may hub-sync on an official web origin). When the
|
|
652
|
+
* switch funnel is not wired it falls back to the sign-in funnel, then to
|
|
653
|
+
* `registerAndActivate`.
|
|
641
654
|
*/
|
|
642
|
-
async commitAuthorizedSession(session, user) {
|
|
643
|
-
|
|
644
|
-
|
|
655
|
+
async commitAuthorizedSession(session, user, opts) {
|
|
656
|
+
const commit = opts?.fromSwitch
|
|
657
|
+
? this.commitSwitchedSession ?? this.commitSession
|
|
658
|
+
: this.commitSession;
|
|
659
|
+
if (commit) {
|
|
660
|
+
await commit(session);
|
|
645
661
|
}
|
|
646
662
|
else {
|
|
647
663
|
await this.sessionClient.registerAndActivate(user.id);
|
|
@@ -753,6 +769,7 @@ export class AccountDialogController {
|
|
|
753
769
|
error: this.error,
|
|
754
770
|
switchingAccountId: this.switchingAccountId,
|
|
755
771
|
signIn: this.signIn,
|
|
772
|
+
commonsAvailability: this.commonsAvailability,
|
|
756
773
|
};
|
|
757
774
|
}
|
|
758
775
|
/** Recompute the snapshot and notify subscribers. */
|
|
@@ -772,11 +789,3 @@ export class AccountDialogController {
|
|
|
772
789
|
export function createAccountDialogController(options) {
|
|
773
790
|
return new AccountDialogController(options);
|
|
774
791
|
}
|
|
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:') {
|