@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.
Files changed (30) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/index.js +7 -5
  3. package/dist/cjs/mixins/OxyServices.auth.js +6 -117
  4. package/dist/cjs/mixins/OxyServices.identity.js +16 -12
  5. package/dist/cjs/session/accountDialogController.js +84 -75
  6. package/dist/cjs/utils/officialOrigins.js +6 -0
  7. package/dist/esm/.tsbuildinfo +1 -1
  8. package/dist/esm/index.js +6 -5
  9. package/dist/esm/mixins/OxyServices.auth.js +6 -117
  10. package/dist/esm/mixins/OxyServices.identity.js +16 -12
  11. package/dist/esm/session/accountDialogController.js +84 -75
  12. package/dist/esm/utils/officialOrigins.js +6 -1
  13. package/dist/types/.tsbuildinfo +1 -1
  14. package/dist/types/index.d.ts +3 -3
  15. package/dist/types/mixins/OxyServices.auth.d.ts +4 -48
  16. package/dist/types/mixins/OxyServices.identity.d.ts +13 -9
  17. package/dist/types/session/accountDialogController.d.ts +84 -49
  18. package/dist/types/utils/officialOrigins.d.ts +6 -0
  19. package/package.json +2 -2
  20. package/src/index.ts +7 -4
  21. package/src/mixins/OxyServices.auth.ts +6 -144
  22. package/src/mixins/OxyServices.identity.ts +19 -15
  23. package/src/mixins/__tests__/OxyServices.identity.test.ts +26 -14
  24. package/src/mixins/__tests__/webauthnAuth.test.ts +1 -1
  25. package/src/session/__tests__/accountDialogController.test.ts +75 -64
  26. package/src/session/__tests__/accountProjection.test.ts +30 -0
  27. package/src/session/accountDialogController.ts +131 -104
  28. package/src/utils/__tests__/officialOrigins.test.ts +14 -0
  29. package/src/utils/officialOrigins.ts +6 -1
  30. 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.signIn(publicKey);
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
- // — no password/2FA logic (that lives at the IdP; `openPasswordAtOxyAuth` only
165
- // hands off). Reuses `SessionClient.switchAccount` / `oxyServices.switchToAccount`
166
- // for the uniform switch and the existing device-flow methods for sign-in.
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 (!('twoFactorRequired' in parsed) && parsed.accessToken) {
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`; 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.
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 (!('twoFactorRequired' in parsed) && parsed.accessToken) {
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 password sign-in at auth.oxy.so) for a device-first session.
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
- * Link password authentication to the current account. Adds a `password`
104
- * auth method (does not remove existing methods).
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 email - The email to associate with password auth.
107
- * @param password - The new password (server enforces strength rules).
107
+ * @param type - The auth-method type to remove.
108
108
  */
109
- async linkPassword(email, password) {
109
+ async unlinkAuthMethod(type) {
110
110
  try {
111
- const result = await this.makeRequest('POST', '/auth/link', { type: 'password', email, password }, { cache: false });
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
- * Unlink an authentication method from the current account. The server
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
- * @param type - The auth-method type to remove.
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 unlinkAuthMethod(type) {
130
+ async removePasskey(credentialId) {
127
131
  try {
128
- const result = await this.makeRequest('DELETE', `/auth/link/${encodeURIComponent(type)}`, undefined, { cache: false });
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
- * It deliberately owns NO password/2FA logic those live at the IdP
25
- * (auth.oxy.so). {@link AccountDialogController.openPasswordAtOxyAuth} only
26
- * builds the hand-off URL; device-first convergence syncs the session back.
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 only — `canOpenApp`
452
- // is undefined/false on web), deep-link straight into its approve screen with
453
- // the same `oxycommons://approve?...` payload the QR encodes. The QR + polling
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.maybeOpenCommons(handle.qrPayload);
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
- * When a `canOpenApp` probe is injected and reports Commons installed, open the
464
- * approve deep link via the injected `openUrl`. Best-effort and non-blocking: a
465
- * probe/open failure is logged and swallowed the QR/polling fallback remains.
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 onlya 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 maybeOpenCommons(qrPayload) {
468
- if (!this.canOpenApp || !this.openUrl)
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
- if (await this.canOpenApp(COMMONS_APP_SCHEME)) {
472
- this.openUrl(qrPayload);
473
- }
498
+ available = await this.canOpenApp(COMMONS_APP_SCHEME);
474
499
  }
475
500
  catch (error) {
476
- logger.debug('[AccountDialogController] Commons deep-link probe failed (QR fallback active)', { component: 'AccountDialogController' }, error);
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 `commitSession` (durable persist + hydration); falls back to
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
- if (this.commitSession) {
644
- await this.commitSession(session);
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
- function isLoopbackOrigin(origin) {
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:') {