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