1515using System . Globalization ;
1616using System . IO ;
1717using System . Linq ;
18+ using System . Security . Cryptography ;
1819using System . Security . Cryptography . X509Certificates ;
20+ using System . Text ;
1921using System . Threading ;
2022using System . Threading . Tasks ;
2123
@@ -120,10 +122,19 @@ private static async Task<InteractiveBrowserCredential> GetInteractiveBrowserCre
120122 interactiveOptions . TenantId = authContext . TenantId ?? "common" ;
121123 interactiveOptions . AuthorityHost = new Uri ( GetAuthorityUrl ( authContext ) ) ;
122124 interactiveOptions . TokenCachePersistenceOptions = GetTokenCachePersistenceOptions ( authContext ) ;
125+ var loginHint = authContext . LoginHint ;
126+ if ( ! string . IsNullOrWhiteSpace ( loginHint ) )
127+ {
128+ interactiveOptions . LoginHint = loginHint ;
129+ }
123130
124- if ( ! File . Exists ( Constants . AuthRecordPath ) )
131+ // Resolve the persisted authentication record for this account. When a login hint is
132+ // supplied a per-account record is used so we never silently reuse a different user's
133+ // record and so repeat sign-ins for the same account are picker-free.
134+ var authRecord = await ResolveInteractiveAuthRecordAsync ( loginHint ) . ConfigureAwait ( false ) ;
135+
136+ if ( authRecord is null )
125137 {
126- AuthenticationRecord authRecord ;
127138 var interactiveBrowserCredential = new InteractiveBrowserCredential ( interactiveOptions ) ;
128139 if ( ShouldUseWam ( authContext ) )
129140 {
@@ -140,17 +151,99 @@ private static async Task<InteractiveBrowserCredential> GetInteractiveBrowserCre
140151 return interactiveBrowserCredential . AuthenticateAsync ( new TokenRequestContext ( authContext . Scopes ) , cancellationToken ) ;
141152 } ) ;
142153 }
143- await WriteAuthRecordAsync ( authRecord ) . ConfigureAwait ( false ) ;
154+ await WriteAuthRecordAsync ( authRecord , loginHint ) . ConfigureAwait ( false ) ;
144155 authContext . HomeAccountId = TryGetHomeAccountId ( authRecord ) ;
145156 return interactiveBrowserCredential ;
146157 }
147158
148- var interactiveAuthRecord = await ReadAuthRecordAsync ( ) . ConfigureAwait ( false ) ;
149- interactiveOptions . AuthenticationRecord = interactiveAuthRecord ;
150- authContext . HomeAccountId = TryGetHomeAccountId ( interactiveAuthRecord ) ;
159+ interactiveOptions . AuthenticationRecord = authRecord ;
160+ authContext . HomeAccountId = TryGetHomeAccountId ( authRecord ) ;
151161 return new InteractiveBrowserCredential ( interactiveOptions ) ;
152162 }
153163
164+ /// <summary>
165+ /// Resolves the persisted <see cref="AuthenticationRecord"/> to use for an interactive sign-in.
166+ /// Without a login hint the shared, legacy record is used. With a login hint the per-account
167+ /// record is preferred; if it is absent, an existing shared record is migrated only when it
168+ /// belongs to the hinted account. A record whose username does not match the supplied hint is
169+ /// never returned, so a different user's record is never silently reused.
170+ /// </summary>
171+ internal static async Task < AuthenticationRecord > ResolveInteractiveAuthRecordAsync ( string loginHint )
172+ {
173+ if ( string . IsNullOrWhiteSpace ( loginHint ) )
174+ {
175+ try
176+ {
177+ return await ReadAuthRecordAsync ( ) . ConfigureAwait ( false ) ;
178+ }
179+ catch
180+ {
181+ return null ;
182+ }
183+ }
184+
185+ AuthenticationRecord keyedRecord = null ;
186+ try
187+ {
188+ keyedRecord = await ReadAuthRecordAsync ( loginHint ) . ConfigureAwait ( false ) ;
189+ }
190+ catch
191+ {
192+ keyedRecord = null ;
193+ }
194+
195+ if ( keyedRecord != null )
196+ return MatchesLoginHint ( keyedRecord , loginHint ) ? keyedRecord : null ;
197+
198+ // Migration: a pre-existing shared record for this same account is promoted to a
199+ // per-account record so existing users are not forced through the account picker again.
200+ AuthenticationRecord legacyRecord = null ;
201+ try
202+ {
203+ legacyRecord = await ReadAuthRecordAsync ( ) . ConfigureAwait ( false ) ;
204+ }
205+ catch
206+ {
207+ legacyRecord = null ;
208+ }
209+
210+ if ( legacyRecord != null && MatchesLoginHint ( legacyRecord , loginHint ) )
211+ {
212+ await WriteAuthRecordAsync ( legacyRecord , loginHint ) . ConfigureAwait ( false ) ;
213+ return legacyRecord ;
214+ }
215+
216+ return null ;
217+ }
218+
219+ /// <summary>
220+ /// Determines whether the username on an <see cref="AuthenticationRecord"/> matches the supplied
221+ /// login hint (case-insensitive). Reading the username must never throw, so failures yield a
222+ /// non-match, which forces a fresh interactive sign-in rather than reusing an unverified record.
223+ /// </summary>
224+ internal static bool MatchesLoginHint ( AuthenticationRecord authRecord , string loginHint )
225+ {
226+ var username = TryGetUsername ( authRecord ) ;
227+ return ! string . IsNullOrWhiteSpace ( username )
228+ && string . Equals ( username . Trim ( ) , loginHint . Trim ( ) , StringComparison . OrdinalIgnoreCase ) ;
229+ }
230+
231+ /// <summary>
232+ /// Safely reads the username from an <see cref="AuthenticationRecord"/>; any error yields
233+ /// <c>null</c> so account matching can never fail sign-in.
234+ /// </summary>
235+ private static string TryGetUsername ( AuthenticationRecord authRecord )
236+ {
237+ try
238+ {
239+ return authRecord ? . Username ;
240+ }
241+ catch
242+ {
243+ return null ;
244+ }
245+ }
246+
154247 private static async Task < DeviceCodeCredential > GetDeviceCodeCredentialAsync ( IAuthContext authContext , CancellationToken cancellationToken = default )
155248 {
156249 if ( authContext is null )
@@ -443,13 +536,17 @@ public static async Task<IAuthContext> LogoutAsync(bool signOutFromBroker = fals
443536 var authContext = GraphSession . Instance . AuthContext ;
444537 GraphSession . Instance . InMemoryTokenCache ? . ClearCache ( ) ;
445538
539+ // The login hint (when supplied at sign-in) keys the per-account auth record for this
540+ // session, so cache clearing and record deletion below are scoped to the correct account.
541+ var accountKey = authContext ? . LoginHint ;
542+
446543 // Identify the account that signed in for this session so cache clearing can be scoped to
447544 // it. Prefer the HomeAccountId captured on the session's auth context (set at sign-in) for
448545 // correct isolation when multiple identities share the per-user persisted store. Fall back
449546 // to the persisted auth record, then to clearing all accounts when neither is available.
450547 var homeAccountId = ! string . IsNullOrEmpty ( authContext ? . HomeAccountId )
451548 ? authContext . HomeAccountId
452- : await GetCurrentHomeAccountIdAsync ( ) . ConfigureAwait ( false ) ;
549+ : await GetCurrentHomeAccountIdAsync ( accountKey ) . ConfigureAwait ( false ) ;
453550
454551 if ( authContext ? . ContextScope == ContextScope . CurrentUser )
455552 {
@@ -484,7 +581,7 @@ await TokenCacheUtilities.ClearPersistedTokenCacheAsync(
484581
485582 GraphSession . Instance . AuthContext = null ;
486583 GraphSession . Instance . GraphHttpClient = null ;
487- await DeleteAuthRecordAsync ( ) . ConfigureAwait ( false ) ;
584+ await DeleteAuthRecordAsync ( accountKey ) . ConfigureAwait ( false ) ;
488585 return authContext ;
489586 }
490587
@@ -553,11 +650,11 @@ private static async Task ClearBrokerTokenCacheAsync(IAuthContext authContext, s
553650 /// Reads the HomeAccountId of the account persisted for the current session, if any.
554651 /// Returns <c>null</c> when no authentication record is available or it cannot be read.
555652 /// </summary>
556- private static async Task < string > GetCurrentHomeAccountIdAsync ( )
653+ private static async Task < string > GetCurrentHomeAccountIdAsync ( string accountKey = null )
557654 {
558655 try
559656 {
560- var authRecord = await ReadAuthRecordAsync ( ) . ConfigureAwait ( false ) ;
657+ var authRecord = await ReadAuthRecordAsync ( accountKey ) . ConfigureAwait ( false ) ;
561658 return TryGetHomeAccountId ( authRecord ) ;
562659 }
563660 catch
@@ -601,29 +698,57 @@ private static void LogCacheClearFailure(string summary, Exception ex)
601698 }
602699 }
603700
604- private static async Task < AuthenticationRecord > ReadAuthRecordAsync ( )
701+ internal static async Task < AuthenticationRecord > ReadAuthRecordAsync ( string accountKey = null )
605702 {
606703 // Try to create directory if it doesn't exist.
607704 Directory . CreateDirectory ( Constants . GraphDirectoryPath ) ;
608- if ( ! File . Exists ( Constants . AuthRecordPath ) )
705+ var authRecordPath = GetAuthRecordPath ( accountKey ) ;
706+ if ( ! File . Exists ( authRecordPath ) )
609707 return null ;
610- using ( FileStream authRecordStream = new FileStream ( Constants . AuthRecordPath , FileMode . Open , FileAccess . Read ) )
708+ using ( FileStream authRecordStream = new FileStream ( authRecordPath , FileMode . Open , FileAccess . Read ) )
611709 return await AuthenticationRecord . DeserializeAsync ( authRecordStream ) . ConfigureAwait ( false ) ;
612710 }
613711
614- public static async Task WriteAuthRecordAsync ( AuthenticationRecord authRecord )
712+ public static async Task WriteAuthRecordAsync ( AuthenticationRecord authRecord , string accountKey = null )
615713 {
616714 // Try to create directory if it doesn't exist.
617715 Directory . CreateDirectory ( Constants . GraphDirectoryPath ) ;
618- using ( FileStream authRecordStream = new FileStream ( Constants . AuthRecordPath , FileMode . Create , FileAccess . Write ) )
716+ using ( FileStream authRecordStream = new FileStream ( GetAuthRecordPath ( accountKey ) , FileMode . Create , FileAccess . Write ) )
619717 await authRecord . SerializeAsync ( authRecordStream ) . ConfigureAwait ( false ) ;
620718 }
621719
622- public static Task DeleteAuthRecordAsync ( )
720+ public static Task DeleteAuthRecordAsync ( string accountKey = null )
623721 {
624- if ( File . Exists ( Constants . AuthRecordPath ) )
625- File . Delete ( Constants . AuthRecordPath ) ;
722+ var authRecordPath = GetAuthRecordPath ( accountKey ) ;
723+ if ( File . Exists ( authRecordPath ) )
724+ File . Delete ( authRecordPath ) ;
626725 return Task . CompletedTask ;
627726 }
727+
728+ /// <summary>
729+ /// Resolves the on-disk path of the persisted <see cref="AuthenticationRecord"/> for a given
730+ /// account. When <paramref name="accountKey"/> is null or empty the shared, legacy record path
731+ /// (<see cref="Constants.AuthRecordPath"/>) is returned to preserve backwards compatibility for
732+ /// sign-ins without a login hint. Otherwise a per-account file is used so that a hinted sign-in
733+ /// reuses only that account's record (avoiding silent reuse of a different user's record and
734+ /// enabling picker-free repeat sign-ins). The account key is normalized (trimmed, lower-cased)
735+ /// and hashed so the file name is stable, case-insensitive, filesystem-safe, and does not embed
736+ /// the user's identifier in clear text.
737+ /// </summary>
738+ internal static string GetAuthRecordPath ( string accountKey )
739+ {
740+ if ( string . IsNullOrWhiteSpace ( accountKey ) )
741+ return Constants . AuthRecordPath ;
742+
743+ var normalized = accountKey . Trim ( ) . ToLowerInvariant ( ) ;
744+ using ( var sha256 = SHA256 . Create ( ) )
745+ {
746+ var hashBytes = sha256 . ComputeHash ( Encoding . UTF8 . GetBytes ( normalized ) ) ;
747+ var builder = new StringBuilder ( hashBytes . Length * 2 ) ;
748+ foreach ( var b in hashBytes )
749+ builder . Append ( b . ToString ( "x2" , CultureInfo . InvariantCulture ) ) ;
750+ return Path . Combine ( Constants . GraphDirectoryPath , $ "mg.authrecord.{ builder } .json") ;
751+ }
752+ }
628753 }
629754}
0 commit comments