diff --git a/src/Microsoft.DotNet.Helix/Client/CSharp/ApiFactory.cs b/src/Microsoft.DotNet.Helix/Client/CSharp/ApiFactory.cs
index f5f6f7cec00..427101e2cef 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 GetAuthenticatedWithEntra(TokenCredential credential)
+ {
+ return new HelixApi(new HelixApiOptions(ValidateEntraCredential(credential)));
+ }
+
///
/// Obtains API client for unauthenticated access to external queues.
/// The client will access production Helix instance.
@@ -46,6 +56,29 @@ 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 GetAuthenticatedWithEntra(string baseUri, TokenCredential credential)
+ {
+ return new HelixApi(new HelixApiOptions(new Uri(baseUri), ValidateEntraCredential(credential)));
+ }
+
+ ///
+ /// Obtains an API client using an Entra credential and explicit scope for a custom Helix instance.
+ ///
+ public static IHelixApi GetAuthenticatedWithEntra(
+ string baseUri,
+ TokenCredential credential,
+ string scope)
+ {
+ return new HelixApi(new HelixApiOptions(
+ new Uri(baseUri),
+ ValidateEntraCredential(credential),
+ new[] { scope }));
+ }
+
///
/// Obtains API client for unauthenticated access to external queues.
/// The client will access Helix instance at the provided URI.
@@ -58,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 e525ec33435..9e44c61994b 100644
--- a/src/Microsoft.DotNet.Helix/Client/CSharp/HelixApiOptions.cs
+++ b/src/Microsoft.DotNet.Helix/Client/CSharp/HelixApiOptions.cs
@@ -2,26 +2,118 @@
// 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 = ValidateBaseUri(baseUri);
+ Credentials = credentials ?? throw new ArgumentNullException(nameof(credentials));
+ if (credentials is HelixApiTokenCredential)
+ {
+ throw new ArgumentException(
+ "Explicit scopes are only supported for Entra credentials. " +
+ "For PAT authentication, pass HelixApiTokenCredential without explicit scopes.",
+ 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)
+ {
+ baseUri = ValidateBaseUri(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));
+ }
+
+ private static Uri ValidateBaseUri(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));
+ }
+
+ return 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..8772bb4b519
--- /dev/null
+++ b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/HelixApiAuthenticationTests.cs
@@ -0,0 +1,182 @@
+// 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 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()
+ {
+ 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[] { "" }));
+ }
+
+ [Fact]
+ public void ExplicitScopeRequiresAbsoluteBaseUri()
+ {
+ Assert.Throws(() =>
+ new HelixApiOptions(
+ new Uri("relative", UriKind.Relative),
+ new TestTokenCredential(),
+ new[] { "api://custom-helix/.default" }));
+ }
+
+ [Fact]
+ public void ExplicitScopeRejectsPatCredential()
+ {
+ var exception = Assert.Throws(() =>
+ new HelixApiOptions(
+ new Uri("https://localhost:5001/"),
+ new HelixApiTokenCredential("legacy-token"),
+ new[] { "api://custom-helix/.default" }));
+
+ Assert.Contains("without explicit scopes", exception.Message);
+ }
+
+ [Fact]
+ public void EntraFactoryUsesProductionScope()
+ {
+ var api = Assert.IsType(
+ ApiFactory.GetAuthenticatedWithEntra(new TestTokenCredential()));
+
+ Assert.Equal(HelixApiAuthenticationMode.EntraId, api.Options.AuthenticationMode);
+ 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"));
+ }
+
+ [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(
+ 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 c464b912e7d..2038e551ea9 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
@@ -21,6 +21,7 @@
+