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