@oxyhq/core 1.11.18 → 1.11.20

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.
@@ -10,6 +10,25 @@ export interface FedCMConfig {
10
10
  configURL: string;
11
11
  clientId?: string;
12
12
  }
13
+ /**
14
+ * FedCM request mode values.
15
+ *
16
+ * The W3C FedCM spec renamed the `IdentityCredentialRequestOptions.mode` enum:
17
+ * `'widget'` → `'passive'` and `'button'` → `'active'`. Modern Chrome only
18
+ * accepts `'active'`/`'passive'` and throws a synchronous `TypeError` for the
19
+ * legacy values, while Chrome 125–131 only understands `'button'`/`'widget'`.
20
+ * Callers should use the modern values; the legacy values are accepted for
21
+ * convenience and normalised internally.
22
+ */
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
+ }
13
32
  /**
14
33
  * Federated Credential Management (FedCM) Authentication Mixin
15
34
  *
@@ -111,11 +130,15 @@ export declare function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(
111
130
  context?: string;
112
131
  loginHint?: string;
113
132
  mediation?: "silent" | "optional" | "required";
114
- mode?: "button" | "widget";
115
- }): Promise<{
116
- token: string;
117
- isAutoSelected: boolean;
118
- } | null>;
133
+ /**
134
+ * FedCM request mode. The W3C spec values are `'active'` (user-gesture
135
+ * button flow) and `'passive'` (browser-initiated widget flow). Chrome
136
+ * 125–131 used the legacy names `'button'`/`'widget'`; those are accepted
137
+ * here and mapped to the modern values, with an automatic legacy retry if
138
+ * the running browser only understands the old enum.
139
+ */
140
+ mode?: FedCMRequestMode;
141
+ }): Promise<FedCMTokenResult | null>;
119
142
  /**
120
143
  * Exchange FedCM ID token for Oxy session
121
144
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "1.11.18",
3
+ "version": "1.11.20",
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",
@@ -17,12 +17,117 @@ export interface FedCMConfig {
17
17
  clientId?: string;
18
18
  }
19
19
 
20
+ /**
21
+ * FedCM request mode values.
22
+ *
23
+ * The W3C FedCM spec renamed the `IdentityCredentialRequestOptions.mode` enum:
24
+ * `'widget'` → `'passive'` and `'button'` → `'active'`. Modern Chrome only
25
+ * accepts `'active'`/`'passive'` and throws a synchronous `TypeError` for the
26
+ * legacy values, while Chrome 125–131 only understands `'button'`/`'widget'`.
27
+ * Callers should use the modern values; the legacy values are accepted for
28
+ * convenience and normalised internally.
29
+ */
30
+ export type FedCMRequestMode = 'active' | 'passive' | 'button' | 'widget';
31
+
32
+ // Modern (W3C spec) → legacy (Chrome 125–131) mode value mapping. Used to
33
+ // retry a credential request when an older browser rejects the modern enum.
34
+ const MODERN_TO_LEGACY_MODE: Record<'active' | 'passive', 'button' | 'widget'> = {
35
+ active: 'button',
36
+ passive: 'widget',
37
+ };
38
+
39
+ // Legacy → modern mapping so callers may pass either spelling.
40
+ const LEGACY_TO_MODERN_MODE: Record<'button' | 'widget', 'active' | 'passive'> = {
41
+ button: 'active',
42
+ widget: 'passive',
43
+ };
44
+
45
+ /**
46
+ * Normalise any accepted mode value to the modern W3C spelling
47
+ * (`'active'`/`'passive'`), which is what is sent to the browser first.
48
+ */
49
+ function toModernMode(mode: FedCMRequestMode): 'active' | 'passive' {
50
+ return mode === 'button' || mode === 'widget' ? LEGACY_TO_MODERN_MODE[mode] : mode;
51
+ }
52
+
53
+ /**
54
+ * Detect the synchronous `TypeError` a pre-spec browser throws when it does not
55
+ * recognise a modern `mode` enum value (e.g. Chrome 125–131 rejecting
56
+ * `'active'`/`'passive'`). Such a browser only understands the legacy
57
+ * `'button'`/`'widget'` values, so the caller can retry with those.
58
+ */
59
+ function isUnknownModeEnumError(error: unknown): boolean {
60
+ if (!(error instanceof TypeError)) return false;
61
+ const message = error.message.toLowerCase();
62
+ return (
63
+ message.includes('identitycredentialrequestoptionsmode') ||
64
+ ((message.includes('active') || message.includes('passive')) &&
65
+ (message.includes('enum') || message.includes('not a valid')))
66
+ );
67
+ }
68
+
69
+ // Minimal structural types for the FedCM `navigator.credentials.get` surface.
70
+ // The DOM lib does not ship these in every TypeScript version we build against,
71
+ // so we model only the fields this mixin reads/writes. This lets the FedCM code
72
+ // stay free of `any` without depending on lib-dom FedCM typings.
73
+ interface FedCMProviderRequest {
74
+ configURL: string;
75
+ clientId: string;
76
+ nonce: string;
77
+ params?: { nonce: string };
78
+ loginHint?: string;
79
+ }
80
+
81
+ interface FedCMIdentityRequest {
82
+ providers: FedCMProviderRequest[];
83
+ mode?: FedCMRequestMode;
84
+ }
85
+
86
+ interface FedCMCredentialRequest {
87
+ identity: FedCMIdentityRequest;
88
+ mediation: 'silent' | 'optional' | 'required';
89
+ signal: AbortSignal;
90
+ }
91
+
92
+ interface FedCMIdentityCredential {
93
+ type?: string;
94
+ token?: string;
95
+ isAutoSelected?: boolean;
96
+ }
97
+
98
+ interface FedCMCredentialsContainer {
99
+ get(options: FedCMCredentialRequest): Promise<FedCMIdentityCredential | null>;
100
+ }
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
+
20
125
  const FEDCM_LOGIN_HINT_KEY = 'oxy_fedcm_login_hint';
21
126
 
22
127
  // Global lock to prevent concurrent FedCM requests
23
128
  // FedCM only allows one navigator.credentials.get request at a time
24
129
  let fedCMRequestInProgress = false;
25
- let fedCMRequestPromise: Promise<any> | null = null;
130
+ let fedCMRequestPromise: Promise<FedCMTokenResult | null> | null = null;
26
131
  let currentMediationMode: string | null = null;
27
132
 
28
133
  /**
@@ -120,15 +225,17 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
120
225
 
121
226
  debug.log('Interactive sign-in: Requesting credential for', clientId, loginHint ? `(hint: ${loginHint})` : '');
122
227
 
123
- // Request credential from browser's native identity flow
124
- // mode: 'button' signals this is a user-gesture-initiated flow (Chrome 125+)
228
+ // Request credential from browser's native identity flow.
229
+ // mode: 'active' signals this is a user-gesture-initiated (button) flow.
230
+ // 'active' is the current W3C spec value; requestIdentityCredential
231
+ // transparently retries with the legacy 'button' value for Chrome 125–131.
125
232
  const credential = await this.requestIdentityCredential({
126
233
  configURL: this.resolveFedcmConfigUrl(),
127
234
  clientId,
128
235
  nonce,
129
236
  context: options.context,
130
237
  loginHint,
131
- mode: 'button',
238
+ mode: 'active',
132
239
  });
133
240
 
134
241
  if (!credential || !credential.token) {
@@ -140,9 +247,11 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
140
247
  // Exchange FedCM ID token for Oxy session
141
248
  const session = await this.exchangeIdTokenForSession(credential.token);
142
249
 
143
- // Store access token in HttpService (extract from response or get from session)
144
- if (session && (session as any).accessToken) {
145
- this.httpService.setTokens((session as any).accessToken);
250
+ // Store access token in HttpService. `accessToken`/`refreshToken` are
251
+ // declared optional on SessionLoginResponse; default the refresh token to
252
+ // an empty string when the exchange did not return one.
253
+ if (session?.accessToken) {
254
+ this.httpService.setTokens(session.accessToken, session.refreshToken ?? '');
146
255
  }
147
256
 
148
257
  // Store the user ID as loginHint for future FedCM requests
@@ -150,17 +259,20 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
150
259
  this.storeLoginHint(session.user.id);
151
260
  }
152
261
 
153
- debug.log('Interactive sign-in: Success!', { userId: (session as any)?.user?.id });
262
+ debug.log('Interactive sign-in: Success!', { userId: session?.user?.id });
154
263
 
155
264
  return session;
156
265
  } catch (error) {
157
266
  debug.log('Interactive sign-in failed:', error);
158
267
  const errorMessage = error instanceof Error ? error.message : String(error);
268
+ // FedCM aborts/network failures surface as DOMException/Error instances,
269
+ // both of which carry a `name`. Anything else has no meaningful name.
270
+ const errorName = error instanceof Error ? error.name : '';
159
271
 
160
- if ((error as any).name === 'AbortError') {
272
+ if (errorName === 'AbortError') {
161
273
  throw new OxyAuthenticationError('Sign-in was cancelled by user');
162
274
  }
163
- if ((error as any).name === 'NetworkError') {
275
+ if (errorName === 'NetworkError') {
164
276
  throw new OxyAuthenticationError('Network error during sign-in. Please check your connection.');
165
277
  }
166
278
  if (errorMessage.includes('multiple accounts')) {
@@ -217,7 +329,7 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
217
329
  // We intentionally do NOT fall back to optional mediation here because
218
330
  // this runs on app startup — showing browser UI without user action is bad UX.
219
331
  // Optional/interactive mediation should only happen when the user clicks "Sign In".
220
- let credential: { token: string; isAutoSelected: boolean } | null = null;
332
+ let credential: FedCMTokenResult | null = null;
221
333
 
222
334
  const loginHint = this.getStoredLoginHint();
223
335
 
@@ -284,9 +396,11 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
284
396
  return null;
285
397
  }
286
398
 
287
- // Set the access token
288
- if ((session as any).accessToken) {
289
- this.httpService.setTokens((session as any).accessToken);
399
+ // Set the access token. `accessToken`/`refreshToken` are declared optional
400
+ // on SessionLoginResponse; default the refresh token to an empty string when
401
+ // the exchange did not return one.
402
+ if (session.accessToken) {
403
+ this.httpService.setTokens(session.accessToken, session.refreshToken ?? '');
290
404
  debug.log('Silent SSO: Access token set');
291
405
  } else {
292
406
  debug.warn('Silent SSO: No accessToken in session response');
@@ -322,8 +436,15 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
322
436
  context?: string;
323
437
  loginHint?: string;
324
438
  mediation?: 'silent' | 'optional' | 'required';
325
- mode?: 'button' | 'widget';
326
- }): Promise<{ token: string; isAutoSelected: boolean } | null> {
439
+ /**
440
+ * FedCM request mode. The W3C spec values are `'active'` (user-gesture
441
+ * button flow) and `'passive'` (browser-initiated widget flow). Chrome
442
+ * 125–131 used the legacy names `'button'`/`'widget'`; those are accepted
443
+ * here and mapped to the modern values, with an automatic legacy retry if
444
+ * the running browser only understands the old enum.
445
+ */
446
+ mode?: FedCMRequestMode;
447
+ }): Promise<FedCMTokenResult | null> {
327
448
  const requestedMediation = options.mediation || 'optional';
328
449
  const isInteractive = requestedMediation !== 'silent';
329
450
 
@@ -367,31 +488,58 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
367
488
  controller.abort();
368
489
  }, timeoutMs);
369
490
 
491
+ // Normalise the caller's mode to the modern W3C value first. A modern
492
+ // browser accepts it; an older one (Chrome 125–131) rejects it with a
493
+ // synchronous TypeError, in which case we retry with the legacy value.
494
+ const modernMode = options.mode ? toModernMode(options.mode) : undefined;
495
+
496
+ // Build the identity request for a specific mode value. The `mode` field
497
+ // lives on the `identity` object (sibling of `providers`), separate from
498
+ // the top-level `mediation` field.
499
+ const buildCredentialOptions = (modeValue: FedCMRequestMode | undefined): FedCMCredentialRequest => ({
500
+ identity: {
501
+ providers: [
502
+ {
503
+ configURL: options.configURL,
504
+ clientId: options.clientId,
505
+ // Older browsers read `nonce` at the top level; Chrome 145+
506
+ // expects it inside `params`. Send both for full coverage.
507
+ nonce: options.nonce,
508
+ params: {
509
+ nonce: options.nonce,
510
+ },
511
+ ...(options.loginHint && { loginHint: options.loginHint }),
512
+ },
513
+ ],
514
+ ...(modeValue && { mode: modeValue }),
515
+ },
516
+ mediation: requestedMediation,
517
+ signal: controller.signal,
518
+ });
519
+
520
+ // The DOM lib's `CredentialsContainer` does not declare the FedCM `identity`
521
+ // request in every TypeScript version we build against. Re-type through the
522
+ // minimal structural interface above (not `any`) to keep this typed.
523
+ const credentials = navigator.credentials as unknown as FedCMCredentialsContainer;
524
+
370
525
  fedCMRequestPromise = (async () => {
371
526
  try {
372
- debug.log('Calling navigator.credentials.get with mediation:', requestedMediation);
373
- // Type assertion needed as FedCM types may not be in all TypeScript versions
374
- const credentialOptions: any = {
375
- identity: {
376
- providers: [
377
- {
378
- configURL: options.configURL,
379
- clientId: options.clientId,
380
- // Older browsers read `nonce` at the top level; Chrome 145+
381
- // expects it inside `params`. Send both for full coverage.
382
- nonce: options.nonce,
383
- params: {
384
- nonce: options.nonce,
385
- },
386
- ...(options.loginHint && { loginHint: options.loginHint }),
387
- },
388
- ],
389
- ...(options.mode && { mode: options.mode }),
390
- },
391
- mediation: requestedMediation,
392
- signal: controller.signal,
393
- };
394
- const credential = (await (navigator.credentials as any).get(credentialOptions)) as any;
527
+ debug.log('Calling navigator.credentials.get with mediation:', requestedMediation, modernMode ? `mode: ${modernMode}` : '');
528
+ let credential: FedCMIdentityCredential | null;
529
+ try {
530
+ credential = await credentials.get(buildCredentialOptions(modernMode));
531
+ } catch (modeError) {
532
+ // Chrome 125–131 only knows the legacy 'button'/'widget' enum and
533
+ // throws a synchronous TypeError for the modern 'active'/'passive'
534
+ // values. Retry once with the legacy value so older browsers work.
535
+ if (modernMode && isUnknownModeEnumError(modeError)) {
536
+ const legacyMode = MODERN_TO_LEGACY_MODE[modernMode];
537
+ debug.log(`Browser rejected modern mode '${modernMode}'; retrying with legacy mode '${legacyMode}'`);
538
+ credential = await credentials.get(buildCredentialOptions(legacyMode));
539
+ } else {
540
+ throw modeError;
541
+ }
542
+ }
395
543
 
396
544
  debug.log('navigator.credentials.get returned:', {
397
545
  hasCredential: !!credential,
@@ -399,7 +547,7 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
399
547
  hasToken: !!credential?.token,
400
548
  });
401
549
 
402
- if (!credential || credential.type !== 'identity') {
550
+ if (!credential || credential.type !== 'identity' || !credential.token) {
403
551
  debug.log('No valid identity credential returned');
404
552
  return null;
405
553
  }
@@ -471,9 +619,17 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
471
619
  }
472
620
 
473
621
  try {
474
- if ('IdentityCredential' in window && 'disconnect' in (window as any).IdentityCredential) {
622
+ // The DOM lib does not declare the global `IdentityCredential` interface
623
+ // object (with its static `disconnect`) in every TypeScript version we
624
+ // build against. Read it off `window` through the minimal structural type
625
+ // (not `any`), guarding that `disconnect` is actually present at runtime.
626
+ const fedCMWindow = window as unknown as {
627
+ IdentityCredential?: Partial<FedCMIdentityCredentialStatic>;
628
+ };
629
+ const identityCredential = fedCMWindow.IdentityCredential;
630
+ if (identityCredential && typeof identityCredential.disconnect === 'function') {
475
631
  const clientId = this.getClientId();
476
- await (window as any).IdentityCredential.disconnect({
632
+ await identityCredential.disconnect({
477
633
  configURL: this.resolveFedcmConfigUrl(),
478
634
  clientId,
479
635
  accountHint: accountHint || '*',
@@ -185,3 +185,139 @@ describe('OxyServices FedCM nonce binding', () => {
185
185
  await expect(oxy.silentSignInWithFedCM()).resolves.toBeNull();
186
186
  });
187
187
  });
188
+
189
+ /**
190
+ * FedCM `mode` enum regression tests.
191
+ *
192
+ * The W3C FedCM spec renamed `IdentityCredentialRequestOptions.mode`:
193
+ * `'button'` → `'active'` and `'widget'` → `'passive'`. Modern Chrome rejects
194
+ * the legacy values with a synchronous `TypeError`; Chrome 125–131 only knows
195
+ * the legacy values. These tests lock in that:
196
+ *
197
+ * 1. the interactive button flow requests the MODERN `mode: 'active'`;
198
+ * 2. a `TypeError` on the modern value triggers a single retry with the
199
+ * LEGACY `'button'` value (so older Chrome still works);
200
+ * 3. the silent SSO path sends NO `mode` at all (mode and mediation are
201
+ * independent fields).
202
+ */
203
+ function mintAndExchange(oxy: OxyServices, exchanged: string[]): void {
204
+ jest
205
+ .spyOn(oxy, 'makeRequest')
206
+ .mockImplementation(async (_method: string, url: string, data?: unknown) => {
207
+ if (url === '/fedcm/nonce') {
208
+ return { nonce: 'server-nonce', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
209
+ }
210
+ if (url === '/fedcm/exchange') {
211
+ exchanged.push((data as { id_token: string }).id_token);
212
+ return {
213
+ sessionId: 'sess_mode',
214
+ deviceId: 'dev_mode',
215
+ expiresAt: new Date(Date.now() + 60000).toISOString(),
216
+ accessToken: 'access_mode',
217
+ user: { id: 'user_mode', username: 'tester' },
218
+ } as never;
219
+ }
220
+ throw new Error(`unexpected request to ${url}`);
221
+ });
222
+ }
223
+
224
+ describe('OxyServices FedCM mode enum (active/passive)', () => {
225
+ afterEach(() => {
226
+ clearBrowserGlobals();
227
+ jest.restoreAllMocks();
228
+ });
229
+
230
+ it('interactive sign-in requests the modern mode: "active"', async () => {
231
+ const modesSeen: Array<string | undefined> = [];
232
+ installBrowserGlobals({
233
+ credentialsGet: async (opts) => {
234
+ modesSeen.push(opts.identity.mode);
235
+ return { type: 'identity', token: 'idp-id-token', isAutoSelected: false };
236
+ },
237
+ });
238
+
239
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
240
+ const exchanged: string[] = [];
241
+ mintAndExchange(oxy, exchanged);
242
+
243
+ const session = await oxy.signInWithFedCM();
244
+
245
+ expect(session.sessionId).toBe('sess_mode');
246
+ // The modern W3C value — never the legacy 'button' — is sent first.
247
+ expect(modesSeen).toEqual(['active']);
248
+ expect(exchanged).toEqual(['idp-id-token']);
249
+ });
250
+
251
+ it('retries with legacy mode "button" when the browser rejects "active" with a TypeError', async () => {
252
+ const modesSeen: Array<string | undefined> = [];
253
+ installBrowserGlobals({
254
+ credentialsGet: async (opts) => {
255
+ modesSeen.push(opts.identity.mode);
256
+ // First call (modern 'active'): emulate Chrome 125–131 rejecting the
257
+ // unknown enum value synchronously with a TypeError.
258
+ if (opts.identity.mode === 'active') {
259
+ throw new TypeError(
260
+ "The provided value 'active' is not a valid enum value of type IdentityCredentialRequestOptionsMode."
261
+ );
262
+ }
263
+ // Second call (legacy 'button'): the old browser accepts it.
264
+ return { type: 'identity', token: 'legacy-id-token', isAutoSelected: false };
265
+ },
266
+ });
267
+
268
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
269
+ const exchanged: string[] = [];
270
+ mintAndExchange(oxy, exchanged);
271
+
272
+ const session = await oxy.signInWithFedCM();
273
+
274
+ expect(session.sessionId).toBe('sess_mode');
275
+ // Tried modern 'active' first, then fell back to legacy 'button'.
276
+ expect(modesSeen).toEqual(['active', 'button']);
277
+ // The token from the successful legacy retry was exchanged.
278
+ expect(exchanged).toEqual(['legacy-id-token']);
279
+ });
280
+
281
+ it('does not retry (and surfaces the error) when a non-mode TypeError is thrown', async () => {
282
+ let callCount = 0;
283
+ installBrowserGlobals({
284
+ credentialsGet: async () => {
285
+ callCount += 1;
286
+ throw new TypeError('Failed to fetch the FedCM config file.');
287
+ },
288
+ });
289
+
290
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
291
+ const exchanged: string[] = [];
292
+ mintAndExchange(oxy, exchanged);
293
+
294
+ await expect(oxy.signInWithFedCM()).rejects.toThrow('Failed to fetch the FedCM config file.');
295
+ // Only one attempt — an unrelated TypeError must not trigger the legacy retry.
296
+ expect(callCount).toBe(1);
297
+ });
298
+
299
+ it('silent SSO sends no mode (mode and mediation are independent fields)', async () => {
300
+ let credentialCall: CredentialGetCall | null = null;
301
+ installBrowserGlobals({
302
+ credentialsGet: async (opts) => {
303
+ credentialCall = opts;
304
+ return null;
305
+ },
306
+ });
307
+
308
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
309
+ jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
310
+ if (url === '/fedcm/nonce') {
311
+ return { nonce: 'n', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
312
+ }
313
+ throw new Error(`unexpected request to ${url}`);
314
+ });
315
+
316
+ await oxy.silentSignInWithFedCM();
317
+
318
+ expect(credentialCall).not.toBeNull();
319
+ const call = credentialCall as unknown as CredentialGetCall;
320
+ expect(call.mediation).toBe('silent');
321
+ expect(call.identity.mode).toBeUndefined();
322
+ });
323
+ });