@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.
- package/README.md +17 -2
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/mixins/OxyServices.auth.js +14 -1
- package/dist/cjs/mixins/OxyServices.fedcm.js +102 -9
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/mixins/OxyServices.auth.js +14 -1
- package/dist/esm/mixins/OxyServices.fedcm.js +101 -9
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/mixins/OxyServices.fedcm.d.ts +33 -4
- package/package.json +1 -1
- package/src/mixins/OxyServices.auth.ts +16 -1
- package/src/mixins/OxyServices.fedcm.ts +135 -14
- package/src/mixins/__tests__/fedcm.test.ts +182 -0
- package/src/mixins/__tests__/verifyChallenge.test.ts +135 -0
|
@@ -21,6 +21,22 @@ export interface FedCMConfig {
|
|
|
21
21
|
* convenience and normalised internally.
|
|
22
22
|
*/
|
|
23
23
|
export type FedCMRequestMode = 'active' | 'passive' | 'button' | 'widget';
|
|
24
|
+
/**
|
|
25
|
+
* Normalised result of a FedCM credential request: the IdP-issued ID token plus
|
|
26
|
+
* whether the browser auto-selected the account (no explicit user choice).
|
|
27
|
+
*/
|
|
28
|
+
interface FedCMTokenResult {
|
|
29
|
+
token: string;
|
|
30
|
+
isAutoSelected: boolean;
|
|
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;
|
|
24
40
|
/**
|
|
25
41
|
* Federated Credential Management (FedCM) Authentication Mixin
|
|
26
42
|
*
|
|
@@ -105,6 +121,22 @@ export declare function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(
|
|
|
105
121
|
* ```
|
|
106
122
|
*/
|
|
107
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>;
|
|
108
140
|
/**
|
|
109
141
|
* Request identity credential from browser using FedCM API
|
|
110
142
|
*
|
|
@@ -130,10 +162,7 @@ export declare function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(
|
|
|
130
162
|
* the running browser only understands the old enum.
|
|
131
163
|
*/
|
|
132
164
|
mode?: FedCMRequestMode;
|
|
133
|
-
}): Promise<
|
|
134
|
-
token: string;
|
|
135
|
-
isAutoSelected: boolean;
|
|
136
|
-
} | null>;
|
|
165
|
+
}): Promise<FedCMTokenResult | null>;
|
|
137
166
|
/**
|
|
138
167
|
* Exchange FedCM ID token for Oxy session
|
|
139
168
|
*
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -99,14 +99,77 @@ interface FedCMCredentialsContainer {
|
|
|
99
99
|
get(options: FedCMCredentialRequest): Promise<FedCMIdentityCredential | null>;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Normalised result of a FedCM credential request: the IdP-issued ID token plus
|
|
104
|
+
* whether the browser auto-selected the account (no explicit user choice).
|
|
105
|
+
*/
|
|
106
|
+
interface FedCMTokenResult {
|
|
107
|
+
token: string;
|
|
108
|
+
isAutoSelected: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Options accepted by the static `IdentityCredential.disconnect()` method
|
|
112
|
+
// (W3C FedCM "disconnect" / sign-out). Not declared in every lib-dom version.
|
|
113
|
+
interface FedCMDisconnectOptions {
|
|
114
|
+
configURL: string;
|
|
115
|
+
clientId: string;
|
|
116
|
+
accountHint: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Minimal structural shape of the global `IdentityCredential` interface object,
|
|
120
|
+
// modelling only the static `disconnect` method this mixin invokes.
|
|
121
|
+
interface FedCMIdentityCredentialStatic {
|
|
122
|
+
disconnect(options: FedCMDisconnectOptions): Promise<void>;
|
|
123
|
+
}
|
|
124
|
+
|
|
102
125
|
const FEDCM_LOGIN_HINT_KEY = 'oxy_fedcm_login_hint';
|
|
103
126
|
|
|
104
127
|
// Global lock to prevent concurrent FedCM requests
|
|
105
128
|
// FedCM only allows one navigator.credentials.get request at a time
|
|
106
129
|
let fedCMRequestInProgress = false;
|
|
107
|
-
let fedCMRequestPromise: Promise<
|
|
130
|
+
let fedCMRequestPromise: Promise<FedCMTokenResult | null> | null = null;
|
|
108
131
|
let currentMediationMode: string | null = null;
|
|
109
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
|
+
|
|
110
173
|
/**
|
|
111
174
|
* Federated Credential Management (FedCM) Authentication Mixin
|
|
112
175
|
*
|
|
@@ -224,9 +287,11 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
224
287
|
// Exchange FedCM ID token for Oxy session
|
|
225
288
|
const session = await this.exchangeIdTokenForSession(credential.token);
|
|
226
289
|
|
|
227
|
-
// Store access token in HttpService
|
|
228
|
-
|
|
229
|
-
|
|
290
|
+
// Store access token in HttpService. `accessToken`/`refreshToken` are
|
|
291
|
+
// declared optional on SessionLoginResponse; default the refresh token to
|
|
292
|
+
// an empty string when the exchange did not return one.
|
|
293
|
+
if (session?.accessToken) {
|
|
294
|
+
this.httpService.setTokens(session.accessToken, session.refreshToken ?? '');
|
|
230
295
|
}
|
|
231
296
|
|
|
232
297
|
// Store the user ID as loginHint for future FedCM requests
|
|
@@ -234,17 +299,20 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
234
299
|
this.storeLoginHint(session.user.id);
|
|
235
300
|
}
|
|
236
301
|
|
|
237
|
-
debug.log('Interactive sign-in: Success!', { userId:
|
|
302
|
+
debug.log('Interactive sign-in: Success!', { userId: session?.user?.id });
|
|
238
303
|
|
|
239
304
|
return session;
|
|
240
305
|
} catch (error) {
|
|
241
306
|
debug.log('Interactive sign-in failed:', error);
|
|
242
307
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
308
|
+
// FedCM aborts/network failures surface as DOMException/Error instances,
|
|
309
|
+
// both of which carry a `name`. Anything else has no meaningful name.
|
|
310
|
+
const errorName = error instanceof Error ? error.name : '';
|
|
243
311
|
|
|
244
|
-
if (
|
|
312
|
+
if (errorName === 'AbortError') {
|
|
245
313
|
throw new OxyAuthenticationError('Sign-in was cancelled by user');
|
|
246
314
|
}
|
|
247
|
-
if (
|
|
315
|
+
if (errorName === 'NetworkError') {
|
|
248
316
|
throw new OxyAuthenticationError('Network error during sign-in. Please check your connection.');
|
|
249
317
|
}
|
|
250
318
|
if (errorMessage.includes('multiple accounts')) {
|
|
@@ -294,6 +362,49 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
294
362
|
return null;
|
|
295
363
|
}
|
|
296
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> {
|
|
297
408
|
const clientId = this.getClientId();
|
|
298
409
|
debug.log('Silent SSO: Starting for', clientId);
|
|
299
410
|
|
|
@@ -301,7 +412,7 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
301
412
|
// We intentionally do NOT fall back to optional mediation here because
|
|
302
413
|
// this runs on app startup — showing browser UI without user action is bad UX.
|
|
303
414
|
// Optional/interactive mediation should only happen when the user clicks "Sign In".
|
|
304
|
-
let credential:
|
|
415
|
+
let credential: FedCMTokenResult | null = null;
|
|
305
416
|
|
|
306
417
|
const loginHint = this.getStoredLoginHint();
|
|
307
418
|
|
|
@@ -368,9 +479,11 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
368
479
|
return null;
|
|
369
480
|
}
|
|
370
481
|
|
|
371
|
-
// Set the access token
|
|
372
|
-
|
|
373
|
-
|
|
482
|
+
// Set the access token. `accessToken`/`refreshToken` are declared optional
|
|
483
|
+
// on SessionLoginResponse; default the refresh token to an empty string when
|
|
484
|
+
// the exchange did not return one.
|
|
485
|
+
if (session.accessToken) {
|
|
486
|
+
this.httpService.setTokens(session.accessToken, session.refreshToken ?? '');
|
|
374
487
|
debug.log('Silent SSO: Access token set');
|
|
375
488
|
} else {
|
|
376
489
|
debug.warn('Silent SSO: No accessToken in session response');
|
|
@@ -414,7 +527,7 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
414
527
|
* the running browser only understands the old enum.
|
|
415
528
|
*/
|
|
416
529
|
mode?: FedCMRequestMode;
|
|
417
|
-
}): Promise<
|
|
530
|
+
}): Promise<FedCMTokenResult | null> {
|
|
418
531
|
const requestedMediation = options.mediation || 'optional';
|
|
419
532
|
const isInteractive = requestedMediation !== 'silent';
|
|
420
533
|
|
|
@@ -589,9 +702,17 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
589
702
|
}
|
|
590
703
|
|
|
591
704
|
try {
|
|
592
|
-
|
|
705
|
+
// The DOM lib does not declare the global `IdentityCredential` interface
|
|
706
|
+
// object (with its static `disconnect`) in every TypeScript version we
|
|
707
|
+
// build against. Read it off `window` through the minimal structural type
|
|
708
|
+
// (not `any`), guarding that `disconnect` is actually present at runtime.
|
|
709
|
+
const fedCMWindow = window as unknown as {
|
|
710
|
+
IdentityCredential?: Partial<FedCMIdentityCredentialStatic>;
|
|
711
|
+
};
|
|
712
|
+
const identityCredential = fedCMWindow.IdentityCredential;
|
|
713
|
+
if (identityCredential && typeof identityCredential.disconnect === 'function') {
|
|
593
714
|
const clientId = this.getClientId();
|
|
594
|
-
await
|
|
715
|
+
await identityCredential.disconnect({
|
|
595
716
|
configURL: this.resolveFedcmConfigUrl(),
|
|
596
717
|
clientId,
|
|
597
718
|
accountHint: accountHint || '*',
|
|
@@ -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
|
+
});
|