Enhanced Azure Repos accounts and binding commands#21
Draft
mjcheetham wants to merge 3 commits into
Draft
Conversation
d5c7946 to
ef6ab00
Compare
07cc133 to
2aaca4b
Compare
ef6ab00 to
36a9cec
Compare
9ab8bb4 to
88bac9b
Compare
90574c1 to
f0f6ca0
Compare
…tension The Azure Repos OAuth path is the only production caller of the now-obsolete `string userName` overload of `GetTokenForUserAsync` in the tree, so migrate it and delete the obsolete extension in the same commit — splitting the two would leave a one-commit window in which the extension exists with zero callers, which adds noise without telling a separable story. Migrate `GetAzureAccessTokenAsync` to construct an `IMicrosoftAccount` from the resolved UPN (or `null` when no UPN was resolved) and pass it to the new `IMicrosoftAuthentication.GetTokenForUserAsync` overload directly. The construction is unconditional: when `userName` is null/empty we still want "let MSAL pick interactively" (today's behaviour), so `account` is `null` in that case rather than a `MicrosoftAccount` with both fields blank. With the AzRepos caller migrated, `MicrosoftAuthenticationExtensions` (introduced one PR ago purely to keep in-tree callers building through the API pivot) has no remaining users. Delete the class; the interface is now the single shape callers talk to, with no parallel API to drift against. Assisted-by: Claude Opus 4.7 Signed-off-by: Matthew John Cheetham <mjcheetham@outlook.com>
… 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
Signed-off-by: Matthew John Cheetham <mjcheetham@outlook.com>
The pre-pivot binding manager had one stored shape — `(orgName,
userName)` — and one verb pair to manipulate it (`bind`/`unbind`
with `--local`). It couldn't distinguish "use account A for any
org in tenant T" from "use account A for org O", and the stored
value was a UPN, so a rename outside GCM silently broke the
binding.
Rebuild it around three changes:
- **Scope union.** A binding now lives at one of two scopes:
`Tenant(id)` ("any organization backed by this Microsoft Entra
tenant") or `Org(name)` ("this specific organization"). Each
case carries an `IsLocal` flag that picks per-clone vs.
machine-wide storage. Resolution is most-specific-first: local
Org → global Org → local Tenant → global Tenant → nothing.
- **`IMicrosoftAccount`-typed API.** The manager traffics in
`IMicrosoftAccount` rather than opaque strings, so the storage
boundary owns the UPN-vs-HomeAccountId distinction and the
provider doesn't have to encode-then-translate at the cache
boundary. `Bind` takes an account with at least one of
`HomeAccountId` or `UserName` populated; `GetAccount` returns
whichever fields the stored binding has.
- **Dual-key storage.** `.accountid` always holds the MSAL
`HomeAccountId`, `.username` always holds the UPN. Both are
written atomically when both are known; either alone is
written when only one is available (legacy `.username`-only
bindings continue to read back as a UPN-only account). A
HomeAccountId never changes when an account is renamed or its
UPN suffix shifts, so credential lookups survive identity
drift; the colocated UPN gives `list-bindings` something to
display when MSAL hasn't seen the account recently. No
format detection, no drift warning: with atomic dual-key
writes both keys present is the normal state for a
fully-resolved binding.
User-typed identifiers (CLI `<account>` arguments, URL/credential
user names) classify themselves through a new factory on
`MicrosoftAccount`:
MicrosoftAccount.FromIdentifier(string)
`FromIdentifier` recognises an MSAL Entra `HomeAccountId` shape
positively — an `<object-id>.<tenant-id>` pair split on the LAST
`.` (mirroring `Microsoft.Identity.Client.AccountId.ParseFromString`,
which permits an object id to contain dots in B2C / guest
scenarios) where the tenant-id suffix is a well-formed GUID. The
object-id prefix is only required to be non-empty, since MSAL
itself doesn't validate it as a GUID and exotic-but-real object
ids exist. Anything else (including bare words and ADFS-shape
single tokens, which are indistinguishable from a username)
routes into the `UserName` slot. Keeping the heuristic on the
account type lets the provider stay out of the classification
business and gives future consumers (eg. a picker UI) one place
to reuse. Tests on the factory cover both classic Entra
(GUID.GUID) and non-GUID-object-id shapes, the ambiguous-as-UPN
cases, malformed-tenant rejection, and null/whitespace rejection.
`bind`/`unbind` are reshaped to match:
azure-repos bind <account> (--tenant <id> | --org <name>) [--local]
azure-repos unbind (--tenant <id> | --org <name>) [--local]
`<account>` is resolved against the MSAL cache: if a cached
account matches by UPN or HomeAccountId, we persist both fields
of that cached account; otherwise we warn and persist the value
classified by `MicrosoftAccount.FromIdentifier`. That keeps a
working `login`-then-`bind` flow precise while still letting
users pre-stage bindings before the matching login happens.
On the credential paths:
- `get` derives a tenant id from the resolved Microsoft
authentication authority (parsing the
`login.microsoftonline.com/<tenant>` URL form) and feeds it
into `ResolveAccountBinding`. The returned `IMicrosoftAccount`
is passed directly to `IMicrosoftAuthentication.GetTokenForUserAsync`,
whose HomeAccountId-first / UPN-fallback resolution handles
either shape natively. When no binding matches the result is
`null` — the existing interactive sign-in path handles it from
there. Inline URL/credential-supplied user names go through
`MicrosoftAccount.FromIdentifier` so a user can pin a clone
to a specific MSAL account by either UPN or HomeAccountId. A
future PR (state[]/continue) will layer optimistic
auto-routing on top, with a safe loop-breaking story.
- `store` matches the incoming user name against the MSAL cache
by either UPN or HomeAccountId and binds the resolved account;
on a cache miss it falls back to a single-field binding
classified the same way.
- `erase` clears whichever level (local first, else global) had
a binding for the org. Tenant bindings are left alone — they
cover orgs other than the failing one.
`list-bindings` learns the new shape: bindings group under a
heading derived from the scope (`dev.azure.com/<org>` for Org,
`login.microsoftonline.com/<tenant-id>` for Tenant) with a
`(global)`/`(local)` row each. The bound account is displayed
UPN-first, falling back to HomeAccountId for HomeAccountId-only
bindings. The `--show-remotes` and `--verbose` flags keep their
existing semantics.
The `NoInherit` sentinel from the old binding manager is dropped.
It was a workaround for "I want this clone to never inherit my
global binding"; with bindings now resolved by an explicit
hierarchy, a per-clone "always prompt" toggle is better delivered
through the picker UI work rather than as a magic empty string.
Tests cover the new storage shape (HomeAccountId-only, UPN-only,
both fields), legacy `.username`-only read fallback at both
Tenant and Org scopes, scope/level resolution precedence, and
local-vs-global enumeration boundaries. The existing
`GetCredentialAsync` tests are updated for the new mock surface
(`GetAccount(scope)` returning an `IMicrosoftAccount` instead of
`GetBinding(orgName)` returning a string), and a new
`DevAzureUrlHomeAccountId` test pins the URL-user classification.
Assisted-by: Claude Opus 4.7
Signed-off-by: Matthew John Cheetham <mjcheetham@outlook.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs: git-ecosystem#2057
Summary
Builds on the
IMicrosoftAuthenticationreshape (#22 —msauth-newapi→new-protocol) to deliver the first user-visible feature it unlocks: a clearer Microsoft account model for Azure Repos.Today the Azure Repos provider conflates "which Microsoft identities does GCM have credentials for" with "which identity should I use for this organization". Both layers live in the same
AzureReposBindingManager, both are keyed by UPN, and neither is directly inspectable. This PR splits the two concerns:azure-repos login/logout/listcommands manage this pool directly, mirroringgh auth login/az loginvocabulary.IMicrosoftAccount, storing the MSALHomeAccountId(stable across UPN renames) and the UPN side-by-side under a new scope union (Tenant/Org×IsLocal). Resolution layers most-specific-first.With the underlying API now speaking in
IMicrosoftAccountend-to-end (from the prerequisite PR), the binding manager can pass account records straight through to msauth — no UPN ↔ HomeAccountId translation glue at the provider boundary.No protocol-layer features (
state[]retry loops, prompt-and-remember pickers) are wired up in this PR — that's the next layer. This is the data model and verb surface they'll build on.What's in this PR
Three commits, ordered as a readable story.
azrepos: migrate to IMicrosoftAccount and drop the obsolete msauth extensionThe Azure Repos OAuth path is the only production caller of the now-obsolete
string userNameoverload ofGetTokenForUserAsyncin the tree, so migrate it and delete the obsolete extension in the same commit.GetAzureAccessTokenAsyncconstructs anIMicrosoftAccountfrom the resolved UPN (ornullwhen no UPN was resolved) and passes it to the new interface overload directly; with the caller migrated,MicrosoftAuthenticationExtensionshas no remaining users and disappears. The interface is now the single shape callers talk to, with no parallel API to drift against.azrepos: add login/logout/list for Microsoft accounts and rename list to list-bindingsSplits the verb surface into two clusters:
login [--tenant <id|domain>],logout (<account> | --all),list— operate on the MSAL account pool only. They do not touch the binding manager.logindefaults to theorganizationsauthority (work/school account picker);--tenantexists primarily to pre-stage a guest-account record when the home tenant differs from the tenant you want to use it for.bind,unbind,list-bindings— manage which account a particular ADO tenant or org should use.list-bindingsis whatlistused to be (pure rename of the binding view).The names were previously overloaded: the old
listshowed bindings, there was no equivalent ofgh auth status, and signing in or out was an implicit side effect of bind/unbind. Splitting matches the vocabulary of comparable tools (az login,gh auth login).azrepos: rebuild binding manager around scopes and home account idThe marquee commit. The pre-rewrite binding manager had one stored shape —
(orgName, userName)— and one verb pair to manipulate it. It could not distinguish "use account A for any org in tenant T" from "use account A for org O", and the stored value was a UPN, so a rename outside GCM silently broke the binding.Rebuild around:
AzureReposBindingScopeis a sealed discriminated union ofTenant(id)andOrg(name), each carrying anIsLocalflag that picks per-clone vs. machine-wide storage. Resolution is most-specific-first: local Org → global Org → local Tenant → global Tenant → nothing.IMicrosoftAccount-typed API. The manager takesIMicrosoftAccountonBindand returns one fromGetAccount, so the storage boundary owns the UPN-vs-HomeAccountId distinction and the provider doesn't have to encode-then-translate at the cache boundary..accountidalways holds the MSALHomeAccountId,.usernamealways holds the UPN. Both are written atomically when both are known; either alone when only one is available. Legacy.username-only bindings from older releases continue to read back as a UPN-only account and migrate to the new shape on the next rewrite. No format detection on write, no drift warning on read — with atomic dual-key writes both keys present is the normal, fully-resolved state.MicrosoftAccount.FromIdentifierfactory. New public API onMicrosoftAccount(Core) that classifies a single user-typed string into the matching slot: an Entra<object-id>.<tenant-id-guid>shape (split on the last., mirroringMicrosoft.Identity.Client.AccountId.ParseFromString, with only the tenant-id side required to be a GUID) lands inHomeAccountId; anything else (UPNs, bare words, ADFS-style single tokens) lands inUserName. Used everywhere the provider previously had to decide which slot a raw string belongs in — CLIbind, URL/credentialusernameonget, andstorecache-miss fallback all go through the same factory.bind/unbindare reshaped:bind <account> (--tenant <id> | --org <name>) [--local]andunbind (--tenant <id> | --org <name>) [--local].<account>is matched against the MSAL cache by either UPN or HomeAccountId; on a cache miss the value is classified byMicrosoftAccount.FromIdentifierand persisted as-is, so a clone can be pinned to a specific MSAL account by either UPN or HomeAccountId. TheNoInheritsentinel from the old manager is dropped — with bindings now resolved by an explicit hierarchy, a per-clone "always prompt" toggle is better delivered through a future picker UI than as a magic empty string.Credential get/store/erase paths integrate with the new resolver;
list-bindingslearns to group under scope-derived headings (dev.azure.com/<org>,login.microsoftonline.com/<tenant-id>). Tests cover the new storage shape (HomeAccountId-only, UPN-only, and both fields), legacy.username-only fallback at both Tenant and Org scopes, scope/level resolution precedence, theFromIdentifierfactory (classic Entra GUID.GUID, non-GUID object id, ambiguous-as-UPN, malformed-tenant rejection, null/whitespace rejection), and a newDevAzureUrlHomeAccountIdGetCredentialAsynctest pinning the URL-user classification.What this enables (deliberately not in this PR)
Each of these belongs in a follow-up PR that ships a concrete user-visible win on top of this foundation:
Account picker UI.
Avalonia + terminal fallback for the multi-candidate case (when more than one cached account matches the request's tenant, or none does and the user needs to pick or sign in). The new account-pool surface is where the picker reads its options from.
State-aware get + prompt-and-remember + optimistic auto-route.
Now that
state[]andcontinueare live (from the underlyingnew-protocolPR), the AzRepos provider can return a token optimistically (e.g. the only cached account in the request's tenant), record which account it tried in state, and pick a different one on a 401 — escaping the "wrong cached account is the only candidate" trap that a naive single-match auto-route would create. The picker grows a "remember this choice" checkbox whose result is threaded through state and persisted on the nextstore— so a binding is only written when the chosen credential actually worked.Friendly tenant names.
Today
list-bindingsshows raw tenant GUIDs under thelogin.microsoftonline.com/<id>heading. A Graph-based lookup can map those to display names; deferred until there's a clear cache story.Compatibility
Existing
.usernamebindings from older releases.Read transparently as a fallback when no
.accountidexists at the same scope (Org and Tenant scopes — the legacy storage was Org-only in older releases, but the fallback is symmetric now). The first time a binding is rewritten (viabind, or implicitly viastore), it migrates to.accountidkeyed by HomeAccountId.Existing
bind <org> <username>CLI syntax.Breaking.
bindandunbindnow take--tenant/--org/--localflags and a single positional<account>. The old positional<organization> <username>form is gone.vnextis still pre-release and the verb surface needed to align with the new identity model; a deprecation period would have lived in an awkward shape.azure-repos listoutput.Breaking.
listnow shows MSAL-cached accounts; the binding view moved tolist-bindings. Scripts parsing the old format need to switch verb.MicrosoftAuthenticationExtensionsdeletion.Out-of-tree callers that depended on the obsolete
string userNameoverload ofGetTokenForUserAsync(added in the prerequisite PR) now need to construct anIMicrosoftAccount. The in-tree migration and deletion happen together in commit 1.Testing
.username-only read fallback at both Tenant and Org scopes; local-vs-global enumeration boundaries; and the layered resolution hierarchy across scopes and levels.MicrosoftAccount.FromIdentifierin Core: classic Entra (GUID.GUID), non-GUID object id (B2C / multi-dot shapes), ambiguous-as-UPN cases, malformed-tenant rejection, and null/whitespace rejection.AzureReposHostProviderTestsupdated to the new mock surface (GetAccount(scope)returningIMicrosoftAccount), with a newDevAzureUrlHomeAccountIdtest pinning the URL-user classification.azure-repos listagainst a live MSAL cache with multiple accounts.Notes for reviewers
TranslateUpnToAccountIdAsync/TranslateAccountIdToUpnAsyncpair gluing the binding store to the auth API; with msauth takingIMicrosoftAccountnatively (from the prerequisite PR) those helpers don't exist.MicrosoftAccount.FromIdentifieris the one new piece of Core (non-AzRepos) public surface introduced by this PR. It exists because three sites in the AzRepos provider need to classify a single string into the rightIMicrosoftAccountslot; consolidating the heuristic on the type that owns the two slots keeps the provider out of the classification business and gives follow-up consumers (eg. the account picker UI) one place to reuse.Builds on #22. Closes part of git-ecosystem#2057.