@metamask-previews/profile-sync-controller 32.3.1-preview-1efee3183 → 32.3.1-preview-33429d645

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 +18 -1
  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 +33 -21
  7. package/dist/controllers/authentication/AuthenticationController.d.ts.map +1 -1
  8. package/dist/controllers/authentication/AuthenticationController.js +115 -69
  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 +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, 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,7 +70,7 @@ const metadata = {
70
70
  includeInDebugSnapshot: false,
71
71
  usedInUi: true,
72
72
  },
73
- stepUpSessionExpiresAt: {
73
+ verificationSessionExpiresAt: {
74
74
  includeInStateLogs: false,
75
75
  persist: false,
76
76
  includeInDebugSnapshot: false,
@@ -78,11 +78,25 @@ const metadata = {
78
78
  },
79
79
  };
80
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.
81
+ * Upper bound on a verification session's lifetime. The session also ends at
82
+ * the verification token's own `exp` (15 minutes today), whichever comes
83
+ * first, so this only matters if the server ever issues longer-lived tokens.
84
+ * Callers needing a fresher proof pass `maxSessionAgeMs` to
85
+ * `getVerificationToken`.
84
86
  */
85
- export const STEP_UP_SESSION_TTL_MS = 60_000;
87
+ export const VERIFICATION_SESSION_TTL_MS = 15 * 60_000;
88
+ /**
89
+ * Default maximum age of a verification session that may authorize enrolling a
90
+ * credential. A session opened for an unrelated operation must not be able to
91
+ * add a factor long after the fact, while a session proven moments ago (or
92
+ * during the same setup flow, via `maxSessionAgeMs`) may.
93
+ */
94
+ export const ENROLLMENT_MAX_SESSION_AGE_MS = 2 * 60_000;
95
+ /**
96
+ * Key of the only verification session held today. Sessions are keyed by token
97
+ * audience so audience-scoped tokens can be added without reshaping storage.
98
+ */
99
+ const DEFAULT_AUDIENCE = '';
86
100
  const MESSENGER_EXPOSED_METHODS = [
87
101
  'performSignIn',
88
102
  'performSignOut',
@@ -98,10 +112,10 @@ const MESSENGER_EXPOSED_METHODS = [
98
112
  'refreshEnrolledCredentials',
99
113
  'beginCredentialEnrollment',
100
114
  'completeCredentialEnrollment',
101
- 'beginStepUp',
102
- 'completeStepUp',
103
- 'getElevatedProfileToken',
104
- 'clearStepUpSession',
115
+ 'beginCredentialVerification',
116
+ 'completeCredentialVerification',
117
+ 'getVerificationToken',
118
+ 'clearVerificationSession',
105
119
  ];
106
120
  /**
107
121
  * Controller that enables authentication for restricted endpoints.
@@ -121,8 +135,12 @@ export class AuthenticationController extends BaseController {
121
135
  * ceremony started under the previous session cannot apply its result.
122
136
  */
123
137
  #authSessionEpoch = 0;
124
- #stepUpSession = null;
125
- #stepUpTimer;
138
+ /**
139
+ * Verification sessions keyed by token audience. At most one session per
140
+ * audience; only `DEFAULT_AUDIENCE` is used until audience-scoped tokens
141
+ * exist.
142
+ */
143
+ #verificationSessions = new Map();
126
144
  /**
127
145
  * Sequence number of the most recently started credentials refresh. Only
128
146
  * that refresh may write the cache, so a slower, earlier request cannot
@@ -145,7 +163,7 @@ export class AuthenticationController extends BaseController {
145
163
  this.messenger.subscribe('KeyringController:lock', () => {
146
164
  this.#authSessionEpoch += 1;
147
165
  this.#isUnlocked = false;
148
- this.clearStepUpSession();
166
+ this.clearVerificationSession();
149
167
  });
150
168
  },
151
169
  };
@@ -604,8 +622,9 @@ export class AuthenticationController extends BaseController {
604
622
  this.#setTraceAttribute(context, 'mfaErrorCode', mfaCode);
605
623
  if (mfaCode === 'authentication_required' && this.#isUnlocked) {
606
624
  this.#invalidateSrpSession(this.#getPrimaryEntropySourceId());
607
- // An elevated session must not outlive a rejected base session.
608
- this.clearStepUpSession();
625
+ // A verification session must not outlive a rejected base
626
+ // session.
627
+ this.clearVerificationSession();
609
628
  }
610
629
  }
611
630
  throw error;
@@ -664,9 +683,13 @@ export class AuthenticationController extends BaseController {
664
683
  const sessionEpoch = this.#authSessionEpoch;
665
684
  assertValidMfaRequest(request, BeginEnrollmentRequestStruct);
666
685
  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;
686
+ // The server requires AAL2 to add a credential once one that proves AAL2
687
+ // exists: send the verification token only while a recent enough session
688
+ // is live and let the server decide. An older session stays open for
689
+ // other consumers.
690
+ const accessToken = this.getVerificationToken({
691
+ maxSessionAgeMs: request.maxSessionAgeMs ?? ENROLLMENT_MAX_SESSION_AGE_MS,
692
+ })?.accessToken;
670
693
  const challenge = await this.#runMfaRequest('MFA Enroll Begin', request.reason.operation, request.type, async () => await this.#auth.beginMfaEnrollment(request.type, {
671
694
  email: request.email,
672
695
  entropySourceId: primaryEntropySourceId,
@@ -705,8 +728,9 @@ export class AuthenticationController extends BaseController {
705
728
  }
706
729
  throw error;
707
730
  }
708
- // The factor set changed: require a fresh ceremony against the new set.
709
- this.clearStepUpSession();
731
+ // The verification session is deliberately kept: adding a factor does not
732
+ // weaken an earlier proof, and `beginCredentialEnrollment` already limits
733
+ // which sessions may add the next one.
710
734
  try {
711
735
  return await this.refreshEnrolledCredentials();
712
736
  }
@@ -720,74 +744,77 @@ export class AuthenticationController extends BaseController {
720
744
  }
721
745
  }
722
746
  /**
723
- * Begins step-up verification with an enrolled credential.
747
+ * Begins verification with an enrolled credential.
724
748
  *
725
749
  * @param request - Credential type and trace reason.
726
750
  * @returns A challenge for the client-owned ceremony.
727
751
  */
728
- async beginStepUp(request) {
729
- this.#assertIsUnlocked('beginStepUp');
752
+ async beginCredentialVerification(request) {
753
+ this.#assertIsUnlocked('beginCredentialVerification');
730
754
  const sessionEpoch = this.#authSessionEpoch;
731
- assertValidMfaRequest(request, BeginStepUpRequestStruct);
755
+ assertValidMfaRequest(request, BeginVerificationRequestStruct);
732
756
  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');
757
+ const challenge = await this.#runMfaRequest('MFA Verification Begin', request.reason.operation, request.type, async () => await this.#auth.beginMfaVerification(request.type, primaryEntropySourceId));
758
+ this.#assertAuthSessionEpoch(sessionEpoch, 'beginCredentialVerification');
735
759
  return challenge;
736
760
  }
737
761
  /**
738
- * Completes step-up verification and opens a short-lived elevated session.
762
+ * Completes verification and opens a short-lived verification session.
739
763
  *
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.
764
+ * The assertion returned by the MFA service is exchanged at Hydra for an
765
+ * access token. Its assurance level is not checked: the services receiving
766
+ * the token enforce their own requirements. The token itself never enters
767
+ * controller state.
743
768
  *
744
769
  * @param request - Flow identifier, platform or email proof, and trace reason.
745
- * @returns The elevated profile access token.
770
+ * @returns The verification token.
746
771
  */
747
- async completeStepUp(request) {
748
- this.#assertIsUnlocked('completeStepUp');
772
+ async completeCredentialVerification(request) {
773
+ this.#assertIsUnlocked('completeCredentialVerification');
749
774
  const sessionEpoch = this.#authSessionEpoch;
750
- assertValidMfaRequest(request, CompleteStepUpRequestStruct);
775
+ assertValidMfaRequest(request, CompleteVerificationRequestStruct);
751
776
  const { type } = request.proof;
752
777
  const { operation } = request.reason;
753
778
  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');
779
+ const assertion = await this.#runMfaRequest('MFA Verification Complete', operation, type, async () => await this.#auth.completeMfaVerification(request.flowId, request.proof, primaryEntropySourceId));
780
+ this.#assertAuthSessionEpoch(sessionEpoch, 'completeCredentialVerification');
756
781
  const accessToken = await this.#runMfaRequest('MFA Token Exchange', operation, type, async () => await this.#auth.exchangeMfaAssertion(assertion.token));
757
- this.#assertAuthSessionEpoch(sessionEpoch, 'completeStepUp');
782
+ this.#assertAuthSessionEpoch(sessionEpoch, 'completeCredentialVerification');
758
783
  let decodedClaims;
759
784
  try {
760
785
  decodedClaims = decodeJwtPayload(accessToken.accessToken);
761
786
  }
762
787
  catch (error) {
763
- throw new ElevatedTokenInvalidError(toErrorMessage(error));
788
+ throw new VerificationTokenInvalidError(toErrorMessage(error));
764
789
  }
765
- const claims = parseElevatedTokenClaims(decodedClaims);
790
+ const claims = parseVerificationTokenClaims(decodedClaims);
766
791
  if (claims.exp * 1000 <= Date.now()) {
767
- throw new ElevatedTokenInvalidError('Elevated token is expired');
792
+ throw new VerificationTokenInvalidError('Verification token is expired');
768
793
  }
769
794
  const token = { ...accessToken, claims };
770
- this.#openStepUpSession(token);
795
+ this.#openVerificationSession(token);
771
796
  return token;
772
797
  }
773
798
  /**
774
- * Returns the active elevated token when it meets the requested freshness.
799
+ * Returns the active verification token when it meets the requested
800
+ * freshness.
775
801
  *
776
802
  * @param request - Optional maximum session age in milliseconds, measured
777
803
  * 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.
804
+ * @returns A live verification token, or null when no reusable session
805
+ * exists.
779
806
  */
780
- getElevatedProfileToken(request = {}) {
781
- this.#assertIsUnlocked('getElevatedProfileToken');
782
- assertValidMfaRequest(request, GetElevatedTokenRequestStruct);
783
- const session = this.#stepUpSession;
807
+ getVerificationToken(request = {}) {
808
+ this.#assertIsUnlocked('getVerificationToken');
809
+ assertValidMfaRequest(request, GetVerificationTokenRequestStruct);
810
+ const session = this.#verificationSessions.get(DEFAULT_AUDIENCE);
784
811
  if (!session) {
785
812
  return null;
786
813
  }
787
814
  const now = Date.now();
788
815
  if (now >= session.expiresAt) {
789
816
  // The hard-expiry timer has not fired yet (e.g. a suspended tab).
790
- this.clearStepUpSession();
817
+ this.#dropVerificationSession(DEFAULT_AUDIENCE);
791
818
  return null;
792
819
  }
793
820
  // `>=` so a zero max age always forces a fresh ceremony. Not cleared: the
@@ -799,34 +826,53 @@ export class AuthenticationController extends BaseController {
799
826
  return session.token;
800
827
  }
801
828
  /**
802
- * Opens the elevated session. Its lifetime is the session TTL clamped to
829
+ * Opens the verification session. Its lifetime is the session TTL clamped to
803
830
  * the token's own `exp`, so the session never outlives the token.
804
831
  *
805
- * @param token - The freshly exchanged elevated token.
832
+ * @param token - The freshly exchanged verification token.
806
833
  */
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 };
834
+ #openVerificationSession(token) {
835
+ const audience = DEFAULT_AUDIENCE;
836
+ this.#dropVerificationSession(audience);
837
+ const expiresAt = Math.min(token.obtainedAt + VERIFICATION_SESSION_TTL_MS, token.claims.exp * 1000);
838
+ const timer = setTimeout(() => this.#dropVerificationSession(audience), Math.max(0, expiresAt - Date.now()));
839
+ // Never keep a Node process alive for the expiry timer (tests, tooling).
840
+ timer.unref?.();
841
+ this.#verificationSessions.set(audience, { token, expiresAt, timer });
811
842
  this.update((state) => {
812
- state.stepUpSessionExpiresAt = expiresAt;
843
+ state.verificationSessionExpiresAt = expiresAt;
813
844
  });
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
845
  }
818
846
  /**
819
- * Clears the in-memory elevated session and its expiration timer.
847
+ * Drops one audience's verification session and its expiration timer.
848
+ * `verificationSessionExpiresAt` mirrors the default audience only.
849
+ *
850
+ * @param audience - Audience whose session to drop.
851
+ */
852
+ #dropVerificationSession(audience) {
853
+ const session = this.#verificationSessions.get(audience);
854
+ if (session) {
855
+ clearTimeout(session.timer);
856
+ this.#verificationSessions.delete(audience);
857
+ }
858
+ if (audience === DEFAULT_AUDIENCE &&
859
+ this.state.verificationSessionExpiresAt !== undefined) {
860
+ this.update((state) => {
861
+ state.verificationSessionExpiresAt = undefined;
862
+ });
863
+ }
864
+ }
865
+ /**
866
+ * Clears every in-memory verification session and its expiration timer.
820
867
  */
821
- clearStepUpSession() {
822
- if (this.#stepUpTimer !== undefined) {
823
- clearTimeout(this.#stepUpTimer);
824
- this.#stepUpTimer = undefined;
868
+ clearVerificationSession() {
869
+ for (const { timer } of this.#verificationSessions.values()) {
870
+ clearTimeout(timer);
825
871
  }
826
- this.#stepUpSession = null;
827
- if (this.state.stepUpSessionExpiresAt !== undefined) {
872
+ this.#verificationSessions.clear();
873
+ if (this.state.verificationSessionExpiresAt !== undefined) {
828
874
  this.update((state) => {
829
- state.stepUpSessionExpiresAt = undefined;
875
+ state.verificationSessionExpiresAt = undefined;
830
876
  });
831
877
  }
832
878
  }
@@ -848,7 +894,7 @@ export class AuthenticationController extends BaseController {
848
894
  }
849
895
  performSignOut() {
850
896
  this.#authSessionEpoch += 1;
851
- this.clearStepUpSession();
897
+ this.clearVerificationSession();
852
898
  this.#clearEnrolledCredentials();
853
899
  this.update((state) => {
854
900
  state.isSignedIn = false;
@@ -862,7 +908,7 @@ export class AuthenticationController extends BaseController {
862
908
  clearState() {
863
909
  this.#profilePairingRequestEpoch += 1;
864
910
  this.#authSessionEpoch += 1;
865
- this.clearStepUpSession();
911
+ this.clearVerificationSession();
866
912
  this.#clearEnrolledCredentials();
867
913
  this.update(() => ({ ...defaultState }));
868
914
  }