Skip to content

Enhance Microsoft Authentication API for better account management#22

Draft
mjcheetham wants to merge 7 commits into
new-protocolfrom
msauth-newapi
Draft

Enhance Microsoft Authentication API for better account management#22
mjcheetham wants to merge 7 commits into
new-protocolfrom
msauth-newapi

Conversation

@mjcheetham

@mjcheetham mjcheetham commented Jun 11, 2026

Copy link
Copy Markdown
Owner

Refs: git-ecosystem#2057

Summary

Reshapes IMicrosoftAuthentication around an explicit IMicrosoftAccount parameter type, so callers can identify accounts by stable HomeAccountId (preferred), by UserName (fallback), or both — without losing information at the API boundary.

The previous shape took a bare string userName for GetTokenForUserAsync and had no equivalent of app.GetAccountsAsync() / app.RemoveAsync() at all. That worked fine for single-tenant single-account users, but it lost information in two important scenarios: guest accounts where the same UPN exists in two tenants (the same UPN, different HomeAccountId), and callers that already carry a stable id from persistent storage (forced to translate down to a UPN and back). Pivoting the surface around an account abstraction removes both classes of bug at the source.

No provider behaviour changes for end-users. The Azure Repos provider is updated mechanically alongside the API changes that touch its callers, but the AzRepos product features that motivated the reshape (binding manager rewrite, new login/logout/list verbs) ship in a follow-on PR on top of this one.

What's in this PR

Seven commits, ordered as a readable story.

Two preparatory renames

  1. msauth: extract and rename SPI class to own file
    Rename ServicePrincipalIdentityMicrosoftServicePrincipalIdentity for consistency with the other Microsoft* types, and move it to its own file alongside MicrosoftWorkloadFederationOptions. Pure refactor.

  2. msauth: rename MicrosoftAuthenticationFlowType and make private
    MicrosoftAuthenticationFlowType was a public enum at the GitCredentialManager.Authentication namespace level despite being used only inside MicrosoftAuthentication. Move it to a nested internal InteractiveFlowType so the public surface shrinks and the name reads naturally now that it's scoped.

The IMicrosoftAccount surface (4 commits)

  1. msauth: add IMicrosoftAuthentication.GetUserAccountsAsync
    New enumeration method on the interface so callers can list cached Microsoft accounts without going through MSAL directly. Introduces the IMicrosoftAccount abstraction (UPN + opaque HomeAccountId) and a publicly constructible MicrosoftAccount POCO sitting next to it. Relaxes CreatePublicClientApplicationAsync to skip WithAuthority/WithRedirectUri when null, since the cache is keyed by client id only.

    IMicrosoftAccount ships with an IEquatable<IMicrosoftAccount> contract so any implementer must opt in to value semantics from the start: accounts are natural keys (dedupe a cached list, compare a stored value against a fresh enumeration, use as a dictionary key) and reference equality would silently break those scenarios. The concrete MicrosoftAccount implements equality with OrdinalIgnoreCase on both fields — HomeAccountId because MSAL's own AccountId.Equals compares its Identifier case-insensitively, and UserName because UPNs are case-insensitive per RFC 5321. GetHashCode is a manual xor combine of the two field hashes rather than HashCode.Combine, so the type stays buildable on .NET Framework 4.7.2 without pulling in Microsoft.Bcl.HashCode.

  2. msauth: surface the full account from authentication results
    Replaces IMicrosoftAuthenticationResult.AccountUpn (bare string) with a full IMicrosoftAccount Account property. The result now exposes the stable HomeAccountId alongside the display name, so callers persisting a record don't have to re-enumerate the cache to find it.

  3. msauth: add RemoveUserAccountAsync
    Counterpart to GetUserAccountsAsync. Takes an IMicrosoftAccount from the start (no string-first version ever shipped); resolves against the MSAL cache HomeAccountId-first with a UPN fallback, trace-warning on mismatch or ambiguity. String comparisons use OrdinalIgnoreCase throughout to match MSAL's own AccountId.Equals semantics.

  4. msauth: accept IMicrosoftAccount on GetTokenForUserAsync
    Pivots the pre-existing string userName parameter to IMicrosoftAccount. Internal resolution prefers HomeAccountId when present, falls back to UserName, trace-warns on mismatch or multi-match.

    The silent-acquisition helper unifies behind a single GetAccessTokenSilentlyAsync(app, scopes, IAccount, msaPt): callers with an explicit cached account pass it through, and the broker's "default OS account" path resolves to PublicClientApplication.OperatingSystemAccount (an IAccount sentinel) and goes through the same path. The MSA-passthrough tenant-id workaround switches to a null-safe lookup so it no-ops cleanly when the sentinel's HomeAccountId isn't populated.

    Adds a new MicrosoftAuthenticationExtensions static class to host an [Obsolete] extension that preserves the pre-existing (…, string userName, …) signature. Existing in-tree production call sites keep building with a deprecation warning as a TODO list; the only remaining production caller (the Azure Repos OAuth path) migrates in the follow-on PR, which then deletes the extension.

Plus diagnostics (1 commit)

  1. msauth: surface the authentication flow on the result
    Adds a public MicrosoftAuthenticationFlow enum and a Flow property on IMicrosoftAuthenticationResult so consumers can tell (a) whether MSAL prompted the user (IsInteractive() extension method), and (b) which technique was used. Nine values collapse all the paths into named buckets: Unknown (escape hatch), ServicePrincipal, ManagedIdentity, WorkloadFederation, Silent, BrokerSilent, BrokerInteractive, EmbeddedWebView, SystemWebView, DeviceCode.

    Silent vs BrokerSilent is determined at the silent return site by inspecting MSAL's own AuthenticationResultMetadata.TokenSource — broker-cached tokens behave differently from MSAL-cached tokens on revocation and tenant-switch scenarios, and surfacing the distinction is free.

    No consumer reads Flow today — surfacing it now is preparation for picker policy, future telemetry, and trace diagnostics that already had no good way to learn this without log scraping.

What this enables (deliberately not in this PR)

  • Azure Repos consumer migration + product changes.
    A follow-on PR migrates the AzRepos OAuth caller to the new API, deletes the obsolete [Obsolete] extension (zero remaining callers), and rebuilds the binding manager around the new account abstraction. The "no translation helpers at the provider boundary" cleanliness win is the headline of that PR — and is only possible because this PR pivoted the API first.

  • Other providers.
    GitHub, Bitbucket, and GitLab don't use IMicrosoftAuthentication today. If they ever do, they'll get the new shape from day one.

Compatibility

  • Out-of-tree callers of the string-userName overload.
    Continue to compile via the [Obsolete] extension method. Deprecation warning shows up as a TODO; behaviour is preserved exactly — the extension wraps by constructing new MicrosoftAccount(homeAccountId: null, userName), which is the "UPN-only" shape that resolves identically to today.

  • Out-of-tree implementers of IMicrosoftAccount.
    Newly required to implement IEquatable<IMicrosoftAccount>. This is a deliberate contract upgrade: any implementer that didn't already have value semantics was silently broken in dictionary scenarios; the interface now surfaces that requirement at compile time.

  • Out-of-tree implementers of IMicrosoftAuthenticationResult.
    Newly required to implement Flow. The Unknown enum value exists specifically so implementers that don't classify can return it.

  • AccountUpnAccount on IMicrosoftAuthenticationResult.
    Source-breaking for anyone reading the property. The new shape exposes strictly more information (UPN remains accessible via Account.UserName).

Testing

  • Full unit test suite passes (Core, AzureRepos, GitHub, Bitbucket, GitLab).
  • AzureReposHostProviderTests mock setups migrated to the new interface signature; each setup hoists a local var expectedAccount = new MicrosoftAccount(homeAccountId: null, userName: …) (or IMicrosoftAccount expectedAccount = null for the unconstrained case) and passes it directly to Setup. The value-equality contract in commit 3 is what lets Moq match these by value rather than reference.

Notes for reviewers

  • The two IMicrosoftAccount shape decisions worth eyeballing: HomeAccountId-wins-on-mismatch precedence (commit 5, reused in commit 6) and UPN-only-multi-match traces-warn-and-picks-first (also commits 5 + 6). Both are documented at the resolution site.
  • The OrdinalIgnoreCase choice for HomeAccountId comparisons matches MSAL's own AccountId.Equals (Microsoft.Identity.Client AccountId.cs, line ~84). The same comparer is used by the equality contract in commit 3 and the resolution helper introduced in commit 5.
  • Each commit builds cleanly and tests pass at every step. Commit 6 emits one expected deprecation warning at the Azure Repos call site, which the follow-on PR clears.

Closes part of git-ecosystem#2057.

Rename the ServicePrincipalIdentity class to include the prefix
Microsoft* like the other MSAuth-based types, and also move it from the
main MicrosoftAuthentication.cs file to its own - like the
MicrosoftWorkloadFederationOption are in their own file.

Signed-off-by: Matthew John Cheetham <mjcheetham@outlook.com>
The MicrosoftAuthenticationFlowType enumeration is not used outside of
the MicrosoftAuthentication class, but it currently a public enum type
at the GCM.Authentication namespace.

Move the enum to a nested, private type, inside of MSAuthentication
class. Rename the enum to 'InteractiveFlowType' since we are now already
scoping this to MSAuth.

Finally, keep this `internal` visibility since we use the GetFlowType
method, with internal-visibility in the MSAuth diagnostic.

Signed-off-by: Matthew John Cheetham <mjcheetham@outlook.com>
@mjcheetham mjcheetham changed the title msauth-newapi Enhance Microsoft Authentication API for better account management Jun 11, 2026
The Azure Repos provider is about to grow a smarter sign-in flow
that, on a credential `get`, needs to know which Microsoft
accounts MSAL (and the OS broker, when enabled) already has
cached so it can filter them by the authority/tenant the request
is targeting.

Expose a new `GetUserAccountsAsync(clientId, msaPt)` method on
`IMicrosoftAuthentication` that wraps MSAL's
`IPublicClientApplication.GetAccountsAsync()` and projects each
`IAccount` into a GCM-domain `IMicrosoftAccount` carrying the UPN
and the opaque `HomeAccountId`.

The new method intentionally takes neither an authority nor a
redirect URI: MSAL's account cache is keyed by client ID, not by
authority, and a redirect URI is only required for interactive
flows. To support that, relax `CreatePublicClientApplicationAsync`
to skip `WithAuthority` / `WithRedirectUri` when its corresponding
argument is null, letting MSAL fall back to its built-in defaults
for cache-only operations. The existing call site in
`GetTokenForUserAsync` continues to pass both arguments, so its
behaviour is unchanged.

`HomeAccountId` (rather than UPN) is what callers should persist
when they want to remember "this account for this tenant/org": it
is stable across UPN renames. The UPN is still exposed for
display purposes.

`IMicrosoftAccount` ships with an `IEquatable<IMicrosoftAccount>`
contract so any implementer must opt in to value semantics from
the start: accounts are natural keys (dedupe a cached list,
compare a stored value against a fresh enumeration, use as a
dictionary key) and reference equality would silently break those
scenarios for the obvious-looking
`new MicrosoftAccount(...).Equals(otherInstance)` case. The
concrete `MicrosoftAccount` implements equality with
`OrdinalIgnoreCase` on both fields — `HomeAccountId` because
MSAL's own `AccountId.Equals` compares its `Identifier`
case-insensitively (Microsoft.Identity.Client `AccountId.cs`,
line ~84), and `UserName` because UPNs are case-insensitive per
RFC 5321. `GetHashCode` is a manual xor combine of the two
field hashes rather than `HashCode.Combine`, so the type stays
buildable on .NET Framework 4.7.2 without pulling in
`Microsoft.Bcl.HashCode`.

Assisted-by: Claude Opus 4.7
Signed-off-by: Matthew John Cheetham <mjcheetham@outlook.com>
`IMicrosoftAuthenticationResult.AccountUpn` returned only the user
principal name as a bare string. The new `IMicrosoftAccount`
projection introduced for cache enumeration already carries more
useful information about a Microsoft identity — UPN and the stable
`HomeAccountId` — and an authentication result is one of the
obvious places callers want it.

Replace the `AccountUpn` string property with an `Account` property
of type `IMicrosoftAccount`. The implementation reuses the same
`MicrosoftAccount` wrapper that backs `GetAccountsAsync`, so the
MSAL `IAccount` does not leak across the abstraction. Callers that
previously consulted `.AccountUpn` for tracing or for building a
`GitCredential` now go through `.Account.UserName`; the new shape
also lets the upcoming Azure Repos sign-in flow read
`.Account.HomeAccountId` directly off the successful token result,
without a follow-up `GetAccountsAsync` call to identify which
account the broker just authenticated.

Assisted-by: Claude Opus 4.7
`IMicrosoftAuthentication` already exposes `GetUserAccountsAsync`
for enumerating cached MSAL accounts, but has no counterpart for
dropping one. Adding the equivalent `app.RemoveAsync` shape now
unblocks a new Azure Repos `logout` subcommand that needs to clear
a specific account from the cache without going through any
broker UI.

The method takes the same `(clientId, msaPt)` pair as
`GetUserAccountsAsync` plus the `IMicrosoftAccount` to remove.
Resolution against the MSAL cache prefers `HomeAccountId` (the
stable identifier that survives UPN renames) and falls back to
matching by `UserName`; mismatches and ambiguity trace a warning
so callers can diagnose them via the existing logging surface.
Return value distinguishes "removed" from "no such account was
cached" so callers can produce a precise diagnostic without
having to re-enumerate first.

Assisted-by: Claude Opus 4.7
Signed-off-by: Matthew John Cheetham <mjcheetham@outlook.com>
`IMicrosoftAuthentication.GetTokenForUserAsync` predated the
`IMicrosoftAccount` shape that the rest of the auth surface now
uses for identifying cached accounts. It took a bare `string
userName`, matched by UPN against `app.GetAccountsAsync()`, and
quietly picked the first match. That works fine for single-tenant
single-account users but loses information in two scenarios:

  - Guest accounts where the same UPN exists in two tenants
    (e.g. `alice@contoso.com` as a guest in fabrikam). Both
    accounts share a UPN but have distinct `HomeAccountId`
    values; selecting by UPN alone non-deterministically picks one.
  - Callers that already carry a stable `HomeAccountId` (e.g. an
    upcoming binding manager rewrite) have to translate it back
    to a UPN before calling the API, which both wastes the
    stable identifier and reintroduces the ambiguity.

Pivot the interface to take an `IMicrosoftAccount`:

    Task<IMicrosoftAuthenticationResult> GetTokenForUserAsync(
        string authority, string clientId, Uri redirectUri,
        string[] scopes, IMicrosoftAccount account, bool msaPt = false);

Internal resolution prefers `HomeAccountId` when present, falls
back to `UserName` otherwise, traces a warning when both are set
and the cached account's UPN differs from the supplied one
(HomeAccountId wins, supplied UPN is informational), and traces a
warning when UPN-only resolution returns multiple matches
(first-match returned, today's behaviour preserved). `null`
account remains "let MSAL pick interactively".

The silent-acquisition helper unifies behind a single
`GetAccessTokenSilentlyAsync(app, scopes, IAccount, msaPt)`:
callers with an explicit cached account pass it through, and the
broker's "default OS account" path resolves to
`PublicClientApplication.OperatingSystemAccount` (an `IAccount`
sentinel) and goes through the same path. The MSA-passthrough
tenant-id workaround switches to a null-safe lookup so it no-ops
cleanly when the sentinel's `HomeAccountId` isn't populated.

Add a new `MicrosoftAuthenticationExtensions` static class to
host an `[Obsolete]` extension that preserves the pre-existing
`(…, string userName, …)` signature. Existing in-tree production
call sites keep building with a deprecation warning as a TODO
list; the extension wraps by constructing
`new MicrosoftAccount(homeAccountId: null, userName)` — the
"UPN-only" shape — so the legacy semantics are preserved exactly.
A follow-on commit migrates the one remaining production caller
(Azure Repos), then a final commit deletes the extension.

`Mock<IMicrosoftAuthentication>` cannot set up an extension
method, so the eight `.Setup` expressions in
`AzureReposHostProviderTests` and the one direct call in
`MicrosoftAuthenticationTests` migrate to the new interface
method in this commit. Each test hoists a local
`var expectedAccount = new MicrosoftAccount(homeAccountId: null,
userName: …)` (or `IMicrosoftAccount expectedAccount = null` for
the unconstrained case) alongside its other expected-mock-inputs
and passes the value directly to `Setup`; the equality contract
introduced alongside `IMicrosoftAccount` itself is what lets Moq
match these by value rather than reference. Production code
stays on the obsolete extension for now.

Assisted-by: Claude Opus 4.7
Signed-off-by: Matthew John Cheetham <mjcheetham@outlook.com>
@mjcheetham mjcheetham force-pushed the msauth-newapi branch 2 times, most recently from 2ec4c2f to 9ab8bb4 Compare June 12, 2026 09:46
Two consumer needs the current result shape can't answer:

  - "Did MSAL prompt the user to acquire this token?" Useful for
    diagnostics, telemetry, and consumer policy: silent paths can
    be retried more aggressively, interactive paths cost the user
    real time.
  - "Which technique did MSAL use?" Same audiences plus the
    ability to distinguish broker-cached from MSAL-cached tokens
    (different revocation and tenant-switch behaviour), or to
    detect device-code use (often a fallback rather than the
    user's first preference).

Add a public `MicrosoftAuthenticationFlow` enum and a `Flow`
property on `IMicrosoftAuthenticationResult` so consumers can read
both signals. No consumer reads `Flow` today — surfacing it now is
preparation for picker policy, future telemetry, and trace
diagnostics that already had no good way to learn this without
re-deriving it from log scraping.

The enum collapses non-interactive paths into named buckets
(`ServicePrincipal`, `ManagedIdentity`, `WorkloadFederation`,
`Silent`, `BrokerSilent`) rather than a single `NonInteractive`
value: the distinction is cheap to populate and the names carry
useful diagnostic information for free. `Silent` vs `BrokerSilent`
is determined at the silent return site by inspecting MSAL's own
`AuthenticationResultMetadata.TokenSource` — tokens returned by
the broker carry `TokenSource.Broker`, everything else (MSAL's
own cache, or a refresh against the identity provider) does not.

The interactive bucket splits the same way the existing private
`InteractiveFlowType` enum already does: `BrokerInteractive`,
`EmbeddedWebView`, `SystemWebView`, `DeviceCode`. `BrokerInteractive`
(rather than just `Broker`) is named symmetrically with
`BrokerSilent` so a reader looking at one finds the other.

"Interactive" is exposed as an `IsInteractive()` extension method
on the enum, not as a property on the result. This keeps the
result interface minimal and works for callers that have a flow
value from elsewhere (e.g. an enum field on a stored request).
The OS-account-default flow does silent token acquisition
followed by a GCM-side "continue with current account?"
confirmation prompt — the token itself was acquired silently, so
the flow is `Silent` or `BrokerSilent`; the confirmation prompt
is GCM chrome that isn't reflected here.

Workload federation reports itself as `WorkloadFederation` even
though `GetTokenUsingWorkloadFederationAsync` internally calls
`GetTokenForManagedIdentityAsync`: the intermediate MI result is
private to the WIF path, and the surfaced result describes the
outer top-level call.

`MsalResult`'s constructor grows an optional
`MicrosoftAuthenticationFlow` parameter that defaults to `Unknown`.
Every in-tree call site supplies a real value, so the default is
purely an escape hatch for callers that don't classify (test
fakes that build a result without going through
`MicrosoftAuthentication`, or future call sites that want to
construct a result before the flow is decided).

The test fake `AzureReposHostProviderTests.MockMsAuthResult`
grows the property to satisfy the interface; nothing reads it.

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