Skip to content

Commit 2ec4c2f

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. A small `ClassifySilent` helper inside `MicrosoftAuthentication` keeps the lookup in one place. 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 a `MicrosoftAuthenticationFlow` parameter; every call site supplies a real value, so no `Unknown` sentinel is needed on the public surface. 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 a99750a commit 2ec4c2f

2 files changed

Lines changed: 84 additions & 5 deletions

File tree

src/shared/Core/Authentication/MicrosoftAuthentication.cs

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,61 @@ 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>Service principal client credentials grant.</summary>
119+
ServicePrincipal,
120+
121+
/// <summary>Azure managed identity (system or user-assigned).</summary>
122+
ManagedIdentity,
123+
124+
/// <summary>Workload federation (federated identity credentials).</summary>
125+
WorkloadFederation,
126+
127+
/// <summary>User-flow token returned silently from MSAL's own cache.</summary>
128+
Silent,
129+
130+
/// <summary>User-flow token returned silently from the OS broker.</summary>
131+
BrokerSilent,
132+
133+
/// <summary>User-flow token acquired via interactive UI in the OS broker.</summary>
134+
BrokerInteractive,
135+
136+
/// <summary>User-flow token acquired via an interactive embedded WebView (.NET Framework only).</summary>
137+
EmbeddedWebView,
138+
139+
/// <summary>User-flow token acquired via the system default browser.</summary>
140+
SystemWebView,
141+
142+
/// <summary>User-flow token acquired via the device code flow.</summary>
143+
DeviceCode,
144+
}
145+
146+
public static class MicrosoftAuthenticationFlowExtensions
147+
{
148+
/// <summary>
149+
/// True when the flow showed UI to the user during token acquisition. The OS-account-default
150+
/// confirmation prompt is GCM chrome around silent acquisition and is not reflected here.
151+
/// </summary>
152+
public static bool IsInteractive(this MicrosoftAuthenticationFlow flow) => flow switch
153+
{
154+
MicrosoftAuthenticationFlow.BrokerInteractive => true,
155+
MicrosoftAuthenticationFlow.EmbeddedWebView => true,
156+
MicrosoftAuthenticationFlow.SystemWebView => true,
157+
MicrosoftAuthenticationFlow.DeviceCode => true,
158+
_ => false,
159+
};
105160
}
106161

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

299354
AuthenticationResult result = null;
355+
MicrosoftAuthenticationFlow flow = default;
300356

301357
// Resolve the account against the MSAL cache once (HomeAccountId-first, UPN-fallback,
302358
// with trace warnings on mismatch/ambiguity).
@@ -316,6 +372,7 @@ public async Task<IMicrosoftAuthenticationResult> GetTokenForUserAsync(
316372
if (hasExistingUser)
317373
{
318374
result = await GetAccessTokenSilentlyAsync(app, scopes, resolvedAccount, msaPt);
375+
if (result is not null) flow = ClassifySilent(result);
319376
}
320377

321378
//
@@ -359,6 +416,10 @@ public async Task<IMicrosoftAuthenticationResult> GetTokenForUserAsync(
359416
{
360417
result = null;
361418
}
419+
else
420+
{
421+
flow = ClassifySilent(result);
422+
}
362423
}
363424

364425
if (result is null)
@@ -369,6 +430,7 @@ public async Task<IMicrosoftAuthenticationResult> GetTokenForUserAsync(
369430
// We must configure the system webview as a fallback
370431
.WithSystemWebViewOptions(GetSystemWebViewOptions())
371432
.ExecuteAsync();
433+
flow = MicrosoftAuthenticationFlow.BrokerInteractive;
372434
}
373435
}
374436
else
@@ -395,6 +457,7 @@ public async Task<IMicrosoftAuthenticationResult> GetTokenForUserAsync(
395457
.WithUseEmbeddedWebView(true)
396458
.WithEmbeddedWebViewOptions(GetEmbeddedWebViewOptions())
397459
.ExecuteAsync();
460+
flow = MicrosoftAuthenticationFlow.EmbeddedWebView;
398461
break;
399462

400463
case InteractiveFlowType.SystemWebView:
@@ -404,6 +467,7 @@ public async Task<IMicrosoftAuthenticationResult> GetTokenForUserAsync(
404467
.WithPrompt(Prompt.SelectAccount)
405468
.WithSystemWebViewOptions(GetSystemWebViewOptions())
406469
.ExecuteAsync();
470+
flow = MicrosoftAuthenticationFlow.SystemWebView;
407471
break;
408472

409473
case InteractiveFlowType.DeviceCode:
@@ -412,6 +476,7 @@ public async Task<IMicrosoftAuthenticationResult> GetTokenForUserAsync(
412476
// TODO: introduce a small GUI window to show a code if no TTY exists
413477
ThrowIfTerminalPromptsDisabled();
414478
result = await app.AcquireTokenWithDeviceCode(scopes, ShowDeviceCodeInTty).ExecuteAsync();
479+
flow = MicrosoftAuthenticationFlow.DeviceCode;
415480
break;
416481

417482
default:
@@ -420,7 +485,7 @@ public async Task<IMicrosoftAuthenticationResult> GetTokenForUserAsync(
420485
}
421486
}
422487

423-
return new MsalResult(result);
488+
return new MsalResult(result, flow);
424489
}
425490
finally
426491
{
@@ -438,7 +503,7 @@ public async Task<IMicrosoftAuthenticationResult> GetTokenForServicePrincipalAsy
438503
Context.Trace.WriteLine($"Sending with X5C: '{sp.SendX5C}'.");
439504
AuthenticationResult result = await app.AcquireTokenForClient(scopes).WithSendX5C(sp.SendX5C).ExecuteAsync();;
440505

441-
return new MsalResult(result);
506+
return new MsalResult(result, MicrosoftAuthenticationFlow.ServicePrincipal);
442507
}
443508
catch (Exception ex)
444509
{
@@ -462,7 +527,7 @@ public async Task<IMicrosoftAuthenticationResult> GetTokenForManagedIdentityAsyn
462527
try
463528
{
464529
AuthenticationResult result = await app.AcquireTokenForManagedIdentity(resource).ExecuteAsync();
465-
return new MsalResult(result);
530+
return new MsalResult(result, MicrosoftAuthenticationFlow.ManagedIdentity);
466531
}
467532
catch (Exception ex)
468533
{
@@ -482,7 +547,7 @@ public async Task<IMicrosoftAuthenticationResult> GetTokenUsingWorkloadFederatio
482547
.ExecuteAsync()
483548
.ConfigureAwait(false);
484549

485-
return new MsalResult(result);
550+
return new MsalResult(result, MicrosoftAuthenticationFlow.WorkloadFederation);
486551
}
487552

488553
private async Task<string> GetClientAssertion(MicrosoftWorkloadFederationOptions fedOpts, AssertionRequestOptions _)
@@ -1173,14 +1238,27 @@ internal enum InteractiveFlowType
11731238

11741239
private class MsalResult : IMicrosoftAuthenticationResult
11751240
{
1176-
public MsalResult(AuthenticationResult msalResult)
1241+
public MsalResult(AuthenticationResult msalResult, MicrosoftAuthenticationFlow flow)
11771242
{
11781243
AccessToken = msalResult.AccessToken;
11791244
Account = MicrosoftAccount.FromMsalAccount(msalResult.Account);
1245+
Flow = flow;
11801246
}
11811247

11821248
public string AccessToken { get; }
11831249
public IMicrosoftAccount Account { get; }
1250+
public MicrosoftAuthenticationFlow Flow { get; }
11841251
}
1252+
1253+
/// <summary>
1254+
/// Classify a silently-acquired user-flow result by inspecting MSAL's
1255+
/// <see cref="AuthenticationResultMetadata.TokenSource"/>: tokens returned by the OS broker
1256+
/// are <see cref="MicrosoftAuthenticationFlow.BrokerSilent"/>, everything else (MSAL's
1257+
/// own cache, or a refresh against the identity provider) is <see cref="MicrosoftAuthenticationFlow.Silent"/>.
1258+
/// </summary>
1259+
private static MicrosoftAuthenticationFlow ClassifySilent(AuthenticationResult result) =>
1260+
result.AuthenticationResultMetadata?.TokenSource == TokenSource.Broker
1261+
? MicrosoftAuthenticationFlow.BrokerSilent
1262+
: MicrosoftAuthenticationFlow.Silent;
11851263
}
11861264
}

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)