@oxyhq/core 1.11.20 → 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.
@@ -29,6 +29,14 @@ interface FedCMTokenResult {
29
29
  token: string;
30
30
  isAutoSelected: boolean;
31
31
  }
32
+ /**
33
+ * Test-only reset of the page-load silent-SSO memo. The memo is module-scoped
34
+ * and never cleared at runtime (a fresh page load resets it naturally), but
35
+ * tests sharing one module instance need to start from a clean slate.
36
+ *
37
+ * @internal
38
+ */
39
+ export declare function __resetSilentSSOMemoForTests(): void;
32
40
  /**
33
41
  * Federated Credential Management (FedCM) Authentication Mixin
34
42
  *
@@ -113,6 +121,22 @@ export declare function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(
113
121
  * ```
114
122
  */
115
123
  silentSignInWithFedCM(): Promise<SessionLoginResponse | null>;
124
+ /**
125
+ * Build the page-load silent-SSO memo key from the current origin and the
126
+ * configured API base URL. Two providers pointed at the same API from the
127
+ * same origin share a single silent attempt per page load.
128
+ *
129
+ * @internal
130
+ */
131
+ silentSSOMemoKey(): string;
132
+ /**
133
+ * Perform the actual silent FedCM sign-in. Always wrapped by
134
+ * {@link silentSignInWithFedCM}'s page-load memo — never call this directly
135
+ * (doing so bypasses the run-once guard).
136
+ *
137
+ * @internal
138
+ */
139
+ _performSilentSignInWithFedCM(): Promise<SessionLoginResponse | null>;
116
140
  /**
117
141
  * Request identity credential from browser using FedCM API
118
142
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "1.11.20",
3
+ "version": "1.11.21",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -353,7 +353,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
353
353
  deviceFingerprint?: string
354
354
  ): Promise<SessionLoginResponse> {
355
355
  try {
356
- return await this.makeRequest<SessionLoginResponse>('POST', '/auth/verify', {
356
+ const res = await this.makeRequest<SessionLoginResponse>('POST', '/auth/verify', {
357
357
  publicKey,
358
358
  challenge,
359
359
  signature,
@@ -361,6 +361,21 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
361
361
  deviceName,
362
362
  deviceFingerprint,
363
363
  }, { cache: false });
364
+
365
+ // Plant the freshly-minted tokens, mirroring `claimSessionByToken`.
366
+ // `/auth/verify` returns the first access token (and refresh token) in
367
+ // its body, so installing it here means callers get an authenticated
368
+ // client without a second round-trip — and, critically, without
369
+ // falling back to the bearer-protected `GET /session/token/:sessionId`
370
+ // (C1 hardening), which 401s for a brand-new identity that has no
371
+ // bearer yet. `accessToken`/`refreshToken` are optional on
372
+ // SessionLoginResponse; only plant when an access token is present and
373
+ // default the refresh token to an empty string.
374
+ if (res?.accessToken) {
375
+ this.setTokens(res.accessToken, res.refreshToken ?? '');
376
+ }
377
+
378
+ return res;
364
379
  } catch (error) {
365
380
  throw this.handleError(error);
366
381
  }
@@ -130,6 +130,46 @@ let fedCMRequestInProgress = false;
130
130
  let fedCMRequestPromise: Promise<FedCMTokenResult | null> | null = null;
131
131
  let currentMediationMode: string | null = null;
132
132
 
133
+ /**
134
+ * Page-load-persistent memo for SILENT FedCM sign-in.
135
+ *
136
+ * Silent SSO (`mediation: 'silent'`) is the one FedCM flow that runs WITHOUT a
137
+ * user gesture — on app startup / provider mount. Multiple consumers
138
+ * (`@oxyhq/auth`'s `WebOxyProvider` / `useWebSSO`, `@oxyhq/services`'
139
+ * `useWebSSO`) can each mount and trigger it, and a remount storm (route churn,
140
+ * React StrictMode double-invoke, error-boundary recovery) previously turned
141
+ * into a `navigator.credentials.get` storm. This memo collapses every silent
142
+ * attempt for a given `origin + baseURL` into AT MOST ONE browser credential
143
+ * request per page load:
144
+ *
145
+ * - the FIRST silent call runs the real flow and stores its in-flight promise;
146
+ * - concurrent silent calls share that same in-flight promise;
147
+ * - once it settles, the memo retains the resolved value (a session OR `null`)
148
+ * and every subsequent silent call returns it WITHOUT re-invoking the
149
+ * browser.
150
+ *
151
+ * Keyed on `origin + baseURL` (not the OxyServices instance) so it survives
152
+ * instance churn across remounts. Intentionally never cleared: only a fresh
153
+ * page load — which starts a fresh module scope — can change the IdP session
154
+ * state that silent mediation observes.
155
+ *
156
+ * This guard is SILENT-ONLY. Interactive flows (`signInWithFedCM`,
157
+ * `mediation: 'optional'|'required'`, `mode: 'active'|'passive'`) must always
158
+ * be able to re-prompt and are never memoized here.
159
+ */
160
+ const silentSSOMemo = new Map<string, Promise<SessionLoginResponse | null>>();
161
+
162
+ /**
163
+ * Test-only reset of the page-load silent-SSO memo. The memo is module-scoped
164
+ * and never cleared at runtime (a fresh page load resets it naturally), but
165
+ * tests sharing one module instance need to start from a clean slate.
166
+ *
167
+ * @internal
168
+ */
169
+ export function __resetSilentSSOMemoForTests(): void {
170
+ silentSSOMemo.clear();
171
+ }
172
+
133
173
  /**
134
174
  * Federated Credential Management (FedCM) Authentication Mixin
135
175
  *
@@ -322,6 +362,49 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
322
362
  return null;
323
363
  }
324
364
 
365
+ // Page-load run-once guard. The first silent attempt for this
366
+ // origin + API runs; concurrent callers share the in-flight promise; once
367
+ // it settles, every later caller gets the memoized result (session OR
368
+ // null) WITHOUT re-invoking `navigator.credentials.get`. This is the single
369
+ // chokepoint for silent SSO across all consumers and remounts.
370
+ const memoKey = this.silentSSOMemoKey();
371
+ const existing = silentSSOMemo.get(memoKey);
372
+ if (existing) {
373
+ debug.log('Silent SSO: Returning memoized page-load result (no re-invocation)');
374
+ return existing;
375
+ }
376
+
377
+ const attempt = this._performSilentSignInWithFedCM();
378
+ silentSSOMemo.set(memoKey, attempt);
379
+ return attempt;
380
+ }
381
+
382
+ /**
383
+ * Build the page-load silent-SSO memo key from the current origin and the
384
+ * configured API base URL. Two providers pointed at the same API from the
385
+ * same origin share a single silent attempt per page load.
386
+ *
387
+ * @internal
388
+ */
389
+ public silentSSOMemoKey(): string {
390
+ const origin = typeof window !== 'undefined' ? window.location.origin : 'no-origin';
391
+ let baseURL = '';
392
+ try {
393
+ baseURL = this.getBaseURL();
394
+ } catch {
395
+ baseURL = '';
396
+ }
397
+ return `${origin}|${baseURL}`;
398
+ }
399
+
400
+ /**
401
+ * Perform the actual silent FedCM sign-in. Always wrapped by
402
+ * {@link silentSignInWithFedCM}'s page-load memo — never call this directly
403
+ * (doing so bypasses the run-once guard).
404
+ *
405
+ * @internal
406
+ */
407
+ public async _performSilentSignInWithFedCM(): Promise<SessionLoginResponse | null> {
325
408
  const clientId = this.getClientId();
326
409
  debug.log('Silent SSO: Starting for', clientId);
327
410
 
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import { OxyServices } from '../../OxyServices';
22
+ import { __resetSilentSSOMemoForTests } from '../OxyServices.fedcm';
22
23
 
23
24
  interface CredentialGetCall {
24
25
  identity: {
@@ -69,6 +70,11 @@ describe('OxyServices FedCM nonce binding', () => {
69
70
  afterEach(() => {
70
71
  clearBrowserGlobals();
71
72
  jest.restoreAllMocks();
73
+ // The silent-SSO memo is module-scoped and survives between `it` blocks.
74
+ // Each test that calls `silentSignInWithFedCM` expects a fresh browser
75
+ // invocation, so reset the memo (a real page load would do the same by
76
+ // starting a fresh module scope).
77
+ __resetSilentSSOMemoForTests();
72
78
  });
73
79
 
74
80
  it('silent SSO mints a server nonce and forwards it to the browser', async () => {
@@ -225,6 +231,7 @@ describe('OxyServices FedCM mode enum (active/passive)', () => {
225
231
  afterEach(() => {
226
232
  clearBrowserGlobals();
227
233
  jest.restoreAllMocks();
234
+ __resetSilentSSOMemoForTests();
228
235
  });
229
236
 
230
237
  it('interactive sign-in requests the modern mode: "active"', async () => {
@@ -321,3 +328,178 @@ describe('OxyServices FedCM mode enum (active/passive)', () => {
321
328
  expect(call.identity.mode).toBeUndefined();
322
329
  });
323
330
  });
331
+
332
+ /**
333
+ * Page-load silent-SSO run-once guard.
334
+ *
335
+ * Silent SSO must invoke `navigator.credentials.get` AT MOST ONCE per page
336
+ * load, even when multiple consumers / remounts / StrictMode call
337
+ * `silentSignInWithFedCM()` repeatedly. The guard lives at the chokepoint in
338
+ * core: the first silent attempt for an `origin + baseURL` runs; concurrent
339
+ * callers share the in-flight promise; later callers get the memoized result
340
+ * (session OR null) without re-invoking the browser.
341
+ *
342
+ * Interactive sign-in (`signInWithFedCM`) is NOT memoized — a user clicking the
343
+ * sign-in button must always be able to re-prompt. That is asserted too.
344
+ */
345
+ describe('OxyServices FedCM silent-SSO page-load guard', () => {
346
+ afterEach(() => {
347
+ clearBrowserGlobals();
348
+ jest.restoreAllMocks();
349
+ __resetSilentSSOMemoForTests();
350
+ });
351
+
352
+ it('invokes the browser at most once across repeated silent calls and returns the memoized result', async () => {
353
+ let getCallCount = 0;
354
+ installBrowserGlobals({
355
+ credentialsGet: async () => {
356
+ getCallCount += 1;
357
+ return { type: 'identity', token: 'idp-token', isAutoSelected: true };
358
+ },
359
+ });
360
+
361
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
362
+ let exchangeCount = 0;
363
+ jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
364
+ if (url === '/fedcm/nonce') {
365
+ return { nonce: 'n', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
366
+ }
367
+ if (url === '/fedcm/exchange') {
368
+ exchangeCount += 1;
369
+ return {
370
+ sessionId: 'sess_guard',
371
+ deviceId: 'dev_guard',
372
+ expiresAt: new Date(Date.now() + 60000).toISOString(),
373
+ accessToken: 'access_guard',
374
+ user: { id: 'user_guard', username: 'tester' },
375
+ } as never;
376
+ }
377
+ throw new Error(`unexpected request to ${url}`);
378
+ });
379
+
380
+ const first = await oxy.silentSignInWithFedCM();
381
+ const second = await oxy.silentSignInWithFedCM();
382
+ const third = await oxy.silentSignInWithFedCM();
383
+
384
+ // The browser credential request fired exactly once.
385
+ expect(getCallCount).toBe(1);
386
+ // The token exchange ran exactly once too (the whole flow is memoized).
387
+ expect(exchangeCount).toBe(1);
388
+ // Every caller received the same memoized session.
389
+ expect(first?.sessionId).toBe('sess_guard');
390
+ expect(second).toBe(first);
391
+ expect(third).toBe(first);
392
+ });
393
+
394
+ it('shares a single in-flight browser call across concurrent silent callers', async () => {
395
+ let getCallCount = 0;
396
+ let resolveGet: ((value: unknown) => void) | null = null;
397
+ installBrowserGlobals({
398
+ credentialsGet: () =>
399
+ new Promise((resolve) => {
400
+ getCallCount += 1;
401
+ resolveGet = resolve;
402
+ }),
403
+ });
404
+
405
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
406
+ jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
407
+ if (url === '/fedcm/nonce') {
408
+ return { nonce: 'n', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
409
+ }
410
+ if (url === '/fedcm/exchange') {
411
+ return {
412
+ sessionId: 'sess_concurrent',
413
+ deviceId: 'dev_concurrent',
414
+ expiresAt: new Date(Date.now() + 60000).toISOString(),
415
+ accessToken: 'access_concurrent',
416
+ user: { id: 'user_concurrent', username: 'tester' },
417
+ } as never;
418
+ }
419
+ throw new Error(`unexpected request to ${url}`);
420
+ });
421
+
422
+ // Fire three silent calls before the first browser request resolves.
423
+ const p1 = oxy.silentSignInWithFedCM();
424
+ const p2 = oxy.silentSignInWithFedCM();
425
+ const p3 = oxy.silentSignInWithFedCM();
426
+
427
+ // Let the in-flight nonce/get chain start, then release the browser call.
428
+ await Promise.resolve();
429
+ await new Promise((r) => setTimeout(r, 0));
430
+ expect(getCallCount).toBe(1);
431
+ expect(resolveGet).not.toBeNull();
432
+ (resolveGet as unknown as (value: unknown) => void)({
433
+ type: 'identity',
434
+ token: 'idp-token',
435
+ isAutoSelected: true,
436
+ });
437
+
438
+ const [r1, r2, r3] = await Promise.all([p1, p2, p3]);
439
+
440
+ // Only one browser invocation despite three concurrent callers.
441
+ expect(getCallCount).toBe(1);
442
+ expect(r1?.sessionId).toBe('sess_concurrent');
443
+ expect(r2).toBe(r1);
444
+ expect(r3).toBe(r1);
445
+ });
446
+
447
+ it('memoizes a null result (user not signed in) without re-invoking the browser', async () => {
448
+ let getCallCount = 0;
449
+ installBrowserGlobals({
450
+ credentialsGet: async () => {
451
+ getCallCount += 1;
452
+ return null; // user not logged in at IdP
453
+ },
454
+ });
455
+
456
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
457
+ jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
458
+ if (url === '/fedcm/nonce') {
459
+ return { nonce: 'n', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
460
+ }
461
+ throw new Error(`unexpected request to ${url}`);
462
+ });
463
+
464
+ const first = await oxy.silentSignInWithFedCM();
465
+ const second = await oxy.silentSignInWithFedCM();
466
+
467
+ expect(first).toBeNull();
468
+ expect(second).toBeNull();
469
+ // The null verdict is memoized — the browser is not asked again.
470
+ expect(getCallCount).toBe(1);
471
+ });
472
+
473
+ it('does NOT memoize interactive sign-in (each click can re-prompt the browser)', async () => {
474
+ let getCallCount = 0;
475
+ installBrowserGlobals({
476
+ credentialsGet: async () => {
477
+ getCallCount += 1;
478
+ return { type: 'identity', token: `idp-token-${getCallCount}`, isAutoSelected: false };
479
+ },
480
+ });
481
+
482
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
483
+ jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
484
+ if (url === '/fedcm/nonce') {
485
+ return { nonce: 'n', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
486
+ }
487
+ if (url === '/fedcm/exchange') {
488
+ return {
489
+ sessionId: 'sess_interactive',
490
+ deviceId: 'dev_interactive',
491
+ expiresAt: new Date(Date.now() + 60000).toISOString(),
492
+ accessToken: 'access_interactive',
493
+ user: { id: 'user_interactive', username: 'tester' },
494
+ } as never;
495
+ }
496
+ throw new Error(`unexpected request to ${url}`);
497
+ });
498
+
499
+ await oxy.signInWithFedCM();
500
+ await oxy.signInWithFedCM();
501
+
502
+ // Interactive flow is never gated by the silent memo.
503
+ expect(getCallCount).toBe(2);
504
+ });
505
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `verifyChallenge` token-planting regression tests.
3
+ *
4
+ * `OxyServices.verifyChallenge()` returns a `SessionLoginResponse` carrying the
5
+ * first `accessToken`/`refreshToken` minted by `POST /auth/verify`. It must
6
+ * PLANT those tokens internally — mirroring its sibling `claimSessionByToken` —
7
+ * so callers (e.g. @oxyhq/services' `useAuthOperations.performSignIn`) end up
8
+ * with an authenticated client WITHOUT falling back to the bearer-protected
9
+ * `GET /session/token/:sessionId`. That fallback 401s for a brand-new identity
10
+ * that has no bearer yet and previously broke the entire new-identity
11
+ * onboarding flow.
12
+ *
13
+ * These tests stub `makeRequest` so the planting logic is exercised end-to-end
14
+ * against a real OxyServices instance, with token state observed via the public
15
+ * `hasValidToken()` / `getAccessToken()` surface.
16
+ */
17
+
18
+ import { OxyServices } from '../../OxyServices';
19
+
20
+ interface VerifyResponse {
21
+ sessionId: string;
22
+ deviceId: string;
23
+ expiresAt: string;
24
+ accessToken?: string;
25
+ refreshToken?: string;
26
+ user: { id: string; username: string };
27
+ }
28
+
29
+ function makeOxy(): OxyServices {
30
+ return new OxyServices({ baseURL: 'https://api.oxy.so' });
31
+ }
32
+
33
+ describe('OxyServices.verifyChallenge token planting', () => {
34
+ afterEach(() => {
35
+ jest.restoreAllMocks();
36
+ });
37
+
38
+ it('plants the access + refresh token from the /auth/verify response body', async () => {
39
+ const oxy = makeOxy();
40
+ expect(oxy.hasValidToken()).toBe(false);
41
+
42
+ jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method, url) => {
43
+ if (url === '/auth/verify') {
44
+ return {
45
+ sessionId: 'sess_1',
46
+ deviceId: 'dev_1',
47
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
48
+ accessToken: 'access_verify',
49
+ refreshToken: 'refresh_verify',
50
+ user: { id: 'user_1', username: 'tester' },
51
+ } as never;
52
+ }
53
+ throw new Error(`unexpected request to ${url}`);
54
+ });
55
+
56
+ const session = await oxy.verifyChallenge('pubkey', 'challenge', 'sig', 123, 'Device', 'fp');
57
+
58
+ // Response still carries the tokens for callers that want them.
59
+ expect(session.accessToken).toBe('access_verify');
60
+ // ...and they are now planted on the client so subsequent requests are
61
+ // authenticated without a second round-trip.
62
+ expect(oxy.hasValidToken()).toBe(true);
63
+ expect(oxy.getAccessToken()).toBe('access_verify');
64
+ });
65
+
66
+ it('defaults the refresh token to an empty string when the response omits it', async () => {
67
+ const oxy = makeOxy();
68
+
69
+ jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method, url) => {
70
+ if (url === '/auth/verify') {
71
+ return {
72
+ sessionId: 'sess_2',
73
+ deviceId: 'dev_2',
74
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
75
+ accessToken: 'access_only',
76
+ user: { id: 'user_2', username: 'tester2' },
77
+ } as never;
78
+ }
79
+ throw new Error(`unexpected request to ${url}`);
80
+ });
81
+
82
+ const session = await oxy.verifyChallenge('pubkey', 'challenge', 'sig', 456);
83
+
84
+ expect(session.accessToken).toBe('access_only');
85
+ expect(oxy.hasValidToken()).toBe(true);
86
+ expect(oxy.getAccessToken()).toBe('access_only');
87
+ });
88
+
89
+ it('does NOT plant (and stays unauthenticated) when the response carries no access token', async () => {
90
+ const oxy = makeOxy();
91
+
92
+ jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method, url) => {
93
+ if (url === '/auth/verify') {
94
+ // Token-less new identity (onboarding) — no access token in the body.
95
+ return {
96
+ sessionId: 'sess_3',
97
+ deviceId: 'dev_3',
98
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
99
+ user: { id: 'user_3', username: 'tester3' },
100
+ } as VerifyResponse as never;
101
+ }
102
+ throw new Error(`unexpected request to ${url}`);
103
+ });
104
+
105
+ const session = await oxy.verifyChallenge('pubkey', 'challenge', 'sig', 789);
106
+
107
+ expect(session.accessToken).toBeUndefined();
108
+ // No token to plant — the client stays unauthenticated. Crucially the
109
+ // method does NOT reach for the bearer-protected session-token endpoint.
110
+ expect(oxy.hasValidToken()).toBe(false);
111
+ });
112
+
113
+ it('matches claimSessionByToken: both plant tokens via the same path', async () => {
114
+ const oxy = makeOxy();
115
+
116
+ jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method, url) => {
117
+ if (url === '/auth/session/claim') {
118
+ return {
119
+ accessToken: 'access_claim',
120
+ refreshToken: 'refresh_claim',
121
+ sessionId: 'sess_claim',
122
+ deviceId: 'dev_claim',
123
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
124
+ user: { id: 'user_claim', username: 'claimed' },
125
+ } as never;
126
+ }
127
+ throw new Error(`unexpected request to ${url}`);
128
+ });
129
+
130
+ await oxy.claimSessionByToken('session-token-abc');
131
+
132
+ expect(oxy.hasValidToken()).toBe(true);
133
+ expect(oxy.getAccessToken()).toBe('access_claim');
134
+ });
135
+ });