@metamask/profile-sync-controller 32.1.1 → 32.2.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 +12 -1
- package/README.md +17 -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 +340 -9
- 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 +56 -1
- package/dist/sdk/authentication-jwt-bearer/flow-srp.d.ts.map +1 -1
- package/dist/sdk/authentication-jwt-bearer/flow-srp.js +119 -1
- package/dist/sdk/authentication-jwt-bearer/flow-srp.js.map +1 -1
- package/dist/sdk/authentication-jwt-bearer/mfa/schemas.d.ts +691 -0
- package/dist/sdk/authentication-jwt-bearer/mfa/schemas.d.ts.map +1 -0
- package/dist/sdk/authentication-jwt-bearer/mfa/schemas.js +265 -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 +363 -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.d.ts +15 -6
- package/dist/sdk/authentication.d.ts.map +1 -1
- package/dist/sdk/authentication.js +24 -0
- package/dist/sdk/authentication.js.map +1 -1
- package/dist/sdk/constants.d.ts +2 -0
- package/dist/sdk/constants.d.ts.map +1 -1
- package/dist/sdk/constants.js +2 -0
- package/dist/sdk/constants.js.map +1 -1
- package/dist/sdk/errors.d.ts +91 -0
- package/dist/sdk/errors.d.ts.map +1 -1
- package/dist/sdk/errors.js +161 -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/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 +4 -1
|
@@ -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
|
};
|
|
@@ -52,7 +57,30 @@ const metadata = {
|
|
|
52
57
|
includeInDebugSnapshot: false,
|
|
53
58
|
usedInUi: true,
|
|
54
59
|
},
|
|
60
|
+
enrolledCredentials: {
|
|
61
|
+
// Allow-list so new credential types cannot leak identifiers by default.
|
|
62
|
+
includeInStateLogs: (credentials) => credentials?.map(({ type, status, enrolledAt }) => ({
|
|
63
|
+
type,
|
|
64
|
+
status,
|
|
65
|
+
...(enrolledAt === undefined ? {} : { enrolledAt }),
|
|
66
|
+
})) ?? null,
|
|
67
|
+
persist: false,
|
|
68
|
+
includeInDebugSnapshot: false,
|
|
69
|
+
usedInUi: true,
|
|
70
|
+
},
|
|
71
|
+
stepUpSessionExpiresAt: {
|
|
72
|
+
includeInStateLogs: false,
|
|
73
|
+
persist: false,
|
|
74
|
+
includeInDebugSnapshot: false,
|
|
75
|
+
usedInUi: true,
|
|
76
|
+
},
|
|
55
77
|
};
|
|
78
|
+
/**
|
|
79
|
+
* Lifetime of an elevated session. Deliberately shorter than the
|
|
80
|
+
* elevated token's own `exp` so a fresh ceremony is required per sensitive
|
|
81
|
+
* action window, per the MFA phase-1 specification.
|
|
82
|
+
*/
|
|
83
|
+
export const STEP_UP_SESSION_TTL_MS = 60_000;
|
|
56
84
|
const MESSENGER_EXPOSED_METHODS = [
|
|
57
85
|
'performSignIn',
|
|
58
86
|
'performSignOut',
|
|
@@ -65,6 +93,13 @@ const MESSENGER_EXPOSED_METHODS = [
|
|
|
65
93
|
'isSignedIn',
|
|
66
94
|
'requestProfilePairing',
|
|
67
95
|
'clearState',
|
|
96
|
+
'refreshEnrolledCredentials',
|
|
97
|
+
'beginCredentialEnrollment',
|
|
98
|
+
'completeCredentialEnrollment',
|
|
99
|
+
'beginStepUp',
|
|
100
|
+
'completeStepUp',
|
|
101
|
+
'getElevatedProfileToken',
|
|
102
|
+
'clearStepUpSession',
|
|
68
103
|
];
|
|
69
104
|
/**
|
|
70
105
|
* Controller that enables authentication for restricted endpoints.
|
|
@@ -73,11 +108,26 @@ const MESSENGER_EXPOSED_METHODS = [
|
|
|
73
108
|
export class AuthenticationController extends BaseController {
|
|
74
109
|
#metametrics;
|
|
75
110
|
#auth;
|
|
111
|
+
#trace;
|
|
76
112
|
#config = {
|
|
77
113
|
env: Env.PRD,
|
|
78
114
|
isSocialPairingEnabled: () => false,
|
|
79
115
|
};
|
|
80
116
|
#isUnlocked = false;
|
|
117
|
+
/**
|
|
118
|
+
* Bumped whenever the authenticated session ends, so an in-flight MFA
|
|
119
|
+
* ceremony started under the previous session cannot apply its result.
|
|
120
|
+
*/
|
|
121
|
+
#authSessionEpoch = 0;
|
|
122
|
+
#stepUpSession = null;
|
|
123
|
+
#stepUpTimer;
|
|
124
|
+
/**
|
|
125
|
+
* Sequence number of the most recently started credentials refresh. Only
|
|
126
|
+
* that refresh may write the cache, so a slower, earlier request cannot
|
|
127
|
+
* overwrite a newer list (e.g. an explicit refresh landing after an
|
|
128
|
+
* enrollment's refresh).
|
|
129
|
+
*/
|
|
130
|
+
#credentialsRefreshSeq = 0;
|
|
81
131
|
/**
|
|
82
132
|
* Bumped by `requestProfilePairing` and `clearState` so an in-flight
|
|
83
133
|
* `performSignIn` can't clear `needsProfilePairing` afterwards.
|
|
@@ -91,11 +141,13 @@ export class AuthenticationController extends BaseController {
|
|
|
91
141
|
this.#isUnlocked = true;
|
|
92
142
|
});
|
|
93
143
|
this.messenger.subscribe('KeyringController:lock', () => {
|
|
144
|
+
this.#authSessionEpoch += 1;
|
|
94
145
|
this.#isUnlocked = false;
|
|
146
|
+
this.clearStepUpSession();
|
|
95
147
|
});
|
|
96
148
|
},
|
|
97
149
|
};
|
|
98
|
-
constructor({ messenger, state, config, metametrics, }) {
|
|
150
|
+
constructor({ messenger, state, config, metametrics, trace, }) {
|
|
99
151
|
super({
|
|
100
152
|
messenger,
|
|
101
153
|
metadata,
|
|
@@ -105,12 +157,14 @@ export class AuthenticationController extends BaseController {
|
|
|
105
157
|
if (!metametrics) {
|
|
106
158
|
throw new Error('`metametrics` field is required');
|
|
107
159
|
}
|
|
160
|
+
// `??` per key so an explicit `undefined` keeps the default rather than
|
|
161
|
+
// clobbering it.
|
|
108
162
|
this.#config = {
|
|
109
|
-
|
|
110
|
-
...config,
|
|
163
|
+
env: config?.env ?? this.#config.env,
|
|
111
164
|
isSocialPairingEnabled: config?.isSocialPairingEnabled ?? this.#config.isSocialPairingEnabled,
|
|
112
165
|
};
|
|
113
166
|
this.#metametrics = metametrics;
|
|
167
|
+
this.#trace = trace ?? ((_request, fn) => fn?.());
|
|
114
168
|
this.#auth = new JwtBearerAuth({
|
|
115
169
|
env: this.#config.env,
|
|
116
170
|
platform: metametrics.agent,
|
|
@@ -160,14 +214,19 @@ export class AuthenticationController extends BaseController {
|
|
|
160
214
|
throw new Error(`${methodName} - unable to proceed, wallet is locked`);
|
|
161
215
|
}
|
|
162
216
|
}
|
|
217
|
+
#assertAuthSessionEpoch(epoch, methodName) {
|
|
218
|
+
if (!this.#isUnlocked || this.#authSessionEpoch !== epoch) {
|
|
219
|
+
throw new Error(`${methodName} - unable to proceed, the authenticated session ended`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
163
222
|
/**
|
|
164
223
|
* Reads the HD keyring entropy source IDs from KeyringController.
|
|
165
224
|
*
|
|
166
225
|
* @returns The HD keyring metadata IDs, primary first.
|
|
167
226
|
*/
|
|
168
227
|
#getHdKeyringEntropySourceIds() {
|
|
169
|
-
const
|
|
170
|
-
return
|
|
228
|
+
const keyringState = this.messenger.call('KeyringController:getState');
|
|
229
|
+
return selectHdKeyringEntropySourceIds(keyringState);
|
|
171
230
|
}
|
|
172
231
|
/**
|
|
173
232
|
* Resolves the primary SRP's entropy source ID from KeyringController rather
|
|
@@ -179,8 +238,8 @@ export class AuthenticationController extends BaseController {
|
|
|
179
238
|
* the wallet is unlocked.
|
|
180
239
|
*/
|
|
181
240
|
#getPrimaryEntropySourceId() {
|
|
182
|
-
const
|
|
183
|
-
return getPrimaryHdKeyringEntropySourceId(
|
|
241
|
+
const keyringState = this.messenger.call('KeyringController:getState');
|
|
242
|
+
return getPrimaryHdKeyringEntropySourceId(keyringState);
|
|
184
243
|
}
|
|
185
244
|
/**
|
|
186
245
|
* Resolves the SRP login tag for `raw_message`.
|
|
@@ -490,7 +549,276 @@ export class AuthenticationController extends BaseController {
|
|
|
490
549
|
return (this.state.srpSessionData?.[primaryEntropySourceId]?.profile
|
|
491
550
|
?.canonicalProfileId ?? null);
|
|
492
551
|
}
|
|
552
|
+
/**
|
|
553
|
+
* Runs one MFA network step inside a trace span tagged with the caller's
|
|
554
|
+
* operation and the credential type, recording the outcome and MFA error
|
|
555
|
+
* code.
|
|
556
|
+
*
|
|
557
|
+
* @param name - Span name.
|
|
558
|
+
* @param operation - Caller-supplied `reason.operation`.
|
|
559
|
+
* @param credentialType - Credential type the step concerns.
|
|
560
|
+
* @param fn - The network step.
|
|
561
|
+
* @returns The step's result.
|
|
562
|
+
*/
|
|
563
|
+
async #runMfaRequest(name, operation, credentialType, fn) {
|
|
564
|
+
return await this.#trace({
|
|
565
|
+
name,
|
|
566
|
+
tags: { operation, credentialType },
|
|
567
|
+
}, async (context) => {
|
|
568
|
+
try {
|
|
569
|
+
const result = await fn();
|
|
570
|
+
this.#setTraceAttribute(context, 'outcome', 'success');
|
|
571
|
+
return result;
|
|
572
|
+
}
|
|
573
|
+
catch (error) {
|
|
574
|
+
this.#setTraceAttribute(context, 'outcome', 'error');
|
|
575
|
+
const mfaCode = getMfaErrorCode(error);
|
|
576
|
+
if (mfaCode) {
|
|
577
|
+
this.#setTraceAttribute(context, 'mfaErrorCode', mfaCode);
|
|
578
|
+
if (mfaCode === 'authentication_required' && this.#isUnlocked) {
|
|
579
|
+
this.#invalidateSrpSession(this.#getPrimaryEntropySourceId());
|
|
580
|
+
// An elevated session must not outlive a rejected base session.
|
|
581
|
+
this.clearStepUpSession();
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
throw error;
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* `TraceContext` is opaque in `@metamask/controller-utils`; both clients
|
|
590
|
+
* hand back a Sentry span, so attributes are set via duck typing and a
|
|
591
|
+
* non-Sentry context is a silent no-op.
|
|
592
|
+
*
|
|
593
|
+
* @param context - Span handed to the trace callback, if any.
|
|
594
|
+
* @param key - Attribute name.
|
|
595
|
+
* @param value - Attribute value.
|
|
596
|
+
*/
|
|
597
|
+
#setTraceAttribute(context, key, value) {
|
|
598
|
+
const traceSpan = context;
|
|
599
|
+
traceSpan?.setAttribute?.(key, value);
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Refreshes credentials enrolled on the canonical profile.
|
|
603
|
+
*
|
|
604
|
+
* @returns The current supported credentials.
|
|
605
|
+
*/
|
|
606
|
+
async refreshEnrolledCredentials() {
|
|
607
|
+
this.#assertIsUnlocked('refreshEnrolledCredentials');
|
|
608
|
+
const sessionEpoch = this.#authSessionEpoch;
|
|
609
|
+
this.#credentialsRefreshSeq += 1;
|
|
610
|
+
const refreshSeq = this.#credentialsRefreshSeq;
|
|
611
|
+
const primaryEntropySourceId = this.#getPrimaryEntropySourceId();
|
|
612
|
+
const credentials = await this.#runMfaRequest('MFA Credentials Refresh', 'credentials.refresh', 'all', async () => await this.#auth.getMfaCredentials(primaryEntropySourceId));
|
|
613
|
+
this.#assertAuthSessionEpoch(sessionEpoch, 'refreshEnrolledCredentials');
|
|
614
|
+
// A newer refresh started while this one was in flight; its result is at
|
|
615
|
+
// least as fresh, so defer to it rather than overwrite with older data.
|
|
616
|
+
if (refreshSeq !== this.#credentialsRefreshSeq) {
|
|
617
|
+
return this.#getEnrolledCredentials();
|
|
618
|
+
}
|
|
619
|
+
// Skip the write when nothing changed so subscribers are not woken up by
|
|
620
|
+
// a fresh-but-identical array.
|
|
621
|
+
if (JSON.stringify(credentials) !==
|
|
622
|
+
JSON.stringify(this.#getEnrolledCredentials())) {
|
|
623
|
+
this.update((state) => {
|
|
624
|
+
state.enrolledCredentials = credentials;
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
return credentials;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Begins enrollment of a passkey or email OTP credential.
|
|
631
|
+
*
|
|
632
|
+
* @param request - Credential, optional email address, and trace reason.
|
|
633
|
+
* @returns A challenge for the client-owned ceremony.
|
|
634
|
+
*/
|
|
635
|
+
async beginCredentialEnrollment(request) {
|
|
636
|
+
this.#assertIsUnlocked('beginCredentialEnrollment');
|
|
637
|
+
const sessionEpoch = this.#authSessionEpoch;
|
|
638
|
+
assertValidMfaRequest(request, BeginEnrollmentRequestStruct);
|
|
639
|
+
const primaryEntropySourceId = this.#getPrimaryEntropySourceId();
|
|
640
|
+
const challenge = await this.#runMfaRequest('MFA Enroll Begin', request.reason.operation, request.type, async () => await this.#auth.beginMfaEnrollment(request.type, {
|
|
641
|
+
email: request.email,
|
|
642
|
+
entropySourceId: primaryEntropySourceId,
|
|
643
|
+
}));
|
|
644
|
+
this.#assertAuthSessionEpoch(sessionEpoch, 'beginCredentialEnrollment');
|
|
645
|
+
return challenge;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Completes credential enrollment and refreshes the credential cache.
|
|
649
|
+
*
|
|
650
|
+
* A cache-refresh failure does not undo successful enrollment. Email
|
|
651
|
+
* enrollment invalidates the primary SRP session *after* refresh so the
|
|
652
|
+
* credentials call can reuse the still-valid access token; the next token
|
|
653
|
+
* fetch then includes the newly verified email claim. That invalidation
|
|
654
|
+
* happens even if the session ends mid-request: the enrollment succeeded
|
|
655
|
+
* on the server, so a token cached across a lock must not be reused
|
|
656
|
+
* without the new claim.
|
|
657
|
+
*
|
|
658
|
+
* @param request - Flow identifier, platform or email proof, and trace reason.
|
|
659
|
+
* @returns The refreshed credentials, or the existing cache if refresh fails.
|
|
660
|
+
*/
|
|
661
|
+
async completeCredentialEnrollment(request) {
|
|
662
|
+
this.#assertIsUnlocked('completeCredentialEnrollment');
|
|
663
|
+
const sessionEpoch = this.#authSessionEpoch;
|
|
664
|
+
assertValidMfaRequest(request, CompleteEnrollmentRequestStruct);
|
|
665
|
+
const { type } = request.proof;
|
|
666
|
+
const primaryEntropySourceId = this.#getPrimaryEntropySourceId();
|
|
667
|
+
await this.#runMfaRequest('MFA Enroll Complete', request.reason.operation, type, async () => await this.#auth.completeMfaEnrollment(request.flowId, request.proof, primaryEntropySourceId));
|
|
668
|
+
try {
|
|
669
|
+
this.#assertAuthSessionEpoch(sessionEpoch, 'completeCredentialEnrollment');
|
|
670
|
+
}
|
|
671
|
+
catch (error) {
|
|
672
|
+
if (type === 'email_otp') {
|
|
673
|
+
this.#invalidateSrpSession(primaryEntropySourceId);
|
|
674
|
+
}
|
|
675
|
+
throw error;
|
|
676
|
+
}
|
|
677
|
+
// The factor set changed: require a fresh ceremony against the new set.
|
|
678
|
+
this.clearStepUpSession();
|
|
679
|
+
try {
|
|
680
|
+
return await this.refreshEnrolledCredentials();
|
|
681
|
+
}
|
|
682
|
+
catch {
|
|
683
|
+
return this.#getEnrolledCredentials();
|
|
684
|
+
}
|
|
685
|
+
finally {
|
|
686
|
+
if (type === 'email_otp') {
|
|
687
|
+
this.#invalidateSrpSession(primaryEntropySourceId);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Begins step-up verification with an enrolled credential.
|
|
693
|
+
*
|
|
694
|
+
* @param request - Credential type and trace reason.
|
|
695
|
+
* @returns A challenge for the client-owned ceremony.
|
|
696
|
+
*/
|
|
697
|
+
async beginStepUp(request) {
|
|
698
|
+
this.#assertIsUnlocked('beginStepUp');
|
|
699
|
+
const sessionEpoch = this.#authSessionEpoch;
|
|
700
|
+
assertValidMfaRequest(request, BeginStepUpRequestStruct);
|
|
701
|
+
const primaryEntropySourceId = this.#getPrimaryEntropySourceId();
|
|
702
|
+
const challenge = await this.#runMfaRequest('MFA Step-Up Begin', request.reason.operation, request.type, async () => await this.#auth.beginMfaVerification(request.type, primaryEntropySourceId));
|
|
703
|
+
this.#assertAuthSessionEpoch(sessionEpoch, 'beginStepUp');
|
|
704
|
+
return challenge;
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Completes step-up verification and opens a short-lived elevated session.
|
|
708
|
+
*
|
|
709
|
+
* The AAL2 assertion returned by the MFA service is exchanged at Hydra for
|
|
710
|
+
* an elevated access token, whose claims are checked before the session
|
|
711
|
+
* opens. The token itself never enters controller state.
|
|
712
|
+
*
|
|
713
|
+
* @param request - Flow identifier, platform or email proof, and trace reason.
|
|
714
|
+
* @returns The elevated profile access token.
|
|
715
|
+
*/
|
|
716
|
+
async completeStepUp(request) {
|
|
717
|
+
this.#assertIsUnlocked('completeStepUp');
|
|
718
|
+
const sessionEpoch = this.#authSessionEpoch;
|
|
719
|
+
assertValidMfaRequest(request, CompleteStepUpRequestStruct);
|
|
720
|
+
const { type } = request.proof;
|
|
721
|
+
const { operation } = request.reason;
|
|
722
|
+
const primaryEntropySourceId = this.#getPrimaryEntropySourceId();
|
|
723
|
+
const assertion = await this.#runMfaRequest('MFA Step-Up Complete', operation, type, async () => await this.#auth.completeMfaVerification(request.flowId, request.proof, primaryEntropySourceId));
|
|
724
|
+
this.#assertAuthSessionEpoch(sessionEpoch, 'completeStepUp');
|
|
725
|
+
const accessToken = await this.#runMfaRequest('MFA Token Exchange', operation, type, async () => await this.#auth.exchangeMfaAssertion(assertion.token));
|
|
726
|
+
this.#assertAuthSessionEpoch(sessionEpoch, 'completeStepUp');
|
|
727
|
+
let decodedClaims;
|
|
728
|
+
try {
|
|
729
|
+
decodedClaims = decodeJwtPayload(accessToken.accessToken);
|
|
730
|
+
}
|
|
731
|
+
catch (error) {
|
|
732
|
+
throw new ElevatedTokenInvalidError(toErrorMessage(error));
|
|
733
|
+
}
|
|
734
|
+
const claims = parseElevatedTokenClaims(decodedClaims);
|
|
735
|
+
if (claims.exp * 1000 <= Date.now()) {
|
|
736
|
+
throw new ElevatedTokenInvalidError('Elevated token is expired');
|
|
737
|
+
}
|
|
738
|
+
const token = { ...accessToken, claims };
|
|
739
|
+
this.#openStepUpSession(token);
|
|
740
|
+
return token;
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Returns the active elevated token when it meets the requested freshness.
|
|
744
|
+
*
|
|
745
|
+
* @param request - Optional maximum session age in milliseconds, measured
|
|
746
|
+
* from when the token was obtained. Zero always requires a new ceremony.
|
|
747
|
+
* @returns A live elevated token, or null when no reusable session exists.
|
|
748
|
+
*/
|
|
749
|
+
getElevatedProfileToken(request = {}) {
|
|
750
|
+
this.#assertIsUnlocked('getElevatedProfileToken');
|
|
751
|
+
assertValidMfaRequest(request, GetElevatedTokenRequestStruct);
|
|
752
|
+
const session = this.#stepUpSession;
|
|
753
|
+
if (!session) {
|
|
754
|
+
return null;
|
|
755
|
+
}
|
|
756
|
+
const now = Date.now();
|
|
757
|
+
if (now >= session.expiresAt) {
|
|
758
|
+
// The hard-expiry timer has not fired yet (e.g. a suspended tab).
|
|
759
|
+
this.clearStepUpSession();
|
|
760
|
+
return null;
|
|
761
|
+
}
|
|
762
|
+
// `>=` so a zero max age always forces a fresh ceremony. Not cleared: the
|
|
763
|
+
// max age is this caller's requirement; others may still use the session.
|
|
764
|
+
const maxAge = request.maxSessionAgeMs;
|
|
765
|
+
if (maxAge !== undefined && now - session.token.obtainedAt >= maxAge) {
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
768
|
+
return session.token;
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Opens the elevated session. Its lifetime is the session TTL clamped to
|
|
772
|
+
* the token's own `exp`, so the session never outlives the token.
|
|
773
|
+
*
|
|
774
|
+
* @param token - The freshly exchanged elevated token.
|
|
775
|
+
*/
|
|
776
|
+
#openStepUpSession(token) {
|
|
777
|
+
this.clearStepUpSession();
|
|
778
|
+
const expiresAt = Math.min(token.obtainedAt + STEP_UP_SESSION_TTL_MS, token.claims.exp * 1000);
|
|
779
|
+
this.#stepUpSession = { token, expiresAt };
|
|
780
|
+
this.update((state) => {
|
|
781
|
+
state.stepUpSessionExpiresAt = expiresAt;
|
|
782
|
+
});
|
|
783
|
+
this.#stepUpTimer = setTimeout(() => this.clearStepUpSession(), Math.max(0, expiresAt - Date.now()));
|
|
784
|
+
// Never keep a Node process alive for the expiry timer (tests, tooling).
|
|
785
|
+
this.#stepUpTimer.unref?.();
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Clears the in-memory elevated session and its expiration timer.
|
|
789
|
+
*/
|
|
790
|
+
clearStepUpSession() {
|
|
791
|
+
if (this.#stepUpTimer !== undefined) {
|
|
792
|
+
clearTimeout(this.#stepUpTimer);
|
|
793
|
+
this.#stepUpTimer = undefined;
|
|
794
|
+
}
|
|
795
|
+
this.#stepUpSession = null;
|
|
796
|
+
if (this.state.stepUpSessionExpiresAt !== undefined) {
|
|
797
|
+
this.update((state) => {
|
|
798
|
+
state.stepUpSessionExpiresAt = undefined;
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Drops the cached credential list. Callers that wipe profile state must go
|
|
804
|
+
* through this so subscribers never keep credentials belonging to a profile
|
|
805
|
+
* that is no longer active.
|
|
806
|
+
*/
|
|
807
|
+
#clearEnrolledCredentials() {
|
|
808
|
+
if (this.#getEnrolledCredentials().length === 0) {
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
this.update((state) => {
|
|
812
|
+
state.enrolledCredentials = [];
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
#getEnrolledCredentials() {
|
|
816
|
+
return this.state.enrolledCredentials ?? [];
|
|
817
|
+
}
|
|
493
818
|
performSignOut() {
|
|
819
|
+
this.#authSessionEpoch += 1;
|
|
820
|
+
this.clearStepUpSession();
|
|
821
|
+
this.#clearEnrolledCredentials();
|
|
494
822
|
this.update((state) => {
|
|
495
823
|
state.isSignedIn = false;
|
|
496
824
|
state.srpSessionData = undefined;
|
|
@@ -502,6 +830,9 @@ export class AuthenticationController extends BaseController {
|
|
|
502
830
|
*/
|
|
503
831
|
clearState() {
|
|
504
832
|
this.#profilePairingRequestEpoch += 1;
|
|
833
|
+
this.#authSessionEpoch += 1;
|
|
834
|
+
this.clearStepUpSession();
|
|
835
|
+
this.#clearEnrolledCredentials();
|
|
505
836
|
this.update(() => ({ ...defaultState }));
|
|
506
837
|
}
|
|
507
838
|
/**
|