Skip to content

Enhanced Azure Repos accounts and binding commands#21

Draft
mjcheetham wants to merge 3 commits into
msauth-newapifrom
azlogin
Draft

Enhanced Azure Repos accounts and binding commands#21
mjcheetham wants to merge 3 commits into
msauth-newapifrom
azlogin

Conversation

@mjcheetham

@mjcheetham mjcheetham commented Jun 11, 2026

Copy link
Copy Markdown
Owner

Refs: git-ecosystem#2057

Summary

Builds on the IMicrosoftAuthentication reshape (#22msauth-newapinew-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:

  • Accounts: identities GCM holds Microsoft credentials for. Source of truth is the MSAL cache. New azure-repos login / logout / list commands manage this pool directly, mirroring gh auth login / az login vocabulary.
  • Bindings: pure preferences saying "for this scope, prefer this account". The binding manager itself trafficks in IMicrosoftAccount, storing the MSAL HomeAccountId (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 IMicrosoftAccount end-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.

  1. azrepos: migrate to IMicrosoftAccount and drop the obsolete msauth extension
    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. GetAzureAccessTokenAsync constructs an IMicrosoftAccount from the resolved UPN (or null when no UPN was resolved) and passes it to the new interface overload directly; with the caller migrated, MicrosoftAuthenticationExtensions has no remaining users and disappears. The interface is now the single shape callers talk to, with no parallel API to drift against.

  2. azrepos: add login/logout/list for Microsoft accounts and rename list to list-bindings
    Splits 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. login defaults to the organizations authority (work/school account picker); --tenant exists 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-bindings is what list used to be (pure rename of the binding view).

    The names were previously overloaded: the old list showed bindings, there was no equivalent of gh 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).

  3. azrepos: rebuild binding manager around scopes and home account id
    The 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:

    • Scope union. AzureReposBindingScope is a sealed discriminated union of Tenant(id) and Org(name), each carrying 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 takes IMicrosoftAccount on Bind and returns one from GetAccount, so the storage boundary owns the UPN-vs-HomeAccountId distinction and the provider doesn't have to encode-then-translate at the cache boundary.
    • Dual-key storage. .accountid always holds the MSAL HomeAccountId, .username always 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.FromIdentifier factory. New public API on MicrosoftAccount (Core) that classifies a single user-typed string into the matching slot: an Entra <object-id>.<tenant-id-guid> shape (split on the last ., mirroring Microsoft.Identity.Client.AccountId.ParseFromString, with only the tenant-id side required to be a GUID) lands in HomeAccountId; anything else (UPNs, bare words, ADFS-style single tokens) lands in UserName. Used everywhere the provider previously had to decide which slot a raw string belongs in — CLI bind, URL/credential username on get, and store cache-miss fallback all go through the same factory.

    bind/unbind are reshaped: bind <account> (--tenant <id> | --org <name>) [--local] and unbind (--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 by MicrosoftAccount.FromIdentifier and persisted as-is, so a clone can be pinned to a specific MSAL account by either UPN or HomeAccountId. The NoInherit sentinel 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-bindings learns 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, the FromIdentifier factory (classic Entra GUID.GUID, non-GUID object id, ambiguous-as-UPN, malformed-tenant rejection, null/whitespace rejection), and a new DevAzureUrlHomeAccountId GetCredentialAsync test 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[] and continue are live (from the underlying new-protocol PR), 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 next store — so a binding is only written when the chosen credential actually worked.

  • Friendly tenant names.
    Today list-bindings shows raw tenant GUIDs under the login.microsoftonline.com/<id> heading. A Graph-based lookup can map those to display names; deferred until there's a clear cache story.

Compatibility

  • Existing .username bindings from older releases.
    Read transparently as a fallback when no .accountid exists 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 (via bind, or implicitly via store), it migrates to .accountid keyed by HomeAccountId.

  • Existing bind <org> <username> CLI syntax.
    Breaking. bind and unbind now take --tenant/--org/--local flags and a single positional <account>. The old positional <organization> <username> form is gone. vnext is 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 list output.
    Breaking. list now shows MSAL-cached accounts; the binding view moved to list-bindings. Scripts parsing the old format need to switch verb.

  • MicrosoftAuthenticationExtensions deletion.
    Out-of-tree callers that depended on the obsolete string userName overload of GetTokenForUserAsync (added in the prerequisite PR) now need to construct an IMicrosoftAccount. The in-tree migration and deletion happen together in commit 1.

Testing

  • Full unit test suite passes (Core, AzureRepos, GitHub, Bitbucket, GitLab) — 1322 tests, 0 failures.
  • New test coverage for the binding manager: HomeAccountId-only, UPN-only, and dual-field bind shapes; legacy .username-only read fallback at both Tenant and Org scopes; local-vs-global enumeration boundaries; and the layered resolution hierarchy across scopes and levels.
  • New test coverage for MicrosoftAccount.FromIdentifier in Core: classic Entra (GUID.GUID), non-GUID object id (B2C / multi-dot shapes), ambiguous-as-UPN cases, malformed-tenant rejection, and null/whitespace rejection.
  • AzureReposHostProviderTests updated to the new mock surface (GetAccount(scope) returning IMicrosoftAccount), with a new DevAzureUrlHomeAccountId test pinning the URL-user classification.
  • Manually smoke-tested azure-repos list against a live MSAL cache with multiple accounts.

Notes for reviewers

  • Reviewing commit-by-commit is recommended. The chain is deliberately ordered so each commit tells one story; the squashed diff hides the consumer-migration → product-change progression.
  • Each commit builds cleanly and tests pass at every step.
  • The binding manager's "no translation helpers" property is the headline cleanliness win. The previous draft of this work had a TranslateUpnToAccountIdAsync / TranslateAccountIdToUpnAsync pair gluing the binding store to the auth API; with msauth taking IMicrosoftAccount natively (from the prerequisite PR) those helpers don't exist.
  • MicrosoftAccount.FromIdentifier is 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 right IMicrosoftAccount slot; 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.

@mjcheetham mjcheetham changed the base branch from new-protocol to msauth-newapi June 11, 2026 15:27
@mjcheetham mjcheetham force-pushed the azlogin branch 2 times, most recently from 07cc133 to 2aaca4b Compare June 11, 2026 17:19
@mjcheetham mjcheetham force-pushed the msauth-newapi branch 3 times, most recently from 9ab8bb4 to 88bac9b Compare June 12, 2026 09:49
@mjcheetham mjcheetham force-pushed the azlogin branch 3 times, most recently from 90574c1 to f0f6ca0 Compare June 12, 2026 11:02
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant