@oxyhq/core 1.11.18 → 1.11.19

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,17 @@ 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';
13
24
  /**
14
25
  * Federated Credential Management (FedCM) Authentication Mixin
15
26
  *
@@ -111,7 +122,14 @@ export declare function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(
111
122
  context?: string;
112
123
  loginHint?: string;
113
124
  mediation?: "silent" | "optional" | "required";
114
- mode?: "button" | "widget";
125
+ /**
126
+ * FedCM request mode. The W3C spec values are `'active'` (user-gesture
127
+ * button flow) and `'passive'` (browser-initiated widget flow). Chrome
128
+ * 125–131 used the legacy names `'button'`/`'widget'`; those are accepted
129
+ * here and mapped to the modern values, with an automatic legacy retry if
130
+ * the running browser only understands the old enum.
131
+ */
132
+ mode?: FedCMRequestMode;
115
133
  }): Promise<{
116
134
  token: string;
117
135
  isAutoSelected: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "1.11.18",
3
+ "version": "1.11.19",
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,6 +17,88 @@ 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
+
20
102
  const FEDCM_LOGIN_HINT_KEY = 'oxy_fedcm_login_hint';
21
103
 
22
104
  // Global lock to prevent concurrent FedCM requests
@@ -120,15 +202,17 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
120
202
 
121
203
  debug.log('Interactive sign-in: Requesting credential for', clientId, loginHint ? `(hint: ${loginHint})` : '');
122
204
 
123
- // Request credential from browser's native identity flow
124
- // mode: 'button' signals this is a user-gesture-initiated flow (Chrome 125+)
205
+ // Request credential from browser's native identity flow.
206
+ // mode: 'active' signals this is a user-gesture-initiated (button) flow.
207
+ // 'active' is the current W3C spec value; requestIdentityCredential
208
+ // transparently retries with the legacy 'button' value for Chrome 125–131.
125
209
  const credential = await this.requestIdentityCredential({
126
210
  configURL: this.resolveFedcmConfigUrl(),
127
211
  clientId,
128
212
  nonce,
129
213
  context: options.context,
130
214
  loginHint,
131
- mode: 'button',
215
+ mode: 'active',
132
216
  });
133
217
 
134
218
  if (!credential || !credential.token) {
@@ -322,7 +406,14 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
322
406
  context?: string;
323
407
  loginHint?: string;
324
408
  mediation?: 'silent' | 'optional' | 'required';
325
- mode?: 'button' | 'widget';
409
+ /**
410
+ * FedCM request mode. The W3C spec values are `'active'` (user-gesture
411
+ * button flow) and `'passive'` (browser-initiated widget flow). Chrome
412
+ * 125–131 used the legacy names `'button'`/`'widget'`; those are accepted
413
+ * here and mapped to the modern values, with an automatic legacy retry if
414
+ * the running browser only understands the old enum.
415
+ */
416
+ mode?: FedCMRequestMode;
326
417
  }): Promise<{ token: string; isAutoSelected: boolean } | null> {
327
418
  const requestedMediation = options.mediation || 'optional';
328
419
  const isInteractive = requestedMediation !== 'silent';
@@ -367,31 +458,58 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
367
458
  controller.abort();
368
459
  }, timeoutMs);
369
460
 
461
+ // Normalise the caller's mode to the modern W3C value first. A modern
462
+ // browser accepts it; an older one (Chrome 125–131) rejects it with a
463
+ // synchronous TypeError, in which case we retry with the legacy value.
464
+ const modernMode = options.mode ? toModernMode(options.mode) : undefined;
465
+
466
+ // Build the identity request for a specific mode value. The `mode` field
467
+ // lives on the `identity` object (sibling of `providers`), separate from
468
+ // the top-level `mediation` field.
469
+ const buildCredentialOptions = (modeValue: FedCMRequestMode | undefined): FedCMCredentialRequest => ({
470
+ identity: {
471
+ providers: [
472
+ {
473
+ configURL: options.configURL,
474
+ clientId: options.clientId,
475
+ // Older browsers read `nonce` at the top level; Chrome 145+
476
+ // expects it inside `params`. Send both for full coverage.
477
+ nonce: options.nonce,
478
+ params: {
479
+ nonce: options.nonce,
480
+ },
481
+ ...(options.loginHint && { loginHint: options.loginHint }),
482
+ },
483
+ ],
484
+ ...(modeValue && { mode: modeValue }),
485
+ },
486
+ mediation: requestedMediation,
487
+ signal: controller.signal,
488
+ });
489
+
490
+ // The DOM lib's `CredentialsContainer` does not declare the FedCM `identity`
491
+ // request in every TypeScript version we build against. Re-type through the
492
+ // minimal structural interface above (not `any`) to keep this typed.
493
+ const credentials = navigator.credentials as unknown as FedCMCredentialsContainer;
494
+
370
495
  fedCMRequestPromise = (async () => {
371
496
  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;
497
+ debug.log('Calling navigator.credentials.get with mediation:', requestedMediation, modernMode ? `mode: ${modernMode}` : '');
498
+ let credential: FedCMIdentityCredential | null;
499
+ try {
500
+ credential = await credentials.get(buildCredentialOptions(modernMode));
501
+ } catch (modeError) {
502
+ // Chrome 125–131 only knows the legacy 'button'/'widget' enum and
503
+ // throws a synchronous TypeError for the modern 'active'/'passive'
504
+ // values. Retry once with the legacy value so older browsers work.
505
+ if (modernMode && isUnknownModeEnumError(modeError)) {
506
+ const legacyMode = MODERN_TO_LEGACY_MODE[modernMode];
507
+ debug.log(`Browser rejected modern mode '${modernMode}'; retrying with legacy mode '${legacyMode}'`);
508
+ credential = await credentials.get(buildCredentialOptions(legacyMode));
509
+ } else {
510
+ throw modeError;
511
+ }
512
+ }
395
513
 
396
514
  debug.log('navigator.credentials.get returned:', {
397
515
  hasCredential: !!credential,
@@ -399,7 +517,7 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
399
517
  hasToken: !!credential?.token,
400
518
  });
401
519
 
402
- if (!credential || credential.type !== 'identity') {
520
+ if (!credential || credential.type !== 'identity' || !credential.token) {
403
521
  debug.log('No valid identity credential returned');
404
522
  return null;
405
523
  }
@@ -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
+ });