Skip to content

Commit 34e2549

Browse files
gavinbarronCopilotCopilotramsessanchez
authored
Add -LoginHint parameter to Connect-MgGraph (closes #3605) (#3662)
* Add -LoginHint parameter to Connect-MgGraph Adds a new optional -LoginHint parameter to Connect-MgGraph (UserParameterSet) that pre-populates the account (login_hint) shown during interactive browser / WAM sign-in. This lets callers go straight to the preferred sign-in method for a known account instead of navigating the WAM account picker. - Flow the login hint through IAuthContext / AuthContext and onto InteractiveBrowserCredentialOptions.LoginHint. - Persist a per-account AuthenticationRecord (keyed by a hashed, normalized login hint) so repeat sign-ins for the same account are picker-free, while never silently reusing a different user's record. Existing shared records are migrated to per-account records only when the username matches the supplied hint. - Scope cache clearing and auth-record deletion on Disconnect-MgGraph to the correct per-account record. - Add unit tests and parameter-set tests, plus docs and an example. Closes #3605 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 04338979-a6b1-4abe-9ffe-42eb16b07442 * Document -LoginHint parameter in Connect-MgGraph help Add the missing -LoginHint parameter block to the Connect-MgGraph markdown docs and generated MAML help (syntax + parameter list), which the earlier docs regeneration omitted because the module DLL predated the new parameter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 04338979-a6b1-4abe-9ffe-42eb16b07442 * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Ramses Sanchez-Hernandez <63934382+ramsessanchez@users.noreply.github.com>
1 parent 6028659 commit 34e2549

28 files changed

Lines changed: 1154 additions & 183 deletions

src/Authentication/Authentication.Core/Interfaces/IAuthContext.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ public interface IAuthContext
4545
string Environment { get; set; }
4646
string AppName { get; set; }
4747
string Account { get; set; }
48+
string LoginHint { get; set; }
4849
string HomeAccountId { get; set; }
4950
string CertificateThumbprint { get; set; }
5051
string CertificateSubjectName { get; set; }

src/Authentication/Authentication.Core/Microsoft.Graph.Authentication.Core.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<LangVersion>9.0</LangVersion>
55
<TargetFrameworks>netstandard2.0;net6.0;net472</TargetFrameworks>
66
<RootNamespace>Microsoft.Graph.PowerShell.Authentication.Core</RootNamespace>
7-
<Version>2.38.0</Version>
7+
<Version>2.38.1</Version>
88
<!-- Suppress .NET Target Framework Moniker (TFM) Support Build Warnings -->
99
<SuppressTfmSupportBuildWarnings>true</SuppressTfmSupportBuildWarnings>
1010
</PropertyGroup>

src/Authentication/Authentication.Core/Utilities/AuthenticationHelpers.cs

Lines changed: 143 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
using System.Globalization;
1616
using System.IO;
1717
using System.Linq;
18+
using System.Security.Cryptography;
1819
using System.Security.Cryptography.X509Certificates;
20+
using System.Text;
1921
using System.Threading;
2022
using 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

Comments
 (0)