@metamask/profile-sync-controller 32.1.1 → 32.3.0
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/CHANGELOG.md +27 -1
- package/README.md +21 -0
- package/dist/controllers/authentication/AuthenticationController-method-action-types.d.ts +80 -1
- package/dist/controllers/authentication/AuthenticationController-method-action-types.d.ts.map +1 -1
- package/dist/controllers/authentication/AuthenticationController-method-action-types.js.map +1 -1
- package/dist/controllers/authentication/AuthenticationController.d.ts +79 -2
- package/dist/controllers/authentication/AuthenticationController.d.ts.map +1 -1
- package/dist/controllers/authentication/AuthenticationController.js +374 -12
- package/dist/controllers/authentication/AuthenticationController.js.map +1 -1
- package/dist/controllers/authentication/index.d.ts +1 -1
- package/dist/controllers/authentication/index.d.ts.map +1 -1
- package/dist/controllers/authentication/index.js.map +1 -1
- package/dist/controllers/authentication/mocks/mockResponses.d.ts +5 -0
- package/dist/controllers/authentication/mocks/mockResponses.d.ts.map +1 -1
- package/dist/controllers/authentication/mocks/mockResponses.js +26 -1
- package/dist/controllers/authentication/mocks/mockResponses.js.map +1 -1
- package/dist/controllers/user-storage/UserStorageController.d.ts.map +1 -1
- package/dist/controllers/user-storage/UserStorageController.js +6 -5
- package/dist/controllers/user-storage/UserStorageController.js.map +1 -1
- package/dist/sdk/authentication-jwt-bearer/flow-srp.d.ts +60 -2
- package/dist/sdk/authentication-jwt-bearer/flow-srp.d.ts.map +1 -1
- package/dist/sdk/authentication-jwt-bearer/flow-srp.js +122 -2
- package/dist/sdk/authentication-jwt-bearer/flow-srp.js.map +1 -1
- package/dist/sdk/authentication-jwt-bearer/mfa/schemas.d.ts +693 -0
- package/dist/sdk/authentication-jwt-bearer/mfa/schemas.d.ts.map +1 -0
- package/dist/sdk/authentication-jwt-bearer/mfa/schemas.js +266 -0
- package/dist/sdk/authentication-jwt-bearer/mfa/schemas.js.map +1 -0
- package/dist/sdk/authentication-jwt-bearer/mfa/services.d.ts +104 -0
- package/dist/sdk/authentication-jwt-bearer/mfa/services.d.ts.map +1 -0
- package/dist/sdk/authentication-jwt-bearer/mfa/services.js +368 -0
- package/dist/sdk/authentication-jwt-bearer/mfa/services.js.map +1 -0
- package/dist/sdk/authentication-jwt-bearer/mfa/types.d.ts +125 -0
- package/dist/sdk/authentication-jwt-bearer/mfa/types.d.ts.map +1 -0
- package/dist/sdk/authentication-jwt-bearer/mfa/types.js +3 -0
- package/dist/sdk/authentication-jwt-bearer/mfa/types.js.map +1 -0
- package/dist/sdk/authentication-jwt-bearer/services.d.ts +4 -5
- package/dist/sdk/authentication-jwt-bearer/services.d.ts.map +1 -1
- package/dist/sdk/authentication-jwt-bearer/services.js +7 -4
- package/dist/sdk/authentication-jwt-bearer/services.js.map +1 -1
- package/dist/sdk/authentication-jwt-bearer/types.d.ts +9 -0
- package/dist/sdk/authentication-jwt-bearer/types.d.ts.map +1 -1
- package/dist/sdk/authentication-jwt-bearer/types.js.map +1 -1
- package/dist/sdk/authentication.d.ts +17 -7
- package/dist/sdk/authentication.d.ts.map +1 -1
- package/dist/sdk/authentication.js +25 -1
- package/dist/sdk/authentication.js.map +1 -1
- package/dist/sdk/constants.d.ts +3 -0
- package/dist/sdk/constants.d.ts.map +1 -1
- package/dist/sdk/constants.js +3 -0
- package/dist/sdk/constants.js.map +1 -1
- package/dist/sdk/errors.d.ts +106 -0
- package/dist/sdk/errors.d.ts.map +1 -1
- package/dist/sdk/errors.js +179 -0
- package/dist/sdk/errors.js.map +1 -1
- package/dist/sdk/index.d.ts +3 -0
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +1 -0
- package/dist/sdk/index.js.map +1 -1
- package/dist/sdk/mocks/auth.d.ts +63 -0
- package/dist/sdk/mocks/auth.d.ts.map +1 -1
- package/dist/sdk/mocks/auth.js +90 -0
- package/dist/sdk/mocks/auth.js.map +1 -1
- package/dist/sdk/utils/as-record.d.ts +10 -0
- package/dist/sdk/utils/as-record.d.ts.map +1 -0
- package/dist/sdk/utils/as-record.js +14 -0
- package/dist/sdk/utils/as-record.js.map +1 -0
- package/dist/sdk/utils/jwt.d.ts +13 -0
- package/dist/sdk/utils/jwt.d.ts.map +1 -0
- package/dist/sdk/utils/jwt.js +21 -0
- package/dist/sdk/utils/jwt.js.map +1 -0
- package/dist/sdk/utils/to-error-message.d.ts +8 -0
- package/dist/sdk/utils/to-error-message.d.ts.map +1 -0
- package/dist/sdk/utils/to-error-message.js +10 -0
- package/dist/sdk/utils/to-error-message.js.map +1 -0
- package/dist/sdk/utils/validate-login-response.d.ts.map +1 -1
- package/dist/sdk/utils/validate-login-response.js +5 -7
- package/dist/sdk/utils/validate-login-response.js.map +1 -1
- package/dist/sdk/utils/validate-pair-response.d.ts +4 -0
- package/dist/sdk/utils/validate-pair-response.d.ts.map +1 -1
- package/dist/sdk/utils/validate-pair-response.js.map +1 -1
- package/dist/shared/storage-schema.d.ts +1 -0
- package/dist/shared/storage-schema.d.ts.map +1 -1
- package/dist/shared/storage-schema.js +1 -0
- package/dist/shared/storage-schema.js.map +1 -1
- package/dist/shared/utils/entropy-source.d.ts +3 -12
- package/dist/shared/utils/entropy-source.d.ts.map +1 -1
- package/dist/shared/utils/entropy-source.js +4 -17
- package/dist/shared/utils/entropy-source.js.map +1 -1
- package/package.json +12 -9
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { BaseController } from '@metamask/base-controller';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { selectHdKeyringEntropySourceIds } from '@metamask/keyring-controller';
|
|
3
|
+
import { BeginEnrollmentRequestStruct, CompleteEnrollmentRequestStruct, assertValidMfaRequest, BeginStepUpRequestStruct, CompleteStepUpRequestStruct, GetElevatedTokenRequestStruct, parseElevatedTokenClaims, } from '../../sdk/authentication-jwt-bearer/mfa/schemas.js';
|
|
4
|
+
import { assertMessageStartsWithMetamask, AuthType, Env, JwtBearerAuth, PairConflictError, getMfaErrorCode, ElevatedTokenInvalidError, } from '../../sdk/index.js';
|
|
5
|
+
import { decodeJwtPayload } from '../../sdk/utils/jwt.js';
|
|
6
|
+
import { toErrorMessage } from '../../sdk/utils/to-error-message.js';
|
|
7
|
+
import { getPrimaryHdKeyringEntropySourceId } from '../../shared/utils/entropy-source.js';
|
|
4
8
|
import { getHdKeyringSeed } from '../../shared/utils/hd-keyring-seed.js';
|
|
5
9
|
import { getMessageSigningPublicKey, signMessageWithMessageSigningKey, } from '../../shared/utils/message-signing.js';
|
|
6
10
|
const controllerName = 'AuthenticationController';
|
|
7
11
|
export const defaultState = {
|
|
8
12
|
isSignedIn: false,
|
|
13
|
+
enrolledCredentials: [],
|
|
9
14
|
needsProfilePairing: true,
|
|
10
15
|
needsSocialPairing: true,
|
|
11
16
|
};
|
|
@@ -29,7 +34,7 @@ const metadata = {
|
|
|
29
34
|
usedInUi: true,
|
|
30
35
|
},
|
|
31
36
|
srpSessionData: {
|
|
32
|
-
// Remove access token from state logs
|
|
37
|
+
// Remove access token and paired identifiers from state logs
|
|
33
38
|
includeInStateLogs: (srpSessionData) => {
|
|
34
39
|
// Unreachable branch, included just to fix a type error for the case where this property is
|
|
35
40
|
// unset. The type gets collapsed to include `| undefined` even though `undefined` is never
|
|
@@ -41,9 +46,11 @@ const metadata = {
|
|
|
41
46
|
}
|
|
42
47
|
return Object.entries(srpSessionData).reduce((sanitizedSrpSessionData, [key, value]) => {
|
|
43
48
|
const { accessToken: _unused, ...tokenWithoutAccessToken } = value.token;
|
|
49
|
+
const { pairedIdentifierIds: _unusedPairedIdentifierIds, ...profileWithoutPairedIdentifierIds } = value.profile;
|
|
44
50
|
sanitizedSrpSessionData[key] = {
|
|
45
51
|
...value,
|
|
46
52
|
token: tokenWithoutAccessToken,
|
|
53
|
+
profile: profileWithoutPairedIdentifierIds,
|
|
47
54
|
};
|
|
48
55
|
return sanitizedSrpSessionData;
|
|
49
56
|
}, {});
|
|
@@ -52,7 +59,30 @@ const metadata = {
|
|
|
52
59
|
includeInDebugSnapshot: false,
|
|
53
60
|
usedInUi: true,
|
|
54
61
|
},
|
|
62
|
+
enrolledCredentials: {
|
|
63
|
+
// Allow-list so new credential types cannot leak identifiers by default.
|
|
64
|
+
includeInStateLogs: (credentials) => credentials?.map(({ type, status, enrolledAt }) => ({
|
|
65
|
+
type,
|
|
66
|
+
status,
|
|
67
|
+
...(enrolledAt === undefined ? {} : { enrolledAt }),
|
|
68
|
+
})) ?? null,
|
|
69
|
+
persist: false,
|
|
70
|
+
includeInDebugSnapshot: false,
|
|
71
|
+
usedInUi: true,
|
|
72
|
+
},
|
|
73
|
+
stepUpSessionExpiresAt: {
|
|
74
|
+
includeInStateLogs: false,
|
|
75
|
+
persist: false,
|
|
76
|
+
includeInDebugSnapshot: false,
|
|
77
|
+
usedInUi: true,
|
|
78
|
+
},
|
|
55
79
|
};
|
|
80
|
+
/**
|
|
81
|
+
* Lifetime of an elevated session. Deliberately shorter than the
|
|
82
|
+
* elevated token's own `exp` so a fresh ceremony is required per sensitive
|
|
83
|
+
* action window, per the MFA phase-1 specification.
|
|
84
|
+
*/
|
|
85
|
+
export const STEP_UP_SESSION_TTL_MS = 60_000;
|
|
56
86
|
const MESSENGER_EXPOSED_METHODS = [
|
|
57
87
|
'performSignIn',
|
|
58
88
|
'performSignOut',
|
|
@@ -65,6 +95,13 @@ const MESSENGER_EXPOSED_METHODS = [
|
|
|
65
95
|
'isSignedIn',
|
|
66
96
|
'requestProfilePairing',
|
|
67
97
|
'clearState',
|
|
98
|
+
'refreshEnrolledCredentials',
|
|
99
|
+
'beginCredentialEnrollment',
|
|
100
|
+
'completeCredentialEnrollment',
|
|
101
|
+
'beginStepUp',
|
|
102
|
+
'completeStepUp',
|
|
103
|
+
'getElevatedProfileToken',
|
|
104
|
+
'clearStepUpSession',
|
|
68
105
|
];
|
|
69
106
|
/**
|
|
70
107
|
* Controller that enables authentication for restricted endpoints.
|
|
@@ -73,11 +110,26 @@ const MESSENGER_EXPOSED_METHODS = [
|
|
|
73
110
|
export class AuthenticationController extends BaseController {
|
|
74
111
|
#metametrics;
|
|
75
112
|
#auth;
|
|
113
|
+
#trace;
|
|
76
114
|
#config = {
|
|
77
115
|
env: Env.PRD,
|
|
78
116
|
isSocialPairingEnabled: () => false,
|
|
79
117
|
};
|
|
80
118
|
#isUnlocked = false;
|
|
119
|
+
/**
|
|
120
|
+
* Bumped whenever the authenticated session ends, so an in-flight MFA
|
|
121
|
+
* ceremony started under the previous session cannot apply its result.
|
|
122
|
+
*/
|
|
123
|
+
#authSessionEpoch = 0;
|
|
124
|
+
#stepUpSession = null;
|
|
125
|
+
#stepUpTimer;
|
|
126
|
+
/**
|
|
127
|
+
* Sequence number of the most recently started credentials refresh. Only
|
|
128
|
+
* that refresh may write the cache, so a slower, earlier request cannot
|
|
129
|
+
* overwrite a newer list (e.g. an explicit refresh landing after an
|
|
130
|
+
* enrollment's refresh).
|
|
131
|
+
*/
|
|
132
|
+
#credentialsRefreshSeq = 0;
|
|
81
133
|
/**
|
|
82
134
|
* Bumped by `requestProfilePairing` and `clearState` so an in-flight
|
|
83
135
|
* `performSignIn` can't clear `needsProfilePairing` afterwards.
|
|
@@ -91,11 +143,13 @@ export class AuthenticationController extends BaseController {
|
|
|
91
143
|
this.#isUnlocked = true;
|
|
92
144
|
});
|
|
93
145
|
this.messenger.subscribe('KeyringController:lock', () => {
|
|
146
|
+
this.#authSessionEpoch += 1;
|
|
94
147
|
this.#isUnlocked = false;
|
|
148
|
+
this.clearStepUpSession();
|
|
95
149
|
});
|
|
96
150
|
},
|
|
97
151
|
};
|
|
98
|
-
constructor({ messenger, state, config, metametrics, }) {
|
|
152
|
+
constructor({ messenger, state, config, metametrics, trace, }) {
|
|
99
153
|
super({
|
|
100
154
|
messenger,
|
|
101
155
|
metadata,
|
|
@@ -105,12 +159,14 @@ export class AuthenticationController extends BaseController {
|
|
|
105
159
|
if (!metametrics) {
|
|
106
160
|
throw new Error('`metametrics` field is required');
|
|
107
161
|
}
|
|
162
|
+
// `??` per key so an explicit `undefined` keeps the default rather than
|
|
163
|
+
// clobbering it.
|
|
108
164
|
this.#config = {
|
|
109
|
-
|
|
110
|
-
...config,
|
|
165
|
+
env: config?.env ?? this.#config.env,
|
|
111
166
|
isSocialPairingEnabled: config?.isSocialPairingEnabled ?? this.#config.isSocialPairingEnabled,
|
|
112
167
|
};
|
|
113
168
|
this.#metametrics = metametrics;
|
|
169
|
+
this.#trace = trace ?? ((_request, fn) => fn?.());
|
|
114
170
|
this.#auth = new JwtBearerAuth({
|
|
115
171
|
env: this.#config.env,
|
|
116
172
|
platform: metametrics.agent,
|
|
@@ -146,11 +202,16 @@ export class AuthenticationController extends BaseController {
|
|
|
146
202
|
if (!state.srpSessionData) {
|
|
147
203
|
state.srpSessionData = {};
|
|
148
204
|
}
|
|
205
|
+
// The API omits `paired_identifier_ids` when it fails to load them, so
|
|
206
|
+
// keep the last known value rather than wiping it.
|
|
207
|
+
const pairedIdentifierIds = loginResponse.profile.pairedIdentifierIds ??
|
|
208
|
+
state.srpSessionData[resolvedId]?.profile.pairedIdentifierIds;
|
|
149
209
|
state.srpSessionData[resolvedId] = {
|
|
150
210
|
...loginResponse,
|
|
151
211
|
profile: {
|
|
152
212
|
...loginResponse.profile,
|
|
153
213
|
metaMetricsId,
|
|
214
|
+
...(pairedIdentifierIds ? { pairedIdentifierIds } : {}),
|
|
154
215
|
},
|
|
155
216
|
};
|
|
156
217
|
});
|
|
@@ -160,14 +221,19 @@ export class AuthenticationController extends BaseController {
|
|
|
160
221
|
throw new Error(`${methodName} - unable to proceed, wallet is locked`);
|
|
161
222
|
}
|
|
162
223
|
}
|
|
224
|
+
#assertAuthSessionEpoch(epoch, methodName) {
|
|
225
|
+
if (!this.#isUnlocked || this.#authSessionEpoch !== epoch) {
|
|
226
|
+
throw new Error(`${methodName} - unable to proceed, the authenticated session ended`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
163
229
|
/**
|
|
164
230
|
* Reads the HD keyring entropy source IDs from KeyringController.
|
|
165
231
|
*
|
|
166
232
|
* @returns The HD keyring metadata IDs, primary first.
|
|
167
233
|
*/
|
|
168
234
|
#getHdKeyringEntropySourceIds() {
|
|
169
|
-
const
|
|
170
|
-
return
|
|
235
|
+
const keyringState = this.messenger.call('KeyringController:getState');
|
|
236
|
+
return selectHdKeyringEntropySourceIds(keyringState);
|
|
171
237
|
}
|
|
172
238
|
/**
|
|
173
239
|
* Resolves the primary SRP's entropy source ID from KeyringController rather
|
|
@@ -179,8 +245,8 @@ export class AuthenticationController extends BaseController {
|
|
|
179
245
|
* the wallet is unlocked.
|
|
180
246
|
*/
|
|
181
247
|
#getPrimaryEntropySourceId() {
|
|
182
|
-
const
|
|
183
|
-
return getPrimaryHdKeyringEntropySourceId(
|
|
248
|
+
const keyringState = this.messenger.call('KeyringController:getState');
|
|
249
|
+
return getPrimaryHdKeyringEntropySourceId(keyringState);
|
|
184
250
|
}
|
|
185
251
|
/**
|
|
186
252
|
* Resolves the SRP login tag for `raw_message`.
|
|
@@ -371,11 +437,12 @@ export class AuthenticationController extends BaseController {
|
|
|
371
437
|
return;
|
|
372
438
|
}
|
|
373
439
|
try {
|
|
374
|
-
await this.#auth.pairSocialIdentifier({
|
|
440
|
+
const pairedIdentifierIds = await this.#auth.pairSocialIdentifier({
|
|
375
441
|
identifierType,
|
|
376
442
|
socialJwt,
|
|
377
443
|
...(email ? { email } : {}),
|
|
378
444
|
}, primaryAccessToken);
|
|
445
|
+
this.#setPrimaryPairedIdentifierIds(pairedIdentifierIds);
|
|
379
446
|
this.#clearNeedsSocialPairing();
|
|
380
447
|
}
|
|
381
448
|
catch (error) {
|
|
@@ -459,10 +526,29 @@ export class AuthenticationController extends BaseController {
|
|
|
459
526
|
return [];
|
|
460
527
|
}
|
|
461
528
|
const primaryAccessToken = accessTokens[0]; // Associated with primary SRP.
|
|
462
|
-
const { profileAliases, profile: { canonicalProfileId }, } = await this.#auth.pairSrpProfiles(accessTokens, primaryAccessToken);
|
|
529
|
+
const { profileAliases, profile: { canonicalProfileId, pairedIdentifierIds }, } = await this.#auth.pairSrpProfiles(accessTokens, primaryAccessToken);
|
|
463
530
|
this.#propagateCanonical(canonicalProfileId);
|
|
531
|
+
this.#setPrimaryPairedIdentifierIds(pairedIdentifierIds);
|
|
464
532
|
return profileAliases;
|
|
465
533
|
}
|
|
534
|
+
/**
|
|
535
|
+
* Pair calls use the primary SRP's token, so their response only describes
|
|
536
|
+
* the primary's profile: secondaries skipped by the server are not in it.
|
|
537
|
+
*
|
|
538
|
+
* @param pairedIdentifierIds - Identifiers returned by the pair call, if any.
|
|
539
|
+
*/
|
|
540
|
+
#setPrimaryPairedIdentifierIds(pairedIdentifierIds) {
|
|
541
|
+
if (!pairedIdentifierIds) {
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
const primaryEntropySourceId = this.#getPrimaryEntropySourceId();
|
|
545
|
+
this.update((state) => {
|
|
546
|
+
const entry = state.srpSessionData?.[primaryEntropySourceId];
|
|
547
|
+
if (entry?.profile) {
|
|
548
|
+
entry.profile.pairedIdentifierIds = pairedIdentifierIds;
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
}
|
|
466
552
|
#propagateCanonical(canonicalProfileId) {
|
|
467
553
|
const { srpSessionData } = this.state;
|
|
468
554
|
if (!srpSessionData) {
|
|
@@ -490,7 +576,280 @@ export class AuthenticationController extends BaseController {
|
|
|
490
576
|
return (this.state.srpSessionData?.[primaryEntropySourceId]?.profile
|
|
491
577
|
?.canonicalProfileId ?? null);
|
|
492
578
|
}
|
|
579
|
+
/**
|
|
580
|
+
* Runs one MFA network step inside a trace span tagged with the caller's
|
|
581
|
+
* operation and the credential type, recording the outcome and MFA error
|
|
582
|
+
* code.
|
|
583
|
+
*
|
|
584
|
+
* @param name - Span name.
|
|
585
|
+
* @param operation - Caller-supplied `reason.operation`.
|
|
586
|
+
* @param credentialType - Credential type the step concerns.
|
|
587
|
+
* @param fn - The network step.
|
|
588
|
+
* @returns The step's result.
|
|
589
|
+
*/
|
|
590
|
+
async #runMfaRequest(name, operation, credentialType, fn) {
|
|
591
|
+
return await this.#trace({
|
|
592
|
+
name,
|
|
593
|
+
tags: { operation, credentialType },
|
|
594
|
+
}, async (context) => {
|
|
595
|
+
try {
|
|
596
|
+
const result = await fn();
|
|
597
|
+
this.#setTraceAttribute(context, 'outcome', 'success');
|
|
598
|
+
return result;
|
|
599
|
+
}
|
|
600
|
+
catch (error) {
|
|
601
|
+
this.#setTraceAttribute(context, 'outcome', 'error');
|
|
602
|
+
const mfaCode = getMfaErrorCode(error);
|
|
603
|
+
if (mfaCode) {
|
|
604
|
+
this.#setTraceAttribute(context, 'mfaErrorCode', mfaCode);
|
|
605
|
+
if (mfaCode === 'authentication_required' && this.#isUnlocked) {
|
|
606
|
+
this.#invalidateSrpSession(this.#getPrimaryEntropySourceId());
|
|
607
|
+
// An elevated session must not outlive a rejected base session.
|
|
608
|
+
this.clearStepUpSession();
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
throw error;
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* `TraceContext` is opaque in `@metamask/controller-utils`; both clients
|
|
617
|
+
* hand back a Sentry span, so attributes are set via duck typing and a
|
|
618
|
+
* non-Sentry context is a silent no-op.
|
|
619
|
+
*
|
|
620
|
+
* @param context - Span handed to the trace callback, if any.
|
|
621
|
+
* @param key - Attribute name.
|
|
622
|
+
* @param value - Attribute value.
|
|
623
|
+
*/
|
|
624
|
+
#setTraceAttribute(context, key, value) {
|
|
625
|
+
const traceSpan = context;
|
|
626
|
+
traceSpan?.setAttribute?.(key, value);
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Refreshes credentials enrolled on the canonical profile.
|
|
630
|
+
*
|
|
631
|
+
* @returns The current supported credentials.
|
|
632
|
+
*/
|
|
633
|
+
async refreshEnrolledCredentials() {
|
|
634
|
+
this.#assertIsUnlocked('refreshEnrolledCredentials');
|
|
635
|
+
const sessionEpoch = this.#authSessionEpoch;
|
|
636
|
+
this.#credentialsRefreshSeq += 1;
|
|
637
|
+
const refreshSeq = this.#credentialsRefreshSeq;
|
|
638
|
+
const primaryEntropySourceId = this.#getPrimaryEntropySourceId();
|
|
639
|
+
const credentials = await this.#runMfaRequest('MFA Credentials Refresh', 'credentials.refresh', 'all', async () => await this.#auth.getMfaCredentials(primaryEntropySourceId));
|
|
640
|
+
this.#assertAuthSessionEpoch(sessionEpoch, 'refreshEnrolledCredentials');
|
|
641
|
+
// A newer refresh started while this one was in flight; its result is at
|
|
642
|
+
// least as fresh, so defer to it rather than overwrite with older data.
|
|
643
|
+
if (refreshSeq !== this.#credentialsRefreshSeq) {
|
|
644
|
+
return this.#getEnrolledCredentials();
|
|
645
|
+
}
|
|
646
|
+
// Skip the write when nothing changed so subscribers are not woken up by
|
|
647
|
+
// a fresh-but-identical array.
|
|
648
|
+
if (JSON.stringify(credentials) !==
|
|
649
|
+
JSON.stringify(this.#getEnrolledCredentials())) {
|
|
650
|
+
this.update((state) => {
|
|
651
|
+
state.enrolledCredentials = credentials;
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
return credentials;
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Begins enrollment of a passkey or email OTP credential.
|
|
658
|
+
*
|
|
659
|
+
* @param request - Credential, optional email address, and trace reason.
|
|
660
|
+
* @returns A challenge for the client-owned ceremony.
|
|
661
|
+
*/
|
|
662
|
+
async beginCredentialEnrollment(request) {
|
|
663
|
+
this.#assertIsUnlocked('beginCredentialEnrollment');
|
|
664
|
+
const sessionEpoch = this.#authSessionEpoch;
|
|
665
|
+
assertValidMfaRequest(request, BeginEnrollmentRequestStruct);
|
|
666
|
+
const primaryEntropySourceId = this.#getPrimaryEntropySourceId();
|
|
667
|
+
// Enrolling beyond the first credential requires AAL2: send the elevated
|
|
668
|
+
// token while a session is live and let the server decide.
|
|
669
|
+
const accessToken = this.getElevatedProfileToken()?.accessToken;
|
|
670
|
+
const challenge = await this.#runMfaRequest('MFA Enroll Begin', request.reason.operation, request.type, async () => await this.#auth.beginMfaEnrollment(request.type, {
|
|
671
|
+
email: request.email,
|
|
672
|
+
entropySourceId: primaryEntropySourceId,
|
|
673
|
+
accessToken,
|
|
674
|
+
}));
|
|
675
|
+
this.#assertAuthSessionEpoch(sessionEpoch, 'beginCredentialEnrollment');
|
|
676
|
+
return challenge;
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Completes credential enrollment and refreshes the credential cache.
|
|
680
|
+
*
|
|
681
|
+
* A cache-refresh failure does not undo successful enrollment. Email
|
|
682
|
+
* enrollment invalidates the primary SRP session *after* refresh so the
|
|
683
|
+
* credentials call can reuse the still-valid access token; the next token
|
|
684
|
+
* fetch then includes the newly verified email claim. That invalidation
|
|
685
|
+
* happens even if the session ends mid-request: the enrollment succeeded
|
|
686
|
+
* on the server, so a token cached across a lock must not be reused
|
|
687
|
+
* without the new claim.
|
|
688
|
+
*
|
|
689
|
+
* @param request - Flow identifier, platform or email proof, and trace reason.
|
|
690
|
+
* @returns The refreshed credentials, or the existing cache if refresh fails.
|
|
691
|
+
*/
|
|
692
|
+
async completeCredentialEnrollment(request) {
|
|
693
|
+
this.#assertIsUnlocked('completeCredentialEnrollment');
|
|
694
|
+
const sessionEpoch = this.#authSessionEpoch;
|
|
695
|
+
assertValidMfaRequest(request, CompleteEnrollmentRequestStruct);
|
|
696
|
+
const { type } = request.proof;
|
|
697
|
+
const primaryEntropySourceId = this.#getPrimaryEntropySourceId();
|
|
698
|
+
await this.#runMfaRequest('MFA Enroll Complete', request.reason.operation, type, async () => await this.#auth.completeMfaEnrollment(request.flowId, request.proof, primaryEntropySourceId));
|
|
699
|
+
try {
|
|
700
|
+
this.#assertAuthSessionEpoch(sessionEpoch, 'completeCredentialEnrollment');
|
|
701
|
+
}
|
|
702
|
+
catch (error) {
|
|
703
|
+
if (type === 'email_otp') {
|
|
704
|
+
this.#invalidateSrpSession(primaryEntropySourceId);
|
|
705
|
+
}
|
|
706
|
+
throw error;
|
|
707
|
+
}
|
|
708
|
+
// The factor set changed: require a fresh ceremony against the new set.
|
|
709
|
+
this.clearStepUpSession();
|
|
710
|
+
try {
|
|
711
|
+
return await this.refreshEnrolledCredentials();
|
|
712
|
+
}
|
|
713
|
+
catch {
|
|
714
|
+
return this.#getEnrolledCredentials();
|
|
715
|
+
}
|
|
716
|
+
finally {
|
|
717
|
+
if (type === 'email_otp') {
|
|
718
|
+
this.#invalidateSrpSession(primaryEntropySourceId);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Begins step-up verification with an enrolled credential.
|
|
724
|
+
*
|
|
725
|
+
* @param request - Credential type and trace reason.
|
|
726
|
+
* @returns A challenge for the client-owned ceremony.
|
|
727
|
+
*/
|
|
728
|
+
async beginStepUp(request) {
|
|
729
|
+
this.#assertIsUnlocked('beginStepUp');
|
|
730
|
+
const sessionEpoch = this.#authSessionEpoch;
|
|
731
|
+
assertValidMfaRequest(request, BeginStepUpRequestStruct);
|
|
732
|
+
const primaryEntropySourceId = this.#getPrimaryEntropySourceId();
|
|
733
|
+
const challenge = await this.#runMfaRequest('MFA Step-Up Begin', request.reason.operation, request.type, async () => await this.#auth.beginMfaVerification(request.type, primaryEntropySourceId));
|
|
734
|
+
this.#assertAuthSessionEpoch(sessionEpoch, 'beginStepUp');
|
|
735
|
+
return challenge;
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Completes step-up verification and opens a short-lived elevated session.
|
|
739
|
+
*
|
|
740
|
+
* The AAL2 assertion returned by the MFA service is exchanged at Hydra for
|
|
741
|
+
* an elevated access token, whose claims are checked before the session
|
|
742
|
+
* opens. The token itself never enters controller state.
|
|
743
|
+
*
|
|
744
|
+
* @param request - Flow identifier, platform or email proof, and trace reason.
|
|
745
|
+
* @returns The elevated profile access token.
|
|
746
|
+
*/
|
|
747
|
+
async completeStepUp(request) {
|
|
748
|
+
this.#assertIsUnlocked('completeStepUp');
|
|
749
|
+
const sessionEpoch = this.#authSessionEpoch;
|
|
750
|
+
assertValidMfaRequest(request, CompleteStepUpRequestStruct);
|
|
751
|
+
const { type } = request.proof;
|
|
752
|
+
const { operation } = request.reason;
|
|
753
|
+
const primaryEntropySourceId = this.#getPrimaryEntropySourceId();
|
|
754
|
+
const assertion = await this.#runMfaRequest('MFA Step-Up Complete', operation, type, async () => await this.#auth.completeMfaVerification(request.flowId, request.proof, primaryEntropySourceId));
|
|
755
|
+
this.#assertAuthSessionEpoch(sessionEpoch, 'completeStepUp');
|
|
756
|
+
const accessToken = await this.#runMfaRequest('MFA Token Exchange', operation, type, async () => await this.#auth.exchangeMfaAssertion(assertion.token));
|
|
757
|
+
this.#assertAuthSessionEpoch(sessionEpoch, 'completeStepUp');
|
|
758
|
+
let decodedClaims;
|
|
759
|
+
try {
|
|
760
|
+
decodedClaims = decodeJwtPayload(accessToken.accessToken);
|
|
761
|
+
}
|
|
762
|
+
catch (error) {
|
|
763
|
+
throw new ElevatedTokenInvalidError(toErrorMessage(error));
|
|
764
|
+
}
|
|
765
|
+
const claims = parseElevatedTokenClaims(decodedClaims);
|
|
766
|
+
if (claims.exp * 1000 <= Date.now()) {
|
|
767
|
+
throw new ElevatedTokenInvalidError('Elevated token is expired');
|
|
768
|
+
}
|
|
769
|
+
const token = { ...accessToken, claims };
|
|
770
|
+
this.#openStepUpSession(token);
|
|
771
|
+
return token;
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Returns the active elevated token when it meets the requested freshness.
|
|
775
|
+
*
|
|
776
|
+
* @param request - Optional maximum session age in milliseconds, measured
|
|
777
|
+
* from when the token was obtained. Zero always requires a new ceremony.
|
|
778
|
+
* @returns A live elevated token, or null when no reusable session exists.
|
|
779
|
+
*/
|
|
780
|
+
getElevatedProfileToken(request = {}) {
|
|
781
|
+
this.#assertIsUnlocked('getElevatedProfileToken');
|
|
782
|
+
assertValidMfaRequest(request, GetElevatedTokenRequestStruct);
|
|
783
|
+
const session = this.#stepUpSession;
|
|
784
|
+
if (!session) {
|
|
785
|
+
return null;
|
|
786
|
+
}
|
|
787
|
+
const now = Date.now();
|
|
788
|
+
if (now >= session.expiresAt) {
|
|
789
|
+
// The hard-expiry timer has not fired yet (e.g. a suspended tab).
|
|
790
|
+
this.clearStepUpSession();
|
|
791
|
+
return null;
|
|
792
|
+
}
|
|
793
|
+
// `>=` so a zero max age always forces a fresh ceremony. Not cleared: the
|
|
794
|
+
// max age is this caller's requirement; others may still use the session.
|
|
795
|
+
const maxAge = request.maxSessionAgeMs;
|
|
796
|
+
if (maxAge !== undefined && now - session.token.obtainedAt >= maxAge) {
|
|
797
|
+
return null;
|
|
798
|
+
}
|
|
799
|
+
return session.token;
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Opens the elevated session. Its lifetime is the session TTL clamped to
|
|
803
|
+
* the token's own `exp`, so the session never outlives the token.
|
|
804
|
+
*
|
|
805
|
+
* @param token - The freshly exchanged elevated token.
|
|
806
|
+
*/
|
|
807
|
+
#openStepUpSession(token) {
|
|
808
|
+
this.clearStepUpSession();
|
|
809
|
+
const expiresAt = Math.min(token.obtainedAt + STEP_UP_SESSION_TTL_MS, token.claims.exp * 1000);
|
|
810
|
+
this.#stepUpSession = { token, expiresAt };
|
|
811
|
+
this.update((state) => {
|
|
812
|
+
state.stepUpSessionExpiresAt = expiresAt;
|
|
813
|
+
});
|
|
814
|
+
this.#stepUpTimer = setTimeout(() => this.clearStepUpSession(), Math.max(0, expiresAt - Date.now()));
|
|
815
|
+
// Never keep a Node process alive for the expiry timer (tests, tooling).
|
|
816
|
+
this.#stepUpTimer.unref?.();
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Clears the in-memory elevated session and its expiration timer.
|
|
820
|
+
*/
|
|
821
|
+
clearStepUpSession() {
|
|
822
|
+
if (this.#stepUpTimer !== undefined) {
|
|
823
|
+
clearTimeout(this.#stepUpTimer);
|
|
824
|
+
this.#stepUpTimer = undefined;
|
|
825
|
+
}
|
|
826
|
+
this.#stepUpSession = null;
|
|
827
|
+
if (this.state.stepUpSessionExpiresAt !== undefined) {
|
|
828
|
+
this.update((state) => {
|
|
829
|
+
state.stepUpSessionExpiresAt = undefined;
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* Drops the cached credential list. Callers that wipe profile state must go
|
|
835
|
+
* through this so subscribers never keep credentials belonging to a profile
|
|
836
|
+
* that is no longer active.
|
|
837
|
+
*/
|
|
838
|
+
#clearEnrolledCredentials() {
|
|
839
|
+
if (this.#getEnrolledCredentials().length === 0) {
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
this.update((state) => {
|
|
843
|
+
state.enrolledCredentials = [];
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
#getEnrolledCredentials() {
|
|
847
|
+
return this.state.enrolledCredentials ?? [];
|
|
848
|
+
}
|
|
493
849
|
performSignOut() {
|
|
850
|
+
this.#authSessionEpoch += 1;
|
|
851
|
+
this.clearStepUpSession();
|
|
852
|
+
this.#clearEnrolledCredentials();
|
|
494
853
|
this.update((state) => {
|
|
495
854
|
state.isSignedIn = false;
|
|
496
855
|
state.srpSessionData = undefined;
|
|
@@ -502,6 +861,9 @@ export class AuthenticationController extends BaseController {
|
|
|
502
861
|
*/
|
|
503
862
|
clearState() {
|
|
504
863
|
this.#profilePairingRequestEpoch += 1;
|
|
864
|
+
this.#authSessionEpoch += 1;
|
|
865
|
+
this.clearStepUpSession();
|
|
866
|
+
this.#clearEnrolledCredentials();
|
|
505
867
|
this.update(() => ({ ...defaultState }));
|
|
506
868
|
}
|
|
507
869
|
/**
|