@metamask/profile-sync-controller 32.3.1 → 33.0.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 +23 -2
- package/README.md +19 -11
- package/dist/controllers/authentication/AuthenticationController-method-action-types.d.ts +25 -22
- 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 +30 -23
- package/dist/controllers/authentication/AuthenticationController.d.ts.map +1 -1
- package/dist/controllers/authentication/AuthenticationController.js +102 -77
- 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/sdk/authentication-jwt-bearer/flow-srp.d.ts +13 -11
- package/dist/sdk/authentication-jwt-bearer/flow-srp.d.ts.map +1 -1
- package/dist/sdk/authentication-jwt-bearer/flow-srp.js +9 -7
- package/dist/sdk/authentication-jwt-bearer/flow-srp.js.map +1 -1
- package/dist/sdk/authentication-jwt-bearer/mfa/schemas.d.ts +13 -14
- package/dist/sdk/authentication-jwt-bearer/mfa/schemas.d.ts.map +1 -1
- package/dist/sdk/authentication-jwt-bearer/mfa/schemas.js +19 -17
- package/dist/sdk/authentication-jwt-bearer/mfa/schemas.js.map +1 -1
- package/dist/sdk/authentication-jwt-bearer/mfa/services.d.ts +6 -6
- package/dist/sdk/authentication-jwt-bearer/mfa/services.d.ts.map +1 -1
- package/dist/sdk/authentication-jwt-bearer/mfa/services.js +8 -6
- package/dist/sdk/authentication-jwt-bearer/mfa/services.js.map +1 -1
- package/dist/sdk/authentication-jwt-bearer/mfa/types.d.ts +25 -11
- package/dist/sdk/authentication-jwt-bearer/mfa/types.d.ts.map +1 -1
- package/dist/sdk/authentication-jwt-bearer/mfa/types.js.map +1 -1
- package/dist/sdk/authentication.d.ts +4 -4
- package/dist/sdk/authentication.d.ts.map +1 -1
- package/dist/sdk/authentication.js.map +1 -1
- package/dist/sdk/errors.d.ts +4 -4
- package/dist/sdk/errors.d.ts.map +1 -1
- package/dist/sdk/errors.js +6 -6
- package/dist/sdk/errors.js.map +1 -1
- package/dist/sdk/index.d.ts +2 -2
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js.map +1 -1
- package/dist/sdk/mocks/auth.d.ts +2 -2
- package/dist/sdk/mocks/auth.d.ts.map +1 -1
- package/dist/sdk/mocks/auth.js +3 -3
- package/dist/sdk/mocks/auth.js.map +1 -1
- package/package.json +3 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { BaseController } from '@metamask/base-controller';
|
|
2
2
|
import { selectHdKeyringEntropySourceIds } from '@metamask/keyring-controller';
|
|
3
|
-
import { BeginEnrollmentRequestStruct, CompleteEnrollmentRequestStruct, assertValidMfaRequest,
|
|
4
|
-
import { assertMessageStartsWithMetamask, AuthType, Env, JwtBearerAuth, PairConflictError, getMfaErrorCode,
|
|
3
|
+
import { BeginEnrollmentRequestStruct, CompleteEnrollmentRequestStruct, assertValidMfaRequest, BeginVerificationRequestStruct, CompleteVerificationRequestStruct, GetVerificationTokenRequestStruct, parseVerificationTokenClaims, } from '../../sdk/authentication-jwt-bearer/mfa/schemas.js';
|
|
4
|
+
import { assertMessageStartsWithMetamask, AuthType, Env, JwtBearerAuth, PairConflictError, getMfaErrorCode, VerificationTokenInvalidError, } from '../../sdk/index.js';
|
|
5
5
|
import { decodeJwtPayload } from '../../sdk/utils/jwt.js';
|
|
6
6
|
import { toErrorMessage } from '../../sdk/utils/to-error-message.js';
|
|
7
7
|
import { getPrimaryHdKeyringEntropySourceId } from '../../shared/utils/entropy-source.js';
|
|
@@ -70,19 +70,27 @@ const metadata = {
|
|
|
70
70
|
includeInDebugSnapshot: false,
|
|
71
71
|
usedInUi: true,
|
|
72
72
|
},
|
|
73
|
-
stepUpSessionExpiresAt: {
|
|
74
|
-
includeInStateLogs: false,
|
|
75
|
-
persist: false,
|
|
76
|
-
includeInDebugSnapshot: false,
|
|
77
|
-
usedInUi: true,
|
|
78
|
-
},
|
|
79
73
|
};
|
|
80
74
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
75
|
+
* Upper bound on a verification session's lifetime. The session also ends at
|
|
76
|
+
* the verification token's own `exp` (15 minutes today), whichever comes
|
|
77
|
+
* first, so this only matters if the server ever issues longer-lived tokens.
|
|
78
|
+
* Callers needing a fresher proof pass `maxSessionAgeMs` to
|
|
79
|
+
* `getVerificationToken`.
|
|
84
80
|
*/
|
|
85
|
-
export const
|
|
81
|
+
export const VERIFICATION_SESSION_TTL_MS = 15 * 60_000;
|
|
82
|
+
/**
|
|
83
|
+
* Default maximum age of a verification session that may authorize enrolling a
|
|
84
|
+
* credential. A session opened for an unrelated operation must not be able to
|
|
85
|
+
* add a factor long after the fact, while a session proven moments ago (or
|
|
86
|
+
* during the same setup flow, via `maxSessionAgeMs`) may.
|
|
87
|
+
*/
|
|
88
|
+
export const ENROLLMENT_MAX_SESSION_AGE_MS = 2 * 60_000;
|
|
89
|
+
/**
|
|
90
|
+
* Key of the only verification session held today. Sessions are keyed by token
|
|
91
|
+
* audience so audience-scoped tokens can be added without reshaping storage.
|
|
92
|
+
*/
|
|
93
|
+
const DEFAULT_AUDIENCE = '';
|
|
86
94
|
const MESSENGER_EXPOSED_METHODS = [
|
|
87
95
|
'performSignIn',
|
|
88
96
|
'performSignOut',
|
|
@@ -98,10 +106,10 @@ const MESSENGER_EXPOSED_METHODS = [
|
|
|
98
106
|
'refreshEnrolledCredentials',
|
|
99
107
|
'beginCredentialEnrollment',
|
|
100
108
|
'completeCredentialEnrollment',
|
|
101
|
-
'
|
|
102
|
-
'
|
|
103
|
-
'
|
|
104
|
-
'
|
|
109
|
+
'beginCredentialVerification',
|
|
110
|
+
'completeCredentialVerification',
|
|
111
|
+
'getVerificationToken',
|
|
112
|
+
'clearVerificationSession',
|
|
105
113
|
];
|
|
106
114
|
/**
|
|
107
115
|
* Controller that enables authentication for restricted endpoints.
|
|
@@ -121,8 +129,12 @@ export class AuthenticationController extends BaseController {
|
|
|
121
129
|
* ceremony started under the previous session cannot apply its result.
|
|
122
130
|
*/
|
|
123
131
|
#authSessionEpoch = 0;
|
|
124
|
-
|
|
125
|
-
|
|
132
|
+
/**
|
|
133
|
+
* Verification sessions keyed by token audience. At most one session per
|
|
134
|
+
* audience; only `DEFAULT_AUDIENCE` is used until audience-scoped tokens
|
|
135
|
+
* exist.
|
|
136
|
+
*/
|
|
137
|
+
#verificationSessions = new Map();
|
|
126
138
|
/**
|
|
127
139
|
* Sequence number of the most recently started credentials refresh. Only
|
|
128
140
|
* that refresh may write the cache, so a slower, earlier request cannot
|
|
@@ -145,7 +157,7 @@ export class AuthenticationController extends BaseController {
|
|
|
145
157
|
this.messenger.subscribe('KeyringController:lock', () => {
|
|
146
158
|
this.#authSessionEpoch += 1;
|
|
147
159
|
this.#isUnlocked = false;
|
|
148
|
-
this.
|
|
160
|
+
this.clearVerificationSession();
|
|
149
161
|
});
|
|
150
162
|
},
|
|
151
163
|
};
|
|
@@ -604,8 +616,9 @@ export class AuthenticationController extends BaseController {
|
|
|
604
616
|
this.#setTraceAttribute(context, 'mfaErrorCode', mfaCode);
|
|
605
617
|
if (mfaCode === 'authentication_required' && this.#isUnlocked) {
|
|
606
618
|
this.#invalidateSrpSession(this.#getPrimaryEntropySourceId());
|
|
607
|
-
//
|
|
608
|
-
|
|
619
|
+
// A verification session must not outlive a rejected base
|
|
620
|
+
// session.
|
|
621
|
+
this.clearVerificationSession();
|
|
609
622
|
}
|
|
610
623
|
}
|
|
611
624
|
throw error;
|
|
@@ -664,9 +677,13 @@ export class AuthenticationController extends BaseController {
|
|
|
664
677
|
const sessionEpoch = this.#authSessionEpoch;
|
|
665
678
|
assertValidMfaRequest(request, BeginEnrollmentRequestStruct);
|
|
666
679
|
const primaryEntropySourceId = this.#getPrimaryEntropySourceId();
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
|
|
680
|
+
// The server requires AAL2 to add a credential once one that proves AAL2
|
|
681
|
+
// exists: send the verification token only while a recent enough session
|
|
682
|
+
// is live and let the server decide. An older session stays open for
|
|
683
|
+
// other consumers.
|
|
684
|
+
const accessToken = this.getVerificationToken({
|
|
685
|
+
maxSessionAgeMs: request.maxSessionAgeMs ?? ENROLLMENT_MAX_SESSION_AGE_MS,
|
|
686
|
+
})?.accessToken;
|
|
670
687
|
const challenge = await this.#runMfaRequest('MFA Enroll Begin', request.reason.operation, request.type, async () => await this.#auth.beginMfaEnrollment(request.type, {
|
|
671
688
|
email: request.email,
|
|
672
689
|
entropySourceId: primaryEntropySourceId,
|
|
@@ -705,8 +722,9 @@ export class AuthenticationController extends BaseController {
|
|
|
705
722
|
}
|
|
706
723
|
throw error;
|
|
707
724
|
}
|
|
708
|
-
// The
|
|
709
|
-
|
|
725
|
+
// The verification session is deliberately kept: adding a factor does not
|
|
726
|
+
// weaken an earlier proof, and `beginCredentialEnrollment` already limits
|
|
727
|
+
// which sessions may add the next one.
|
|
710
728
|
try {
|
|
711
729
|
return await this.refreshEnrolledCredentials();
|
|
712
730
|
}
|
|
@@ -720,74 +738,77 @@ export class AuthenticationController extends BaseController {
|
|
|
720
738
|
}
|
|
721
739
|
}
|
|
722
740
|
/**
|
|
723
|
-
* Begins
|
|
741
|
+
* Begins verification with an enrolled credential.
|
|
724
742
|
*
|
|
725
743
|
* @param request - Credential type and trace reason.
|
|
726
744
|
* @returns A challenge for the client-owned ceremony.
|
|
727
745
|
*/
|
|
728
|
-
async
|
|
729
|
-
this.#assertIsUnlocked('
|
|
746
|
+
async beginCredentialVerification(request) {
|
|
747
|
+
this.#assertIsUnlocked('beginCredentialVerification');
|
|
730
748
|
const sessionEpoch = this.#authSessionEpoch;
|
|
731
|
-
assertValidMfaRequest(request,
|
|
749
|
+
assertValidMfaRequest(request, BeginVerificationRequestStruct);
|
|
732
750
|
const primaryEntropySourceId = this.#getPrimaryEntropySourceId();
|
|
733
|
-
const challenge = await this.#runMfaRequest('MFA
|
|
734
|
-
this.#assertAuthSessionEpoch(sessionEpoch, '
|
|
751
|
+
const challenge = await this.#runMfaRequest('MFA Verification Begin', request.reason.operation, request.type, async () => await this.#auth.beginMfaVerification(request.type, primaryEntropySourceId));
|
|
752
|
+
this.#assertAuthSessionEpoch(sessionEpoch, 'beginCredentialVerification');
|
|
735
753
|
return challenge;
|
|
736
754
|
}
|
|
737
755
|
/**
|
|
738
|
-
* Completes
|
|
756
|
+
* Completes verification and opens a short-lived verification session.
|
|
739
757
|
*
|
|
740
|
-
* The
|
|
741
|
-
*
|
|
742
|
-
*
|
|
758
|
+
* The assertion returned by the MFA service is exchanged at Hydra for an
|
|
759
|
+
* access token. Its assurance level is not checked: the services receiving
|
|
760
|
+
* the token enforce their own requirements. The token itself never enters
|
|
761
|
+
* controller state.
|
|
743
762
|
*
|
|
744
763
|
* @param request - Flow identifier, platform or email proof, and trace reason.
|
|
745
|
-
* @returns The
|
|
764
|
+
* @returns The verification token.
|
|
746
765
|
*/
|
|
747
|
-
async
|
|
748
|
-
this.#assertIsUnlocked('
|
|
766
|
+
async completeCredentialVerification(request) {
|
|
767
|
+
this.#assertIsUnlocked('completeCredentialVerification');
|
|
749
768
|
const sessionEpoch = this.#authSessionEpoch;
|
|
750
|
-
assertValidMfaRequest(request,
|
|
769
|
+
assertValidMfaRequest(request, CompleteVerificationRequestStruct);
|
|
751
770
|
const { type } = request.proof;
|
|
752
771
|
const { operation } = request.reason;
|
|
753
772
|
const primaryEntropySourceId = this.#getPrimaryEntropySourceId();
|
|
754
|
-
const assertion = await this.#runMfaRequest('MFA
|
|
755
|
-
this.#assertAuthSessionEpoch(sessionEpoch, '
|
|
773
|
+
const assertion = await this.#runMfaRequest('MFA Verification Complete', operation, type, async () => await this.#auth.completeMfaVerification(request.flowId, request.proof, primaryEntropySourceId));
|
|
774
|
+
this.#assertAuthSessionEpoch(sessionEpoch, 'completeCredentialVerification');
|
|
756
775
|
const accessToken = await this.#runMfaRequest('MFA Token Exchange', operation, type, async () => await this.#auth.exchangeMfaAssertion(assertion.token));
|
|
757
|
-
this.#assertAuthSessionEpoch(sessionEpoch, '
|
|
776
|
+
this.#assertAuthSessionEpoch(sessionEpoch, 'completeCredentialVerification');
|
|
758
777
|
let decodedClaims;
|
|
759
778
|
try {
|
|
760
779
|
decodedClaims = decodeJwtPayload(accessToken.accessToken);
|
|
761
780
|
}
|
|
762
781
|
catch (error) {
|
|
763
|
-
throw new
|
|
782
|
+
throw new VerificationTokenInvalidError(toErrorMessage(error));
|
|
764
783
|
}
|
|
765
|
-
const claims =
|
|
784
|
+
const claims = parseVerificationTokenClaims(decodedClaims);
|
|
766
785
|
if (claims.exp * 1000 <= Date.now()) {
|
|
767
|
-
throw new
|
|
786
|
+
throw new VerificationTokenInvalidError('Verification token is expired');
|
|
768
787
|
}
|
|
769
788
|
const token = { ...accessToken, claims };
|
|
770
|
-
this.#
|
|
789
|
+
this.#openVerificationSession(token);
|
|
771
790
|
return token;
|
|
772
791
|
}
|
|
773
792
|
/**
|
|
774
|
-
* Returns the active
|
|
793
|
+
* Returns the active verification token when it meets the requested
|
|
794
|
+
* freshness.
|
|
775
795
|
*
|
|
776
796
|
* @param request - Optional maximum session age in milliseconds, measured
|
|
777
797
|
* from when the token was obtained. Zero always requires a new ceremony.
|
|
778
|
-
* @returns A live
|
|
798
|
+
* @returns A live verification token, or null when no reusable session
|
|
799
|
+
* exists.
|
|
779
800
|
*/
|
|
780
|
-
|
|
781
|
-
this.#assertIsUnlocked('
|
|
782
|
-
assertValidMfaRequest(request,
|
|
783
|
-
const session = this.#
|
|
801
|
+
getVerificationToken(request = {}) {
|
|
802
|
+
this.#assertIsUnlocked('getVerificationToken');
|
|
803
|
+
assertValidMfaRequest(request, GetVerificationTokenRequestStruct);
|
|
804
|
+
const session = this.#verificationSessions.get(DEFAULT_AUDIENCE);
|
|
784
805
|
if (!session) {
|
|
785
806
|
return null;
|
|
786
807
|
}
|
|
787
808
|
const now = Date.now();
|
|
788
809
|
if (now >= session.expiresAt) {
|
|
789
810
|
// The hard-expiry timer has not fired yet (e.g. a suspended tab).
|
|
790
|
-
this
|
|
811
|
+
this.#dropVerificationSession(DEFAULT_AUDIENCE);
|
|
791
812
|
return null;
|
|
792
813
|
}
|
|
793
814
|
// `>=` so a zero max age always forces a fresh ceremony. Not cleared: the
|
|
@@ -799,36 +820,40 @@ export class AuthenticationController extends BaseController {
|
|
|
799
820
|
return session.token;
|
|
800
821
|
}
|
|
801
822
|
/**
|
|
802
|
-
* Opens the
|
|
823
|
+
* Opens the verification session. Its lifetime is the session TTL clamped to
|
|
803
824
|
* the token's own `exp`, so the session never outlives the token.
|
|
804
825
|
*
|
|
805
|
-
* @param token - The freshly exchanged
|
|
826
|
+
* @param token - The freshly exchanged verification token.
|
|
806
827
|
*/
|
|
807
|
-
#
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
state.stepUpSessionExpiresAt = expiresAt;
|
|
813
|
-
});
|
|
814
|
-
this.#stepUpTimer = setTimeout(() => this.clearStepUpSession(), Math.max(0, expiresAt - Date.now()));
|
|
828
|
+
#openVerificationSession(token) {
|
|
829
|
+
const audience = DEFAULT_AUDIENCE;
|
|
830
|
+
this.#dropVerificationSession(audience);
|
|
831
|
+
const expiresAt = Math.min(token.obtainedAt + VERIFICATION_SESSION_TTL_MS, token.claims.exp * 1000);
|
|
832
|
+
const timer = setTimeout(() => this.#dropVerificationSession(audience), Math.max(0, expiresAt - Date.now()));
|
|
815
833
|
// Never keep a Node process alive for the expiry timer (tests, tooling).
|
|
816
|
-
|
|
834
|
+
timer.unref?.();
|
|
835
|
+
this.#verificationSessions.set(audience, { token, expiresAt, timer });
|
|
817
836
|
}
|
|
818
837
|
/**
|
|
819
|
-
*
|
|
838
|
+
* Drops one audience's verification session and its expiration timer.
|
|
839
|
+
*
|
|
840
|
+
* @param audience - Audience whose session to drop.
|
|
820
841
|
*/
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
842
|
+
#dropVerificationSession(audience) {
|
|
843
|
+
const session = this.#verificationSessions.get(audience);
|
|
844
|
+
if (session) {
|
|
845
|
+
clearTimeout(session.timer);
|
|
846
|
+
this.#verificationSessions.delete(audience);
|
|
825
847
|
}
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Clears every in-memory verification session and its expiration timer.
|
|
851
|
+
*/
|
|
852
|
+
clearVerificationSession() {
|
|
853
|
+
for (const { timer } of this.#verificationSessions.values()) {
|
|
854
|
+
clearTimeout(timer);
|
|
831
855
|
}
|
|
856
|
+
this.#verificationSessions.clear();
|
|
832
857
|
}
|
|
833
858
|
/**
|
|
834
859
|
* Drops the cached credential list. Callers that wipe profile state must go
|
|
@@ -848,7 +873,7 @@ export class AuthenticationController extends BaseController {
|
|
|
848
873
|
}
|
|
849
874
|
performSignOut() {
|
|
850
875
|
this.#authSessionEpoch += 1;
|
|
851
|
-
this.
|
|
876
|
+
this.clearVerificationSession();
|
|
852
877
|
this.#clearEnrolledCredentials();
|
|
853
878
|
this.update((state) => {
|
|
854
879
|
state.isSignedIn = false;
|
|
@@ -862,7 +887,7 @@ export class AuthenticationController extends BaseController {
|
|
|
862
887
|
clearState() {
|
|
863
888
|
this.#profilePairingRequestEpoch += 1;
|
|
864
889
|
this.#authSessionEpoch += 1;
|
|
865
|
-
this.
|
|
890
|
+
this.clearVerificationSession();
|
|
866
891
|
this.#clearEnrolledCredentials();
|
|
867
892
|
this.update(() => ({ ...defaultState }));
|
|
868
893
|
}
|