@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.
Files changed (89) hide show
  1. package/CHANGELOG.md +27 -1
  2. package/README.md +21 -0
  3. package/dist/controllers/authentication/AuthenticationController-method-action-types.d.ts +80 -1
  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 +79 -2
  7. package/dist/controllers/authentication/AuthenticationController.d.ts.map +1 -1
  8. package/dist/controllers/authentication/AuthenticationController.js +374 -12
  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/controllers/authentication/mocks/mockResponses.d.ts +5 -0
  14. package/dist/controllers/authentication/mocks/mockResponses.d.ts.map +1 -1
  15. package/dist/controllers/authentication/mocks/mockResponses.js +26 -1
  16. package/dist/controllers/authentication/mocks/mockResponses.js.map +1 -1
  17. package/dist/controllers/user-storage/UserStorageController.d.ts.map +1 -1
  18. package/dist/controllers/user-storage/UserStorageController.js +6 -5
  19. package/dist/controllers/user-storage/UserStorageController.js.map +1 -1
  20. package/dist/sdk/authentication-jwt-bearer/flow-srp.d.ts +60 -2
  21. package/dist/sdk/authentication-jwt-bearer/flow-srp.d.ts.map +1 -1
  22. package/dist/sdk/authentication-jwt-bearer/flow-srp.js +122 -2
  23. package/dist/sdk/authentication-jwt-bearer/flow-srp.js.map +1 -1
  24. package/dist/sdk/authentication-jwt-bearer/mfa/schemas.d.ts +693 -0
  25. package/dist/sdk/authentication-jwt-bearer/mfa/schemas.d.ts.map +1 -0
  26. package/dist/sdk/authentication-jwt-bearer/mfa/schemas.js +266 -0
  27. package/dist/sdk/authentication-jwt-bearer/mfa/schemas.js.map +1 -0
  28. package/dist/sdk/authentication-jwt-bearer/mfa/services.d.ts +104 -0
  29. package/dist/sdk/authentication-jwt-bearer/mfa/services.d.ts.map +1 -0
  30. package/dist/sdk/authentication-jwt-bearer/mfa/services.js +368 -0
  31. package/dist/sdk/authentication-jwt-bearer/mfa/services.js.map +1 -0
  32. package/dist/sdk/authentication-jwt-bearer/mfa/types.d.ts +125 -0
  33. package/dist/sdk/authentication-jwt-bearer/mfa/types.d.ts.map +1 -0
  34. package/dist/sdk/authentication-jwt-bearer/mfa/types.js +3 -0
  35. package/dist/sdk/authentication-jwt-bearer/mfa/types.js.map +1 -0
  36. package/dist/sdk/authentication-jwt-bearer/services.d.ts +4 -5
  37. package/dist/sdk/authentication-jwt-bearer/services.d.ts.map +1 -1
  38. package/dist/sdk/authentication-jwt-bearer/services.js +7 -4
  39. package/dist/sdk/authentication-jwt-bearer/services.js.map +1 -1
  40. package/dist/sdk/authentication-jwt-bearer/types.d.ts +9 -0
  41. package/dist/sdk/authentication-jwt-bearer/types.d.ts.map +1 -1
  42. package/dist/sdk/authentication-jwt-bearer/types.js.map +1 -1
  43. package/dist/sdk/authentication.d.ts +17 -7
  44. package/dist/sdk/authentication.d.ts.map +1 -1
  45. package/dist/sdk/authentication.js +25 -1
  46. package/dist/sdk/authentication.js.map +1 -1
  47. package/dist/sdk/constants.d.ts +3 -0
  48. package/dist/sdk/constants.d.ts.map +1 -1
  49. package/dist/sdk/constants.js +3 -0
  50. package/dist/sdk/constants.js.map +1 -1
  51. package/dist/sdk/errors.d.ts +106 -0
  52. package/dist/sdk/errors.d.ts.map +1 -1
  53. package/dist/sdk/errors.js +179 -0
  54. package/dist/sdk/errors.js.map +1 -1
  55. package/dist/sdk/index.d.ts +3 -0
  56. package/dist/sdk/index.d.ts.map +1 -1
  57. package/dist/sdk/index.js +1 -0
  58. package/dist/sdk/index.js.map +1 -1
  59. package/dist/sdk/mocks/auth.d.ts +63 -0
  60. package/dist/sdk/mocks/auth.d.ts.map +1 -1
  61. package/dist/sdk/mocks/auth.js +90 -0
  62. package/dist/sdk/mocks/auth.js.map +1 -1
  63. package/dist/sdk/utils/as-record.d.ts +10 -0
  64. package/dist/sdk/utils/as-record.d.ts.map +1 -0
  65. package/dist/sdk/utils/as-record.js +14 -0
  66. package/dist/sdk/utils/as-record.js.map +1 -0
  67. package/dist/sdk/utils/jwt.d.ts +13 -0
  68. package/dist/sdk/utils/jwt.d.ts.map +1 -0
  69. package/dist/sdk/utils/jwt.js +21 -0
  70. package/dist/sdk/utils/jwt.js.map +1 -0
  71. package/dist/sdk/utils/to-error-message.d.ts +8 -0
  72. package/dist/sdk/utils/to-error-message.d.ts.map +1 -0
  73. package/dist/sdk/utils/to-error-message.js +10 -0
  74. package/dist/sdk/utils/to-error-message.js.map +1 -0
  75. package/dist/sdk/utils/validate-login-response.d.ts.map +1 -1
  76. package/dist/sdk/utils/validate-login-response.js +5 -7
  77. package/dist/sdk/utils/validate-login-response.js.map +1 -1
  78. package/dist/sdk/utils/validate-pair-response.d.ts +4 -0
  79. package/dist/sdk/utils/validate-pair-response.d.ts.map +1 -1
  80. package/dist/sdk/utils/validate-pair-response.js.map +1 -1
  81. package/dist/shared/storage-schema.d.ts +1 -0
  82. package/dist/shared/storage-schema.d.ts.map +1 -1
  83. package/dist/shared/storage-schema.js +1 -0
  84. package/dist/shared/storage-schema.js.map +1 -1
  85. package/dist/shared/utils/entropy-source.d.ts +3 -12
  86. package/dist/shared/utils/entropy-source.d.ts.map +1 -1
  87. package/dist/shared/utils/entropy-source.js +4 -17
  88. package/dist/shared/utils/entropy-source.js.map +1 -1
  89. package/package.json +12 -9
@@ -1,11 +1,16 @@
1
1
  import { BaseController } from '@metamask/base-controller';
2
- import { assertMessageStartsWithMetamask, AuthType, Env, JwtBearerAuth, PairConflictError, } from '../../sdk/index.js';
3
- import { getHdKeyringEntropySourceIds, getPrimaryHdKeyringEntropySourceId, } from '../../shared/utils/entropy-source.js';
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
- ...this.#config,
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 { keyrings } = this.messenger.call('KeyringController:getState');
170
- return getHdKeyringEntropySourceIds(keyrings);
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 { keyrings } = this.messenger.call('KeyringController:getState');
183
- return getPrimaryHdKeyringEntropySourceId(keyrings);
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
  /**