Skip to content

Commit d8a8069

Browse files
committed
msauth: surface the authentication flow on the result
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>
1 parent 3782cd1 commit d8a8069

2 files changed

Lines changed: 85 additions & 5 deletions

File tree

src/shared/Core/Authentication/MicrosoftAuthentication.cs

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,64 @@ public interface IMicrosoftAuthenticationResult
102102
{
103103
string AccessToken { get; }
104104
IMicrosoftAccount Account { get; }
105+
106+
/// <summary>
107+
/// How this token was acquired. Use <see cref="MicrosoftAuthenticationFlowExtensions.IsInteractive"/>
108+
/// to fold this down to "did MSAL show the user UI".
109+
/// </summary>
110+
MicrosoftAuthenticationFlow Flow { get; }
111+
}
112+
113+
/// <summary>
114+
/// Identifies how a Microsoft authentication result was produced.
115+
/// </summary>
116+
public enum MicrosoftAuthenticationFlow
117+
{
118+
/// <summary>The flow was not classified by the implementation.</summary>
119+
Unknown = 0,
120+
121+
/// <summary>Service principal client credentials grant.</summary>
122+
ServicePrincipal,
123+
124+
/// <summary>Azure managed identity (system or user-assigned).</summary>
125+
ManagedIdentity,
126+
127+
/// <summary>Workload federation (federated identity credentials).</summary>
128+
WorkloadFederation,
129+
130+
/// <summary>User-flow token returned silently from MSAL's own cache.</summary>
131+
Silent,
132+
133+
/// <summary>User-flow token returned silently from the OS broker.</summary>
134+
BrokerSilent,
135+
136+
/// <summary>User-flow token acquired via interactive UI in the OS broker.</summary>
137+
BrokerInteractive,
138+
139+
/// <summary>User-flow token acquired via an interactive embedded WebView (.NET Framework only).</summary>
140+
EmbeddedWebView,
141+
142+
/// <summary>User-flow token acquired via the system default browser.</summary>
143+
SystemWebView,
144+
145+
/// <summary>User-flow token acquired via the device code flow.</summary>
146+
DeviceCode,
147+
}
148+
149+
public static class MicrosoftAuthenticationFlowExtensions
150+
{
151+
/// <summary>
152+
/// True when the flow showed UI to the user during token acquisition. The OS-account-default
153+
/// confirmation prompt is GCM chrome around silent acquisition and is not reflected here.
154+
/// </summary>
155+
public static bool IsInteractive(this MicrosoftAuthenticationFlow flow) => flow switch
156+
{
157+
MicrosoftAuthenticationFlow.BrokerInteractive => true,
158+
MicrosoftAuthenticationFlow.EmbeddedWebView => true,
159+
MicrosoftAuthenticationFlow.SystemWebView => true,
160+
MicrosoftAuthenticationFlow.DeviceCode => true,
161+
_ => false,
162+
};
105163
}
106164

107165
public interface IMicrosoftAccount : IEquatable<IMicrosoftAccount>
@@ -297,6 +355,7 @@ public async Task<IMicrosoftAuthenticationResult> GetTokenForUserAsync(
297355
IPublicClientApplication app = await CreatePublicClientApplicationAsync(authority, clientId, redirectUri, useBroker, msaPt, uiCts);
298356

299357
AuthenticationResult result = null;
358+
MicrosoftAuthenticationFlow flow = MicrosoftAuthenticationFlow.Unknown;
300359

301360
// Resolve the account against the MSAL cache once (HomeAccountId-first, UPN-fallback,
302361
// with trace warnings on mismatch/ambiguity).
@@ -316,6 +375,14 @@ public async Task<IMicrosoftAuthenticationResult> GetTokenForUserAsync(
316375
if (hasExistingUser)
317376
{
318377
result = await GetAccessTokenSilentlyAsync(app, scopes, resolvedAccount, msaPt);
378+
if (result is not null)
379+
{
380+
// Tokens returned by the OS broker carry TokenSource.Broker; anything else
381+
// (MSAL's own cache, or a refresh against the identity provider) doesn't.
382+
flow = result.AuthenticationResultMetadata?.TokenSource == TokenSource.Broker
383+
? MicrosoftAuthenticationFlow.BrokerSilent
384+
: MicrosoftAuthenticationFlow.Silent;
385+
}
319386
}
320387

321388
//
@@ -359,6 +426,12 @@ public async Task<IMicrosoftAuthenticationResult> GetTokenForUserAsync(
359426
{
360427
result = null;
361428
}
429+
else
430+
{
431+
// This branch is broker-only by construction; a non-null result here
432+
// can only have come from the broker's cache.
433+
flow = MicrosoftAuthenticationFlow.BrokerSilent;
434+
}
362435
}
363436

364437
if (result is null)
@@ -369,6 +442,7 @@ public async Task<IMicrosoftAuthenticationResult> GetTokenForUserAsync(
369442
// We must configure the system webview as a fallback
370443
.WithSystemWebViewOptions(GetSystemWebViewOptions())
371444
.ExecuteAsync();
445+
flow = MicrosoftAuthenticationFlow.BrokerInteractive;
372446
}
373447
}
374448
else
@@ -395,6 +469,7 @@ public async Task<IMicrosoftAuthenticationResult> GetTokenForUserAsync(
395469
.WithUseEmbeddedWebView(true)
396470
.WithEmbeddedWebViewOptions(GetEmbeddedWebViewOptions())
397471
.ExecuteAsync();
472+
flow = MicrosoftAuthenticationFlow.EmbeddedWebView;
398473
break;
399474

400475
case InteractiveFlowType.SystemWebView:
@@ -404,6 +479,7 @@ public async Task<IMicrosoftAuthenticationResult> GetTokenForUserAsync(
404479
.WithPrompt(Prompt.SelectAccount)
405480
.WithSystemWebViewOptions(GetSystemWebViewOptions())
406481
.ExecuteAsync();
482+
flow = MicrosoftAuthenticationFlow.SystemWebView;
407483
break;
408484

409485
case InteractiveFlowType.DeviceCode:
@@ -412,6 +488,7 @@ public async Task<IMicrosoftAuthenticationResult> GetTokenForUserAsync(
412488
// TODO: introduce a small GUI window to show a code if no TTY exists
413489
ThrowIfTerminalPromptsDisabled();
414490
result = await app.AcquireTokenWithDeviceCode(scopes, ShowDeviceCodeInTty).ExecuteAsync();
491+
flow = MicrosoftAuthenticationFlow.DeviceCode;
415492
break;
416493

417494
default:
@@ -420,7 +497,7 @@ public async Task<IMicrosoftAuthenticationResult> GetTokenForUserAsync(
420497
}
421498
}
422499

423-
return new MsalResult(result);
500+
return new MsalResult(result, flow);
424501
}
425502
finally
426503
{
@@ -438,7 +515,7 @@ public async Task<IMicrosoftAuthenticationResult> GetTokenForServicePrincipalAsy
438515
Context.Trace.WriteLine($"Sending with X5C: '{sp.SendX5C}'.");
439516
AuthenticationResult result = await app.AcquireTokenForClient(scopes).WithSendX5C(sp.SendX5C).ExecuteAsync();;
440517

441-
return new MsalResult(result);
518+
return new MsalResult(result, MicrosoftAuthenticationFlow.ServicePrincipal);
442519
}
443520
catch (Exception ex)
444521
{
@@ -462,7 +539,7 @@ public async Task<IMicrosoftAuthenticationResult> GetTokenForManagedIdentityAsyn
462539
try
463540
{
464541
AuthenticationResult result = await app.AcquireTokenForManagedIdentity(resource).ExecuteAsync();
465-
return new MsalResult(result);
542+
return new MsalResult(result, MicrosoftAuthenticationFlow.ManagedIdentity);
466543
}
467544
catch (Exception ex)
468545
{
@@ -482,7 +559,7 @@ public async Task<IMicrosoftAuthenticationResult> GetTokenUsingWorkloadFederatio
482559
.ExecuteAsync()
483560
.ConfigureAwait(false);
484561

485-
return new MsalResult(result);
562+
return new MsalResult(result, MicrosoftAuthenticationFlow.WorkloadFederation);
486563
}
487564

488565
private async Task<string> GetClientAssertion(MicrosoftWorkloadFederationOptions fedOpts, AssertionRequestOptions _)
@@ -1173,14 +1250,16 @@ internal enum InteractiveFlowType
11731250

11741251
private class MsalResult : IMicrosoftAuthenticationResult
11751252
{
1176-
public MsalResult(AuthenticationResult msalResult)
1253+
public MsalResult(AuthenticationResult msalResult, MicrosoftAuthenticationFlow flow = MicrosoftAuthenticationFlow.Unknown)
11771254
{
11781255
AccessToken = msalResult.AccessToken;
11791256
Account = MicrosoftAccount.FromMsalAccount(msalResult.Account);
1257+
Flow = flow;
11801258
}
11811259

11821260
public string AccessToken { get; }
11831261
public IMicrosoftAccount Account { get; }
1262+
public MicrosoftAuthenticationFlow Flow { get; }
11841263
}
11851264
}
11861265
}

src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1088,6 +1088,7 @@ private class MockMsAuthResult : IMicrosoftAuthenticationResult
10881088
{
10891089
public string AccessToken { get; set; }
10901090
public IMicrosoftAccount Account { get; set; }
1091+
public MicrosoftAuthenticationFlow Flow { get; set; }
10911092
}
10921093
}
10931094
}

0 commit comments

Comments
 (0)