Skip to content

Commit 9fe431d

Browse files
committed
azrepos: add login/logout/list for Microsoft accounts and rename list to list-bindings
Split the verb surface into two clusters: - `login` / `logout` / `list` now operate on the MSAL account cache: they add an account, remove one, and enumerate what is currently cached. They do not touch the binding manager. - `bind` / `unbind` / `list-bindings` continue to manage which account a particular Azure DevOps organization should use. `list-bindings` is what `list` used to be (pure rename — the binding view, argument surface, and output format are unchanged). The names were previously overloaded: the old `list` showed bindings, there was no equivalent of `gh auth status` for "which identities does GCM actually have credentials for", and signing in or out was an implicit side effect of bind/unbind. Splitting the clusters makes the two layers explicit and matches the vocabulary of comparable tools (`az login`, `gh auth login`). - `azure-repos login [--tenant <id|domain>]` Runs an interactive Microsoft sign-in. With no flag, signs in against the wildcard `organizations` authority so the user can pick any work/school account; `--tenant` constrains to a specific Microsoft Entra tenant. The flag exists primarily to pre-stage a guest-account record: signing in against the home tenant for a UPN that's also a guest in another tenant only populates the cache with the home-tenant account, so for the guest tenant you have to sign in explicitly against that tenant's authority. - `azure-repos logout (<account> | --all)` Removes one account (matched by UPN or HomeAccountId) or every cached account from MSAL. UPN matches are case-insensitive; ambiguous matches print the candidates so the user can specify by HomeAccountId. No interactive picker yet — `<account>` is required unless `--all` is passed. - `azure-repos list` Prints each cached account on its own line followed by an indented HomeAccountId line, sorted by UPN. The HomeAccountId line exists so users can copy it into `logout <account>` and `bind` when UPN alone is ambiguous (typically guest accounts in multiple tenants). The rename of `list` to `list-bindings` is a deliberate breaking change — vnext is still pre-release and there is no separate deprecation step planned. The follow-on commit reshapes `bind`/`unbind` themselves. Assisted-by: Claude Opus 4.7
1 parent 3849131 commit 9fe431d

1 file changed

Lines changed: 156 additions & 7 deletions

File tree

src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs

Lines changed: 156 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -828,9 +828,36 @@ ProviderCommand ICommandProvider.CreateCommand()
828828
clearCacheCmd.SetHandler(ClearCacheCmd);
829829

830830
//
831-
// list <organization> [--show-remotes] [--verbose]
831+
// login [--tenant <id|domain>]
832832
//
833-
var listCmd = new Command("list", "List all user account bindings");
833+
var loginCmd = new Command("login", "Sign in to a Microsoft account and add it to the credential cache");
834+
var loginTenantOpt = new Option<string>("--tenant", "Sign in to a specific Microsoft Entra tenant (GUID or domain). Required when adding a guest account whose home tenant is different from the tenant you want to use it for.");
835+
loginCmd.AddOption(loginTenantOpt);
836+
loginCmd.SetHandler(LoginCmd, loginTenantOpt);
837+
838+
//
839+
// logout (<account> | --all)
840+
//
841+
var logoutCmd = new Command("logout", "Remove a Microsoft account from the credential cache");
842+
var logoutAccountArg = new Argument<string>("account", "Account to remove (UPN or HomeAccountId)")
843+
{
844+
Arity = ArgumentArity.ZeroOrOne
845+
};
846+
var logoutAllOpt = new Option<bool>("--all", "Remove every cached Microsoft account");
847+
logoutCmd.AddArgument(logoutAccountArg);
848+
logoutCmd.AddOption(logoutAllOpt);
849+
logoutCmd.SetHandler(LogoutCmd, logoutAccountArg, logoutAllOpt);
850+
851+
//
852+
// list
853+
//
854+
var listCmd = new Command("list", "List Microsoft accounts in the credential cache");
855+
listCmd.SetHandler(ListCmd);
856+
857+
//
858+
// list-bindings [<organization>] [--show-remotes] [--verbose]
859+
//
860+
var listBindingsCmd = new Command("list-bindings", "List all user account bindings");
834861
var orgFilterArg = new Argument<string>("organization", "(optional) Filter results by Azure DevOps organization name")
835862
{
836863
Arity = ArgumentArity.ZeroOrOne
@@ -840,10 +867,10 @@ ProviderCommand ICommandProvider.CreateCommand()
840867
Description = "Also show Azure DevOps remote user bindings for the current repository"
841868
};
842869
var verboseOpt = new Option<bool>(new[] { "--verbose", "-v" }, "Verbose output - show remote URLs");
843-
listCmd.AddArgument(orgFilterArg);
844-
listCmd.AddOption(remoteOpt);
845-
listCmd.AddOption(verboseOpt);
846-
listCmd.SetHandler(ListCmd, orgFilterArg, remoteOpt, verboseOpt);
870+
listBindingsCmd.AddArgument(orgFilterArg);
871+
listBindingsCmd.AddOption(remoteOpt);
872+
listBindingsCmd.AddOption(verboseOpt);
873+
listBindingsCmd.SetHandler(ListBindingsCmd, orgFilterArg, remoteOpt, verboseOpt);
847874

848875
//
849876
// bind <organization> <username> [--local]
@@ -875,7 +902,10 @@ ProviderCommand ICommandProvider.CreateCommand()
875902
unbindCmd.SetHandler(UnbindCmd, orgArg, localOpt);
876903

877904
var rootCmd = new ProviderCommand(this);
905+
rootCmd.AddCommand(loginCmd);
906+
rootCmd.AddCommand(logoutCmd);
878907
rootCmd.AddCommand(listCmd);
908+
rootCmd.AddCommand(listBindingsCmd);
879909
rootCmd.AddCommand(bindCmd);
880910
rootCmd.AddCommand(unbindCmd);
881911
rootCmd.AddCommand(clearCacheCmd);
@@ -888,14 +918,133 @@ private void ClearCacheCmd()
888918
_context.Streams.Out.WriteLine("Authority cache cleared");
889919
}
890920

921+
private async Task<int> LoginCmd(string tenantId)
922+
{
923+
// Pick the authority MSAL signs in against. By default we use the wildcard
924+
// `organizations` authority so the user can pick any work/school account; an
925+
// explicit --tenant constrains to one tenant (the only way to pre-stage a
926+
// guest-account record for a non-home tenant).
927+
string authority = !string.IsNullOrWhiteSpace(tenantId)
928+
? $"{AzureDevOpsConstants.AadAuthorityBaseUrl}/{tenantId}"
929+
: $"{AzureDevOpsConstants.AadAuthorityBaseUrl}/organizations";
930+
931+
IMicrosoftAuthenticationResult result;
932+
try
933+
{
934+
result = await _msAuth.GetTokenForUserAsync(
935+
authority,
936+
GetClientId(),
937+
GetRedirectUri(),
938+
AzureDevOpsConstants.AzureDevOpsDefaultScopes,
939+
account: null,
940+
msaPt: true);
941+
}
942+
catch (Exception ex)
943+
{
944+
_context.Streams.Error.WriteLine($"error: sign-in failed: {ex.Message}");
945+
return -1;
946+
}
947+
948+
if (result.Account is null || string.IsNullOrWhiteSpace(result.Account.HomeAccountId))
949+
{
950+
_context.Streams.Error.WriteLine(
951+
"error: sign-in succeeded but no account identifier was returned");
952+
return -1;
953+
}
954+
955+
_context.Streams.Out.WriteLine($"Signed in as {result.Account.UserName}.");
956+
return 0;
957+
}
958+
959+
private async Task<int> LogoutCmd(string account, bool all)
960+
{
961+
bool hasAccount = !string.IsNullOrWhiteSpace(account);
962+
if (all == hasAccount)
963+
{
964+
_context.Streams.Error.WriteLine("error: specify either <account> or --all");
965+
return -1;
966+
}
967+
968+
IReadOnlyList<IMicrosoftAccount> cached =
969+
await _msAuth.GetUserAccountsAsync(GetClientId(), msaPt: true);
970+
971+
if (cached.Count == 0)
972+
{
973+
_context.Streams.Out.WriteLine("No accounts cached.");
974+
return 0;
975+
}
976+
977+
IEnumerable<IMicrosoftAccount> targets;
978+
if (all)
979+
{
980+
targets = cached;
981+
}
982+
else
983+
{
984+
IMicrosoftAccount[] matches = cached.Where(a =>
985+
StringComparer.OrdinalIgnoreCase.Equals(a.UserName, account) ||
986+
StringComparer.Ordinal.Equals(a.HomeAccountId, account))
987+
.ToArray();
988+
if (matches.Length == 0)
989+
{
990+
_context.Streams.Error.WriteLine($"error: no cached account matches '{account}'");
991+
return -1;
992+
}
993+
if (matches.Length > 1)
994+
{
995+
_context.Streams.Error.WriteLine(
996+
$"error: '{account}' is ambiguous; specify the HomeAccountId of the account to remove:");
997+
foreach (IMicrosoftAccount m in matches)
998+
{
999+
_context.Streams.Error.WriteLine($" {m.UserName} ({m.HomeAccountId})");
1000+
}
1001+
return -1;
1002+
}
1003+
targets = matches;
1004+
}
1005+
1006+
int removed = 0;
1007+
foreach (IMicrosoftAccount target in targets)
1008+
{
1009+
if (await _msAuth.RemoveUserAccountAsync(GetClientId(), target, msaPt: true))
1010+
{
1011+
_context.Streams.Out.WriteLine($"Signed out {target.UserName}.");
1012+
removed++;
1013+
}
1014+
}
1015+
1016+
return removed > 0 ? 0 : -1;
1017+
}
1018+
1019+
private async Task<int> ListCmd()
1020+
{
1021+
IReadOnlyList<IMicrosoftAccount> cached =
1022+
await _msAuth.GetUserAccountsAsync(GetClientId(), msaPt: true);
1023+
1024+
if (cached.Count == 0)
1025+
{
1026+
_context.Streams.Out.WriteLine("No accounts cached.");
1027+
return 0;
1028+
}
1029+
1030+
foreach (IMicrosoftAccount account in cached
1031+
.OrderBy(a => a.UserName ?? string.Empty, StringComparer.OrdinalIgnoreCase))
1032+
{
1033+
_context.Streams.Out.WriteLine(account.UserName ?? "(unknown)");
1034+
_context.Streams.Out.WriteLine($" {account.HomeAccountId}");
1035+
}
1036+
1037+
return 0;
1038+
}
1039+
8911040
private class RemoteBinding
8921041
{
8931042
public string Remote { get; set; }
8941043
public bool IsPush { get; set; }
8951044
public Uri Uri { get; set; }
8961045
}
8971046

898-
private void ListCmd(string organization, bool showRemotes, bool verbose)
1047+
private void ListBindingsCmd(string organization, bool showRemotes, bool verbose)
8991048
{
9001049
// Get all organization bindings from the user manager
9011050
IList<AzureReposBinding> bindings = _bindingManager.GetBindings(organization).ToList();

0 commit comments

Comments
 (0)