From 8c7decc4307ccc23e6efe159141964c95c8ae8f3 Mon Sep 17 00:00:00 2001 From: Missy Messa Date: Tue, 18 Aug 2026 13:54:44 -0700 Subject: [PATCH 1/4] Add Entra authentication to the Helix API client Use environment-specific scopes and Azure.Core's expiry-aware Bearer policy for TokenCredential callers while preserving PAT overloads during migration. AB#12269 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e890b71a-c1aa-416c-a15c-be8da9fdd9b4 --- .../Client/CSharp/ApiFactory.cs | 27 +++++ .../Client/CSharp/HelixApiOptions.cs | 69 +++++++++++- .../HelixApiAuthenticationTests.cs | 102 ++++++++++++++++++ .../Microsoft.DotNet.Helix.Sdk.Tests.csproj | 1 + 4 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/HelixApiAuthenticationTests.cs diff --git a/src/Microsoft.DotNet.Helix/Client/CSharp/ApiFactory.cs b/src/Microsoft.DotNet.Helix/Client/CSharp/ApiFactory.cs index f5f6f7cec00..15743d5d7da 100644 --- a/src/Microsoft.DotNet.Helix/Client/CSharp/ApiFactory.cs +++ b/src/Microsoft.DotNet.Helix/Client/CSharp/ApiFactory.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using Azure.Core; namespace Microsoft.DotNet.Helix.Client { @@ -20,6 +21,15 @@ public static IHelixApi GetAuthenticated(string accessToken) return new HelixApi(new HelixApiOptions(new HelixApiTokenCredential(accessToken))); } + /// + /// Obtains an API client using an Entra credential for authenticated access to internal queues. + /// The client requests the production Helix API scope and refreshes tokens based on their expiry. + /// + public static IHelixApi GetAuthenticated(TokenCredential credential) + { + return new HelixApi(new HelixApiOptions(credential)); + } + /// /// Obtains API client for unauthenticated access to external queues. /// The client will access production Helix instance. @@ -46,6 +56,23 @@ public static IHelixApi GetAuthenticated(string baseUri, string accessToken) return new HelixApi(new HelixApiOptions(new Uri(baseUri), new HelixApiTokenCredential(accessToken))); } + /// + /// Obtains an API client using an Entra credential for authenticated access to the provided Helix instance. + /// Production and staging scopes are selected from the base URI. + /// + public static IHelixApi GetAuthenticated(string baseUri, TokenCredential credential) + { + return new HelixApi(new HelixApiOptions(new Uri(baseUri), credential)); + } + + /// + /// Obtains an API client using an Entra credential and explicit scope for a custom Helix instance. + /// + public static IHelixApi GetAuthenticated(string baseUri, TokenCredential credential, string scope) + { + return new HelixApi(new HelixApiOptions(new Uri(baseUri), credential, new[] { scope })); + } + /// /// Obtains API client for unauthenticated access to external queues. /// The client will access Helix instance at the provided URI. diff --git a/src/Microsoft.DotNet.Helix/Client/CSharp/HelixApiOptions.cs b/src/Microsoft.DotNet.Helix/Client/CSharp/HelixApiOptions.cs index e525ec33435..4ac1a384e62 100644 --- a/src/Microsoft.DotNet.Helix/Client/CSharp/HelixApiOptions.cs +++ b/src/Microsoft.DotNet.Helix/Client/CSharp/HelixApiOptions.cs @@ -2,26 +2,93 @@ // The .NET Foundation licenses this file to you under the MIT license. using Azure.Core; +using Azure.Core.Pipeline; using System; +using System.Collections.Generic; +using System.Linq; namespace Microsoft.DotNet.Helix.Client { + public enum HelixApiAuthenticationMode + { + Anonymous, + PersonalAccessToken, + EntraId, + } + partial class HelixApiOptions { + public const string ProductionScope = "api://eb70c40b-c265-44f7-842e-1a568f035f33/.default"; + public const string StagingScope = "api://f45b17a4-149b-4f89-91bc-e6331af8d0e8/.default"; + // See https://github.com/Azure/azure-sdk-for-net/blob/master/sdk/core/Azure.Core/src/RetryOptions.cs for values this overrides public const int DefaultRetryDelaySeconds = 10; public const int DefaultMaxRetryCount = 5; + public HelixApiOptions(Uri baseUri, TokenCredential credentials, IEnumerable scopes) + { + BaseUri = baseUri ?? throw new ArgumentNullException(nameof(baseUri)); + Credentials = credentials ?? throw new ArgumentNullException(nameof(credentials)); + string[] tokenScopes = scopes?.ToArray() ?? throw new ArgumentNullException(nameof(scopes)); + if (tokenScopes.Length == 0 || tokenScopes.Any(string.IsNullOrWhiteSpace)) + { + throw new ArgumentException("At least one non-empty token scope is required.", nameof(scopes)); + } + TokenScopes = Array.AsReadOnly(tokenScopes); + + InitializeOptions(); + } + + public HelixApiAuthenticationMode AuthenticationMode { get; private set; } + + public IReadOnlyList TokenScopes { get; private set; } = Array.Empty(); + partial void InitializeOptions() { - if (Credentials != null) + if (Credentials == null) + { + AuthenticationMode = HelixApiAuthenticationMode.Anonymous; + } + else if (Credentials is HelixApiTokenCredential) { + AuthenticationMode = HelixApiAuthenticationMode.PersonalAccessToken; + TokenScopes = Array.Empty(); AddPolicy(new HelixApiTokenAuthenticationPolicy(Credentials), HttpPipelinePosition.PerCall); } + else + { + AuthenticationMode = HelixApiAuthenticationMode.EntraId; + if (TokenScopes.Count == 0) + { + TokenScopes = Array.AsReadOnly(new[] { GetDefaultScope(BaseUri) }); + } + + AddPolicy( + new BearerTokenAuthenticationPolicy(Credentials, TokenScopes.ToArray()), + HttpPipelinePosition.PerRetry); + } // Users should not generally need to modify these but can do so after creating a HelixApi object if needed Retry.Delay = TimeSpan.FromSeconds(DefaultRetryDelaySeconds); Retry.MaxRetries = DefaultMaxRetryCount; } + + private static string GetDefaultScope(Uri baseUri) + { + if (baseUri.Host.Equals("helix.dot.net", StringComparison.OrdinalIgnoreCase)) + { + return ProductionScope; + } + + if (baseUri.Host.Equals("helix.int-dot.net", StringComparison.OrdinalIgnoreCase)) + { + return StagingScope; + } + + throw new ArgumentException( + $"No default Entra scope is known for Helix API host '{baseUri.Host}'. " + + "Use the HelixApiOptions constructor that accepts explicit scopes.", + nameof(baseUri)); + } } } diff --git a/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/HelixApiAuthenticationTests.cs b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/HelixApiAuthenticationTests.cs new file mode 100644 index 00000000000..eb73ff39a16 --- /dev/null +++ b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/HelixApiAuthenticationTests.cs @@ -0,0 +1,102 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Microsoft.DotNet.Helix.Client; +using Xunit; + +namespace Microsoft.DotNet.Helix.Sdk.Tests +{ + public class HelixApiAuthenticationTests + { + [Fact] + public void AnonymousOptionsExposeAnonymousMode() + { + var options = new HelixApiOptions(); + + Assert.Equal(HelixApiAuthenticationMode.Anonymous, options.AuthenticationMode); + Assert.Empty(options.TokenScopes); + } + + [Fact] + public void PatCredentialPreservesLegacyAuthenticationMode() + { + var options = new HelixApiOptions(new HelixApiTokenCredential("legacy-token")); + + Assert.Equal(HelixApiAuthenticationMode.PersonalAccessToken, options.AuthenticationMode); + Assert.Empty(options.TokenScopes); + } + + [Fact] + public void ProductionCredentialUsesProductionScope() + { + var options = new HelixApiOptions(new TestTokenCredential()); + + Assert.Equal(HelixApiAuthenticationMode.EntraId, options.AuthenticationMode); + Assert.Equal(new[] { HelixApiOptions.ProductionScope }, options.TokenScopes); + } + + [Fact] + public void StagingCredentialUsesStagingScope() + { + var options = new HelixApiOptions( + new Uri("https://helix.int-dot.net/"), + new TestTokenCredential()); + + Assert.Equal(HelixApiAuthenticationMode.EntraId, options.AuthenticationMode); + Assert.Equal(new[] { HelixApiOptions.StagingScope }, options.TokenScopes); + } + + [Fact] + public void CustomHostRequiresExplicitScope() + { + var exception = Assert.Throws(() => + new HelixApiOptions(new Uri("https://localhost:5001/"), new TestTokenCredential())); + + Assert.Contains("explicit scopes", exception.Message); + } + + [Fact] + public void CustomHostUsesExplicitScope() + { + const string scope = "api://custom-helix/.default"; + var options = new HelixApiOptions( + new Uri("https://localhost:5001/"), + new TestTokenCredential(), + new[] { scope }); + + Assert.Equal(HelixApiAuthenticationMode.EntraId, options.AuthenticationMode); + Assert.Equal(new[] { scope }, options.TokenScopes); + } + + [Fact] + public void ExplicitScopeCannotBeEmpty() + { + Assert.Throws(() => + new HelixApiOptions( + new Uri("https://localhost:5001/"), + new TestTokenCredential(), + new[] { "" })); + } + + private sealed class TestTokenCredential : TokenCredential + { + public override AccessToken GetToken( + TokenRequestContext requestContext, + CancellationToken cancellationToken) + { + return new AccessToken("test-token", DateTimeOffset.UtcNow.AddMinutes(30)); + } + + public override ValueTask GetTokenAsync( + TokenRequestContext requestContext, + CancellationToken cancellationToken) + { + return new ValueTask(GetToken(requestContext, cancellationToken)); + } + } + } +} diff --git a/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests.csproj b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests.csproj index 9a9eab6b519..11c5eee5891 100644 --- a/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests.csproj +++ b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests.csproj @@ -22,6 +22,7 @@ + From 520cbc1fb9716486b63ed71ef1cabd2bca5cdff0 Mon Sep 17 00:00:00 2001 From: Missy Messa Date: Tue, 18 Aug 2026 14:02:24 -0700 Subject: [PATCH 2/4] Address Helix Entra client review feedback Use distinct Entra factory method names to preserve source compatibility and reject PAT credentials when explicit OAuth scopes are supplied. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e890b71a-c1aa-416c-a15c-be8da9fdd9b4 --- .../Client/CSharp/ApiFactory.cs | 9 +++++--- .../Client/CSharp/HelixApiOptions.cs | 8 +++++++ .../HelixApiAuthenticationTests.cs | 22 +++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.DotNet.Helix/Client/CSharp/ApiFactory.cs b/src/Microsoft.DotNet.Helix/Client/CSharp/ApiFactory.cs index 15743d5d7da..9821adfed1f 100644 --- a/src/Microsoft.DotNet.Helix/Client/CSharp/ApiFactory.cs +++ b/src/Microsoft.DotNet.Helix/Client/CSharp/ApiFactory.cs @@ -25,7 +25,7 @@ public static IHelixApi GetAuthenticated(string accessToken) /// Obtains an API client using an Entra credential for authenticated access to internal queues. /// The client requests the production Helix API scope and refreshes tokens based on their expiry. /// - public static IHelixApi GetAuthenticated(TokenCredential credential) + public static IHelixApi GetAuthenticatedWithEntra(TokenCredential credential) { return new HelixApi(new HelixApiOptions(credential)); } @@ -60,7 +60,7 @@ public static IHelixApi GetAuthenticated(string baseUri, string accessToken) /// Obtains an API client using an Entra credential for authenticated access to the provided Helix instance. /// Production and staging scopes are selected from the base URI. /// - public static IHelixApi GetAuthenticated(string baseUri, TokenCredential credential) + public static IHelixApi GetAuthenticatedWithEntra(string baseUri, TokenCredential credential) { return new HelixApi(new HelixApiOptions(new Uri(baseUri), credential)); } @@ -68,7 +68,10 @@ public static IHelixApi GetAuthenticated(string baseUri, TokenCredential credent /// /// Obtains an API client using an Entra credential and explicit scope for a custom Helix instance. /// - public static IHelixApi GetAuthenticated(string baseUri, TokenCredential credential, string scope) + public static IHelixApi GetAuthenticatedWithEntra( + string baseUri, + TokenCredential credential, + string scope) { return new HelixApi(new HelixApiOptions(new Uri(baseUri), credential, new[] { scope })); } diff --git a/src/Microsoft.DotNet.Helix/Client/CSharp/HelixApiOptions.cs b/src/Microsoft.DotNet.Helix/Client/CSharp/HelixApiOptions.cs index 4ac1a384e62..7fa26f36a73 100644 --- a/src/Microsoft.DotNet.Helix/Client/CSharp/HelixApiOptions.cs +++ b/src/Microsoft.DotNet.Helix/Client/CSharp/HelixApiOptions.cs @@ -29,6 +29,14 @@ public HelixApiOptions(Uri baseUri, TokenCredential credentials, IEnumerable(() => + new HelixApiOptions( + new Uri("https://localhost:5001/"), + new HelixApiTokenCredential("legacy-token"), + new[] { "api://custom-helix/.default" })); + + Assert.Contains("PAT-specific", exception.Message); + } + + [Fact] + public void EntraFactoryUsesDistinctApiNameAndProductionScope() + { + var api = Assert.IsType( + ApiFactory.GetAuthenticatedWithEntra(new TestTokenCredential())); + + Assert.Equal(HelixApiAuthenticationMode.EntraId, api.Options.AuthenticationMode); + Assert.Equal(new[] { HelixApiOptions.ProductionScope }, api.Options.TokenScopes); + } + private sealed class TestTokenCredential : TokenCredential { public override AccessToken GetToken( From 40e1df39ad616a06d01726a8c74961d46e09bd13 Mon Sep 17 00:00:00 2001 From: Missy Messa Date: Mon, 24 Aug 2026 07:16:55 -0700 Subject: [PATCH 3/4] Validate Helix Entra authentication inputs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0e1a942f-a44f-4e3a-8d35-af3fe8bee535 --- .../Client/CSharp/ApiFactory.cs | 15 ++++++++++ .../Client/CSharp/HelixApiOptions.cs | 10 +++++++ .../HelixApiAuthenticationTests.cs | 28 +++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/src/Microsoft.DotNet.Helix/Client/CSharp/ApiFactory.cs b/src/Microsoft.DotNet.Helix/Client/CSharp/ApiFactory.cs index 9821adfed1f..ba551d5cadc 100644 --- a/src/Microsoft.DotNet.Helix/Client/CSharp/ApiFactory.cs +++ b/src/Microsoft.DotNet.Helix/Client/CSharp/ApiFactory.cs @@ -27,6 +27,11 @@ public static IHelixApi GetAuthenticated(string accessToken) /// public static IHelixApi GetAuthenticatedWithEntra(TokenCredential credential) { + if (credential == null) + { + throw new ArgumentNullException(nameof(credential)); + } + return new HelixApi(new HelixApiOptions(credential)); } @@ -62,6 +67,11 @@ public static IHelixApi GetAuthenticated(string baseUri, string accessToken) /// public static IHelixApi GetAuthenticatedWithEntra(string baseUri, TokenCredential credential) { + if (credential == null) + { + throw new ArgumentNullException(nameof(credential)); + } + return new HelixApi(new HelixApiOptions(new Uri(baseUri), credential)); } @@ -73,6 +83,11 @@ public static IHelixApi GetAuthenticatedWithEntra( TokenCredential credential, string scope) { + if (credential == null) + { + throw new ArgumentNullException(nameof(credential)); + } + return new HelixApi(new HelixApiOptions(new Uri(baseUri), credential, new[] { scope })); } diff --git a/src/Microsoft.DotNet.Helix/Client/CSharp/HelixApiOptions.cs b/src/Microsoft.DotNet.Helix/Client/CSharp/HelixApiOptions.cs index 7fa26f36a73..ded5e7e7e29 100644 --- a/src/Microsoft.DotNet.Helix/Client/CSharp/HelixApiOptions.cs +++ b/src/Microsoft.DotNet.Helix/Client/CSharp/HelixApiOptions.cs @@ -83,6 +83,16 @@ partial void InitializeOptions() private static string GetDefaultScope(Uri baseUri) { + if (baseUri == null) + { + throw new ArgumentNullException(nameof(baseUri)); + } + + if (!baseUri.IsAbsoluteUri) + { + throw new ArgumentException("The Helix API base URI must be absolute.", nameof(baseUri)); + } + if (baseUri.Host.Equals("helix.dot.net", StringComparison.OrdinalIgnoreCase)) { return ProductionScope; diff --git a/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/HelixApiAuthenticationTests.cs b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/HelixApiAuthenticationTests.cs index c822a537269..fd04682fb25 100644 --- a/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/HelixApiAuthenticationTests.cs +++ b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/HelixApiAuthenticationTests.cs @@ -59,6 +59,20 @@ public void CustomHostRequiresExplicitScope() Assert.Contains("explicit scopes", exception.Message); } + [Fact] + public void EntraCredentialRequiresBaseUri() + { + Assert.Throws(() => + new HelixApiOptions(null, new TestTokenCredential())); + } + + [Fact] + public void EntraCredentialRequiresAbsoluteBaseUri() + { + Assert.Throws(() => + new HelixApiOptions(new Uri("relative", UriKind.Relative), new TestTokenCredential())); + } + [Fact] public void CustomHostUsesExplicitScope() { @@ -104,6 +118,20 @@ public void EntraFactoryUsesDistinctApiNameAndProductionScope() Assert.Equal(new[] { HelixApiOptions.ProductionScope }, api.Options.TokenScopes); } + [Fact] + public void EntraFactoryRequiresCredential() + { + Assert.Throws(() => + ApiFactory.GetAuthenticatedWithEntra(null)); + Assert.Throws(() => + ApiFactory.GetAuthenticatedWithEntra("https://helix.dot.net/", null)); + Assert.Throws(() => + ApiFactory.GetAuthenticatedWithEntra( + "https://localhost:5001/", + null, + "api://custom-helix/.default")); + } + private sealed class TestTokenCredential : TokenCredential { public override AccessToken GetToken( From a6540a44f820d4fca8357a25c6e6115b39a2c16d Mon Sep 17 00:00:00 2001 From: Missy Messa Date: Mon, 24 Aug 2026 10:17:35 -0700 Subject: [PATCH 4/4] Address Helix Entra credential feedback Reject PAT credentials from the Entra factory methods, clarify direct HelixApiOptions guidance, and align the authentication test name with its assertions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9d86c2f6-8c72-4ce8-933a-841a368be1d9 --- .../Client/CSharp/ApiFactory.cs | 41 +++++++++++-------- .../Client/CSharp/HelixApiOptions.cs | 2 +- .../HelixApiAuthenticationTests.cs | 24 ++++++++++- 3 files changed, 46 insertions(+), 21 deletions(-) diff --git a/src/Microsoft.DotNet.Helix/Client/CSharp/ApiFactory.cs b/src/Microsoft.DotNet.Helix/Client/CSharp/ApiFactory.cs index ba551d5cadc..427101e2cef 100644 --- a/src/Microsoft.DotNet.Helix/Client/CSharp/ApiFactory.cs +++ b/src/Microsoft.DotNet.Helix/Client/CSharp/ApiFactory.cs @@ -27,12 +27,7 @@ public static IHelixApi GetAuthenticated(string accessToken) /// public static IHelixApi GetAuthenticatedWithEntra(TokenCredential credential) { - if (credential == null) - { - throw new ArgumentNullException(nameof(credential)); - } - - return new HelixApi(new HelixApiOptions(credential)); + return new HelixApi(new HelixApiOptions(ValidateEntraCredential(credential))); } /// @@ -67,12 +62,7 @@ public static IHelixApi GetAuthenticated(string baseUri, string accessToken) /// public static IHelixApi GetAuthenticatedWithEntra(string baseUri, TokenCredential credential) { - if (credential == null) - { - throw new ArgumentNullException(nameof(credential)); - } - - return new HelixApi(new HelixApiOptions(new Uri(baseUri), credential)); + return new HelixApi(new HelixApiOptions(new Uri(baseUri), ValidateEntraCredential(credential))); } /// @@ -83,12 +73,10 @@ public static IHelixApi GetAuthenticatedWithEntra( TokenCredential credential, string scope) { - if (credential == null) - { - throw new ArgumentNullException(nameof(credential)); - } - - return new HelixApi(new HelixApiOptions(new Uri(baseUri), credential, new[] { scope })); + return new HelixApi(new HelixApiOptions( + new Uri(baseUri), + ValidateEntraCredential(credential), + new[] { scope })); } /// @@ -103,5 +91,22 @@ public static IHelixApi GetAnonymous(string baseUri) { return new HelixApi(new HelixApiOptions(new Uri(baseUri))); } + + private static TokenCredential ValidateEntraCredential(TokenCredential credential) + { + if (credential == null) + { + throw new ArgumentNullException(nameof(credential)); + } + + if (credential is HelixApiTokenCredential) + { + throw new ArgumentException( + "HelixApiTokenCredential represents a PAT. Use GetAuthenticated(...) for PAT authentication.", + nameof(credential)); + } + + return credential; + } } } diff --git a/src/Microsoft.DotNet.Helix/Client/CSharp/HelixApiOptions.cs b/src/Microsoft.DotNet.Helix/Client/CSharp/HelixApiOptions.cs index f2dd5c6eb9a..9e44c61994b 100644 --- a/src/Microsoft.DotNet.Helix/Client/CSharp/HelixApiOptions.cs +++ b/src/Microsoft.DotNet.Helix/Client/CSharp/HelixApiOptions.cs @@ -33,7 +33,7 @@ public HelixApiOptions(Uri baseUri, TokenCredential credentials, IEnumerable( ApiFactory.GetAuthenticatedWithEntra(new TestTokenCredential())); @@ -142,6 +142,26 @@ public void EntraFactoryRequiresCredential() "api://custom-helix/.default")); } + [Fact] + public void EntraFactoryRejectsPatCredential() + { + var credential = new HelixApiTokenCredential("legacy-token"); + + var productionException = Assert.Throws(() => + ApiFactory.GetAuthenticatedWithEntra(credential)); + var hostException = Assert.Throws(() => + ApiFactory.GetAuthenticatedWithEntra("https://helix.dot.net/", credential)); + var explicitScopeException = Assert.Throws(() => + ApiFactory.GetAuthenticatedWithEntra( + "https://localhost:5001/", + credential, + "api://custom-helix/.default")); + + Assert.Contains("GetAuthenticated", productionException.Message); + Assert.Contains("GetAuthenticated", hostException.Message); + Assert.Contains("GetAuthenticated", explicitScopeException.Message); + } + private sealed class TestTokenCredential : TokenCredential { public override AccessToken GetToken(