@metamask/profile-sync-controller 32.3.0 → 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.
Files changed (42) hide show
  1. package/CHANGELOG.md +30 -2
  2. package/README.md +19 -11
  3. package/dist/controllers/authentication/AuthenticationController-method-action-types.d.ts +25 -22
  4. package/dist/controllers/authentication/AuthenticationController-method-action-types.d.ts.map +1 -1
  5. package/dist/controllers/authentication/AuthenticationController-method-action-types.js.map +1 -1
  6. package/dist/controllers/authentication/AuthenticationController.d.ts +30 -23
  7. package/dist/controllers/authentication/AuthenticationController.d.ts.map +1 -1
  8. package/dist/controllers/authentication/AuthenticationController.js +102 -77
  9. package/dist/controllers/authentication/AuthenticationController.js.map +1 -1
  10. package/dist/controllers/authentication/index.d.ts +1 -1
  11. package/dist/controllers/authentication/index.d.ts.map +1 -1
  12. package/dist/controllers/authentication/index.js.map +1 -1
  13. package/dist/sdk/authentication-jwt-bearer/flow-srp.d.ts +13 -11
  14. package/dist/sdk/authentication-jwt-bearer/flow-srp.d.ts.map +1 -1
  15. package/dist/sdk/authentication-jwt-bearer/flow-srp.js +9 -7
  16. package/dist/sdk/authentication-jwt-bearer/flow-srp.js.map +1 -1
  17. package/dist/sdk/authentication-jwt-bearer/mfa/schemas.d.ts +13 -14
  18. package/dist/sdk/authentication-jwt-bearer/mfa/schemas.d.ts.map +1 -1
  19. package/dist/sdk/authentication-jwt-bearer/mfa/schemas.js +19 -17
  20. package/dist/sdk/authentication-jwt-bearer/mfa/schemas.js.map +1 -1
  21. package/dist/sdk/authentication-jwt-bearer/mfa/services.d.ts +6 -6
  22. package/dist/sdk/authentication-jwt-bearer/mfa/services.d.ts.map +1 -1
  23. package/dist/sdk/authentication-jwt-bearer/mfa/services.js +8 -6
  24. package/dist/sdk/authentication-jwt-bearer/mfa/services.js.map +1 -1
  25. package/dist/sdk/authentication-jwt-bearer/mfa/types.d.ts +25 -11
  26. package/dist/sdk/authentication-jwt-bearer/mfa/types.d.ts.map +1 -1
  27. package/dist/sdk/authentication-jwt-bearer/mfa/types.js.map +1 -1
  28. package/dist/sdk/authentication.d.ts +4 -4
  29. package/dist/sdk/authentication.d.ts.map +1 -1
  30. package/dist/sdk/authentication.js.map +1 -1
  31. package/dist/sdk/errors.d.ts +4 -4
  32. package/dist/sdk/errors.d.ts.map +1 -1
  33. package/dist/sdk/errors.js +6 -6
  34. package/dist/sdk/errors.js.map +1 -1
  35. package/dist/sdk/index.d.ts +2 -2
  36. package/dist/sdk/index.d.ts.map +1 -1
  37. package/dist/sdk/index.js.map +1 -1
  38. package/dist/sdk/mocks/auth.d.ts +2 -2
  39. package/dist/sdk/mocks/auth.d.ts.map +1 -1
  40. package/dist/sdk/mocks/auth.js +3 -3
  41. package/dist/sdk/mocks/auth.js.map +1 -1
  42. package/package.json +4 -4
@@ -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, 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';
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
- * 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.
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 STEP_UP_SESSION_TTL_MS = 60_000;
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
- 'beginStepUp',
102
- 'completeStepUp',
103
- 'getElevatedProfileToken',
104
- 'clearStepUpSession',
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
- #stepUpSession = null;
125
- #stepUpTimer;
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.clearStepUpSession();
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
- // An elevated session must not outlive a rejected base session.
608
- this.clearStepUpSession();
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
- // 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;
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 factor set changed: require a fresh ceremony against the new set.
709
- this.clearStepUpSession();
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 step-up verification with an enrolled credential.
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 beginStepUp(request) {
729
- this.#assertIsUnlocked('beginStepUp');
746
+ async beginCredentialVerification(request) {
747
+ this.#assertIsUnlocked('beginCredentialVerification');
730
748
  const sessionEpoch = this.#authSessionEpoch;
731
- assertValidMfaRequest(request, BeginStepUpRequestStruct);
749
+ assertValidMfaRequest(request, BeginVerificationRequestStruct);
732
750
  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');
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 step-up verification and opens a short-lived elevated session.
756
+ * Completes verification and opens a short-lived verification session.
739
757
  *
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.
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 elevated profile access token.
764
+ * @returns The verification token.
746
765
  */
747
- async completeStepUp(request) {
748
- this.#assertIsUnlocked('completeStepUp');
766
+ async completeCredentialVerification(request) {
767
+ this.#assertIsUnlocked('completeCredentialVerification');
749
768
  const sessionEpoch = this.#authSessionEpoch;
750
- assertValidMfaRequest(request, CompleteStepUpRequestStruct);
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 Step-Up Complete', operation, type, async () => await this.#auth.completeMfaVerification(request.flowId, request.proof, primaryEntropySourceId));
755
- this.#assertAuthSessionEpoch(sessionEpoch, 'completeStepUp');
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, 'completeStepUp');
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 ElevatedTokenInvalidError(toErrorMessage(error));
782
+ throw new VerificationTokenInvalidError(toErrorMessage(error));
764
783
  }
765
- const claims = parseElevatedTokenClaims(decodedClaims);
784
+ const claims = parseVerificationTokenClaims(decodedClaims);
766
785
  if (claims.exp * 1000 <= Date.now()) {
767
- throw new ElevatedTokenInvalidError('Elevated token is expired');
786
+ throw new VerificationTokenInvalidError('Verification token is expired');
768
787
  }
769
788
  const token = { ...accessToken, claims };
770
- this.#openStepUpSession(token);
789
+ this.#openVerificationSession(token);
771
790
  return token;
772
791
  }
773
792
  /**
774
- * Returns the active elevated token when it meets the requested freshness.
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 elevated token, or null when no reusable session exists.
798
+ * @returns A live verification token, or null when no reusable session
799
+ * exists.
779
800
  */
780
- getElevatedProfileToken(request = {}) {
781
- this.#assertIsUnlocked('getElevatedProfileToken');
782
- assertValidMfaRequest(request, GetElevatedTokenRequestStruct);
783
- const session = this.#stepUpSession;
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.clearStepUpSession();
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 elevated session. Its lifetime is the session TTL clamped to
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 elevated token.
826
+ * @param token - The freshly exchanged verification token.
806
827
  */
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()));
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
- this.#stepUpTimer.unref?.();
834
+ timer.unref?.();
835
+ this.#verificationSessions.set(audience, { token, expiresAt, timer });
817
836
  }
818
837
  /**
819
- * Clears the in-memory elevated session and its expiration timer.
838
+ * Drops one audience's verification session and its expiration timer.
839
+ *
840
+ * @param audience - Audience whose session to drop.
820
841
  */
821
- clearStepUpSession() {
822
- if (this.#stepUpTimer !== undefined) {
823
- clearTimeout(this.#stepUpTimer);
824
- this.#stepUpTimer = undefined;
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
- this.#stepUpSession = null;
827
- if (this.state.stepUpSessionExpiresAt !== undefined) {
828
- this.update((state) => {
829
- state.stepUpSessionExpiresAt = undefined;
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.clearStepUpSession();
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.clearStepUpSession();
890
+ this.clearVerificationSession();
866
891
  this.#clearEnrolledCredentials();
867
892
  this.update(() => ({ ...defaultState }));
868
893
  }