@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/mixins/OxyServices.fedcm.js +87 -27
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/mixins/OxyServices.fedcm.js +87 -27
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/mixins/OxyServices.fedcm.d.ts +19 -1
- package/package.json +1 -1
- package/src/mixins/OxyServices.fedcm.ts +146 -28
- package/src/mixins/__tests__/fedcm.test.ts +136 -0
|
@@ -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
|
-
|
|
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
|
@@ -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: '
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
+
});
|