@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.
- package/README.md +17 -2
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/mixins/OxyServices.fedcm.js +109 -36
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/mixins/OxyServices.fedcm.js +109 -36
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/mixins/OxyServices.fedcm.d.ts +28 -5
- package/package.json +1 -1
- package/src/mixins/OxyServices.fedcm.ts +198 -42
- package/src/mixins/__tests__/fedcm.test.ts +136 -0
|
@@ -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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
@@ -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<
|
|
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: '
|
|
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: '
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
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:
|
|
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 (
|
|
272
|
+
if (errorName === 'AbortError') {
|
|
161
273
|
throw new OxyAuthenticationError('Sign-in was cancelled by user');
|
|
162
274
|
}
|
|
163
|
-
if (
|
|
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:
|
|
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
|
-
|
|
289
|
-
|
|
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
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
|
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
|
+
});
|