Enhance Microsoft Authentication API for better account management#22
Draft
mjcheetham wants to merge 7 commits into
Draft
Enhance Microsoft Authentication API for better account management#22mjcheetham wants to merge 7 commits into
mjcheetham wants to merge 7 commits into
Conversation
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>
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>
d5c7946 to
ef6ab00
Compare
`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
ef6ab00 to
36a9cec
Compare
`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>
2ec4c2f to
9ab8bb4
Compare
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>
9ab8bb4 to
88bac9b
Compare
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
Reshapes
IMicrosoftAuthenticationaround an explicitIMicrosoftAccountparameter type, so callers can identify accounts by stableHomeAccountId(preferred), byUserName(fallback), or both — without losing information at the API boundary.The previous shape took a bare
string userNameforGetTokenForUserAsyncand had no equivalent ofapp.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, differentHomeAccountId), 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
msauth: extract and rename SPI class to own fileRename
ServicePrincipalIdentity→MicrosoftServicePrincipalIdentityfor consistency with the otherMicrosoft*types, and move it to its own file alongsideMicrosoftWorkloadFederationOptions. Pure refactor.msauth: rename MicrosoftAuthenticationFlowType and make privateMicrosoftAuthenticationFlowTypewas a public enum at theGitCredentialManager.Authenticationnamespace level despite being used only insideMicrosoftAuthentication. Move it to a nestedinternal InteractiveFlowTypeso the public surface shrinks and the name reads naturally now that it's scoped.The
IMicrosoftAccountsurface (4 commits)msauth: add IMicrosoftAuthentication.GetUserAccountsAsyncNew enumeration method on the interface so callers can list cached Microsoft accounts without going through MSAL directly. Introduces the
IMicrosoftAccountabstraction (UPN + opaqueHomeAccountId) and a publicly constructibleMicrosoftAccountPOCO sitting next to it. RelaxesCreatePublicClientApplicationAsyncto skipWithAuthority/WithRedirectUriwhen null, since the cache is keyed by client id only.IMicrosoftAccountships with anIEquatable<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 concreteMicrosoftAccountimplements equality withOrdinalIgnoreCaseon both fields —HomeAccountIdbecause MSAL's ownAccountId.Equalscompares itsIdentifiercase-insensitively, andUserNamebecause UPNs are case-insensitive per RFC 5321.GetHashCodeis a manual xor combine of the two field hashes rather thanHashCode.Combine, so the type stays buildable on .NET Framework 4.7.2 without pulling inMicrosoft.Bcl.HashCode.msauth: surface the full account from authentication resultsReplaces
IMicrosoftAuthenticationResult.AccountUpn(bare string) with a fullIMicrosoftAccount Accountproperty. 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.msauth: add RemoveUserAccountAsyncCounterpart to
GetUserAccountsAsync. Takes anIMicrosoftAccountfrom the start (no string-first version ever shipped); resolves against the MSAL cacheHomeAccountId-first with a UPN fallback, trace-warning on mismatch or ambiguity. String comparisons useOrdinalIgnoreCasethroughout to match MSAL's ownAccountId.Equalssemantics.msauth: accept IMicrosoftAccount on GetTokenForUserAsyncPivots the pre-existing
string userNameparameter toIMicrosoftAccount. Internal resolution prefersHomeAccountIdwhen present, falls back toUserName, 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 toPublicClientApplication.OperatingSystemAccount(anIAccountsentinel) 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'sHomeAccountIdisn't populated.Adds a new
MicrosoftAuthenticationExtensionsstatic 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)
msauth: surface the authentication flow on the resultAdds a public
MicrosoftAuthenticationFlowenum and aFlowproperty onIMicrosoftAuthenticationResultso 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.SilentvsBrokerSilentis determined at the silent return site by inspecting MSAL's ownAuthenticationResultMetadata.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
Flowtoday — 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
IMicrosoftAuthenticationtoday. 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 constructingnew 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. TheUnknownenum value exists specifically so implementers that don't classify can return it.AccountUpn→AccountonIMicrosoftAuthenticationResult.Source-breaking for anyone reading the property. The new shape exposes strictly more information (UPN remains accessible via
Account.UserName).Testing
AzureReposHostProviderTestsmock setups migrated to the new interface signature; each setup hoists a localvar expectedAccount = new MicrosoftAccount(homeAccountId: null, userName: …)(orIMicrosoftAccount expectedAccount = nullfor the unconstrained case) and passes it directly toSetup. The value-equality contract in commit 3 is what lets Moq match these by value rather than reference.Notes for reviewers
IMicrosoftAccountshape 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.OrdinalIgnoreCasechoice forHomeAccountIdcomparisons matches MSAL's ownAccountId.Equals(Microsoft.Identity.ClientAccountId.cs, line ~84). The same comparer is used by the equality contract in commit 3 and the resolution helper introduced in commit 5.Closes part of git-ecosystem#2057.