@oxyhq/core 1.11.19 → 1.11.21

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.
@@ -252,7 +252,7 @@ function OxyServicesAuthMixin(Base) {
252
252
  */
253
253
  async verifyChallenge(publicKey, challenge, signature, timestamp, deviceName, deviceFingerprint) {
254
254
  try {
255
- return await this.makeRequest('POST', '/auth/verify', {
255
+ const res = await this.makeRequest('POST', '/auth/verify', {
256
256
  publicKey,
257
257
  challenge,
258
258
  signature,
@@ -260,6 +260,19 @@ function OxyServicesAuthMixin(Base) {
260
260
  deviceName,
261
261
  deviceFingerprint,
262
262
  }, { cache: false });
263
+ // Plant the freshly-minted tokens, mirroring `claimSessionByToken`.
264
+ // `/auth/verify` returns the first access token (and refresh token) in
265
+ // its body, so installing it here means callers get an authenticated
266
+ // client without a second round-trip — and, critically, without
267
+ // falling back to the bearer-protected `GET /session/token/:sessionId`
268
+ // (C1 hardening), which 401s for a brand-new identity that has no
269
+ // bearer yet. `accessToken`/`refreshToken` are optional on
270
+ // SessionLoginResponse; only plant when an access token is present and
271
+ // default the refresh token to an empty string.
272
+ if (res?.accessToken) {
273
+ this.setTokens(res.accessToken, res.refreshToken ?? '');
274
+ }
275
+ return res;
263
276
  }
264
277
  catch (error) {
265
278
  throw this.handleError(error);
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.__resetSilentSSOMemoForTests = __resetSilentSSOMemoForTests;
3
4
  exports.OxyServicesFedCMMixin = OxyServicesFedCMMixin;
4
5
  exports.FedCMMixin = OxyServicesFedCMMixin;
5
6
  const OxyServices_errors_1 = require("../OxyServices.errors");
@@ -43,6 +44,44 @@ const FEDCM_LOGIN_HINT_KEY = 'oxy_fedcm_login_hint';
43
44
  let fedCMRequestInProgress = false;
44
45
  let fedCMRequestPromise = null;
45
46
  let currentMediationMode = null;
47
+ /**
48
+ * Page-load-persistent memo for SILENT FedCM sign-in.
49
+ *
50
+ * Silent SSO (`mediation: 'silent'`) is the one FedCM flow that runs WITHOUT a
51
+ * user gesture — on app startup / provider mount. Multiple consumers
52
+ * (`@oxyhq/auth`'s `WebOxyProvider` / `useWebSSO`, `@oxyhq/services`'
53
+ * `useWebSSO`) can each mount and trigger it, and a remount storm (route churn,
54
+ * React StrictMode double-invoke, error-boundary recovery) previously turned
55
+ * into a `navigator.credentials.get` storm. This memo collapses every silent
56
+ * attempt for a given `origin + baseURL` into AT MOST ONE browser credential
57
+ * request per page load:
58
+ *
59
+ * - the FIRST silent call runs the real flow and stores its in-flight promise;
60
+ * - concurrent silent calls share that same in-flight promise;
61
+ * - once it settles, the memo retains the resolved value (a session OR `null`)
62
+ * and every subsequent silent call returns it WITHOUT re-invoking the
63
+ * browser.
64
+ *
65
+ * Keyed on `origin + baseURL` (not the OxyServices instance) so it survives
66
+ * instance churn across remounts. Intentionally never cleared: only a fresh
67
+ * page load — which starts a fresh module scope — can change the IdP session
68
+ * state that silent mediation observes.
69
+ *
70
+ * This guard is SILENT-ONLY. Interactive flows (`signInWithFedCM`,
71
+ * `mediation: 'optional'|'required'`, `mode: 'active'|'passive'`) must always
72
+ * be able to re-prompt and are never memoized here.
73
+ */
74
+ const silentSSOMemo = new Map();
75
+ /**
76
+ * Test-only reset of the page-load silent-SSO memo. The memo is module-scoped
77
+ * and never cleared at runtime (a fresh page load resets it naturally), but
78
+ * tests sharing one module instance need to start from a clean slate.
79
+ *
80
+ * @internal
81
+ */
82
+ function __resetSilentSSOMemoForTests() {
83
+ silentSSOMemo.clear();
84
+ }
46
85
  /**
47
86
  * Federated Credential Management (FedCM) Authentication Mixin
48
87
  *
@@ -144,9 +183,11 @@ function OxyServicesFedCMMixin(Base) {
144
183
  debug.log('Interactive sign-in: Got credential, exchanging for session');
145
184
  // Exchange FedCM ID token for Oxy session
146
185
  const session = await this.exchangeIdTokenForSession(credential.token);
147
- // Store access token in HttpService (extract from response or get from session)
148
- if (session && session.accessToken) {
149
- this.httpService.setTokens(session.accessToken);
186
+ // Store access token in HttpService. `accessToken`/`refreshToken` are
187
+ // declared optional on SessionLoginResponse; default the refresh token to
188
+ // an empty string when the exchange did not return one.
189
+ if (session?.accessToken) {
190
+ this.httpService.setTokens(session.accessToken, session.refreshToken ?? '');
150
191
  }
151
192
  // Store the user ID as loginHint for future FedCM requests
152
193
  if (session?.user?.id) {
@@ -158,10 +199,13 @@ function OxyServicesFedCMMixin(Base) {
158
199
  catch (error) {
159
200
  debug.log('Interactive sign-in failed:', error);
160
201
  const errorMessage = error instanceof Error ? error.message : String(error);
161
- if (error.name === 'AbortError') {
202
+ // FedCM aborts/network failures surface as DOMException/Error instances,
203
+ // both of which carry a `name`. Anything else has no meaningful name.
204
+ const errorName = error instanceof Error ? error.name : '';
205
+ if (errorName === 'AbortError') {
162
206
  throw new OxyServices_errors_1.OxyAuthenticationError('Sign-in was cancelled by user');
163
207
  }
164
- if (error.name === 'NetworkError') {
208
+ if (errorName === 'NetworkError') {
165
209
  throw new OxyServices_errors_1.OxyAuthenticationError('Network error during sign-in. Please check your connection.');
166
210
  }
167
211
  if (errorMessage.includes('multiple accounts')) {
@@ -209,6 +253,47 @@ function OxyServicesFedCMMixin(Base) {
209
253
  debug.log('Silent SSO: FedCM not supported in this browser');
210
254
  return null;
211
255
  }
256
+ // Page-load run-once guard. The first silent attempt for this
257
+ // origin + API runs; concurrent callers share the in-flight promise; once
258
+ // it settles, every later caller gets the memoized result (session OR
259
+ // null) WITHOUT re-invoking `navigator.credentials.get`. This is the single
260
+ // chokepoint for silent SSO across all consumers and remounts.
261
+ const memoKey = this.silentSSOMemoKey();
262
+ const existing = silentSSOMemo.get(memoKey);
263
+ if (existing) {
264
+ debug.log('Silent SSO: Returning memoized page-load result (no re-invocation)');
265
+ return existing;
266
+ }
267
+ const attempt = this._performSilentSignInWithFedCM();
268
+ silentSSOMemo.set(memoKey, attempt);
269
+ return attempt;
270
+ }
271
+ /**
272
+ * Build the page-load silent-SSO memo key from the current origin and the
273
+ * configured API base URL. Two providers pointed at the same API from the
274
+ * same origin share a single silent attempt per page load.
275
+ *
276
+ * @internal
277
+ */
278
+ silentSSOMemoKey() {
279
+ const origin = typeof window !== 'undefined' ? window.location.origin : 'no-origin';
280
+ let baseURL = '';
281
+ try {
282
+ baseURL = this.getBaseURL();
283
+ }
284
+ catch {
285
+ baseURL = '';
286
+ }
287
+ return `${origin}|${baseURL}`;
288
+ }
289
+ /**
290
+ * Perform the actual silent FedCM sign-in. Always wrapped by
291
+ * {@link silentSignInWithFedCM}'s page-load memo — never call this directly
292
+ * (doing so bypasses the run-once guard).
293
+ *
294
+ * @internal
295
+ */
296
+ async _performSilentSignInWithFedCM() {
212
297
  const clientId = this.getClientId();
213
298
  debug.log('Silent SSO: Starting for', clientId);
214
299
  // Only try silent mediation (no UI) - works if user previously consented.
@@ -273,9 +358,11 @@ function OxyServicesFedCMMixin(Base) {
273
358
  debug.error('Silent SSO: Exchange returned session without user:', session);
274
359
  return null;
275
360
  }
276
- // Set the access token
361
+ // Set the access token. `accessToken`/`refreshToken` are declared optional
362
+ // on SessionLoginResponse; default the refresh token to an empty string when
363
+ // the exchange did not return one.
277
364
  if (session.accessToken) {
278
- this.httpService.setTokens(session.accessToken);
365
+ this.httpService.setTokens(session.accessToken, session.refreshToken ?? '');
279
366
  debug.log('Silent SSO: Access token set');
280
367
  }
281
368
  else {
@@ -461,9 +548,15 @@ function OxyServicesFedCMMixin(Base) {
461
548
  return;
462
549
  }
463
550
  try {
464
- if ('IdentityCredential' in window && 'disconnect' in window.IdentityCredential) {
551
+ // The DOM lib does not declare the global `IdentityCredential` interface
552
+ // object (with its static `disconnect`) in every TypeScript version we
553
+ // build against. Read it off `window` through the minimal structural type
554
+ // (not `any`), guarding that `disconnect` is actually present at runtime.
555
+ const fedCMWindow = window;
556
+ const identityCredential = fedCMWindow.IdentityCredential;
557
+ if (identityCredential && typeof identityCredential.disconnect === 'function') {
465
558
  const clientId = this.getClientId();
466
- await window.IdentityCredential.disconnect({
559
+ await identityCredential.disconnect({
467
560
  configURL: this.resolveFedcmConfigUrl(),
468
561
  clientId,
469
562
  accountHint: accountHint || '*',