From 05d5fad50153c29724dd208ea09d5d02bb254741 Mon Sep 17 00:00:00 2001
From: Missy Messa <47990216+missymessa@users.noreply.github.com>
Date: Tue, 25 Aug 2026 00:58:38 -0700
Subject: [PATCH 1/4] Add Entra authentication to the Helix API client (#17366)
Copilot-Session: e890b71a-c1aa-416c-a15c-be8da9fdd9b4
Copilot-Session: 0e1a942f-a44f-4e3a-8d35-af3fe8bee535
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Client/CSharp/ApiFactory.cs | 50 +++++
.../Client/CSharp/HelixApiOptions.cs | 94 ++++++++-
.../HelixApiAuthenticationTests.cs | 182 ++++++++++++++++++
.../Microsoft.DotNet.Helix.Sdk.Tests.csproj | 1 +
4 files changed, 326 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 769695bd6ed..699c3804989 100644
--- a/src/Microsoft.DotNet.Helix/Client/CSharp/ApiFactory.cs
+++ b/src/Microsoft.DotNet.Helix/Client/CSharp/ApiFactory.cs
@@ -1,4 +1,5 @@
using System;
+using Azure.Core;
namespace Microsoft.DotNet.Helix.Client
{
@@ -17,6 +18,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.
@@ -43,6 +53,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.
@@ -55,5 +88,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 ee0953aebc1..478d940cef2 100644
--- a/src/Microsoft.DotNet.Helix/Client/CSharp/HelixApiOptions.cs
+++ b/src/Microsoft.DotNet.Helix/Client/CSharp/HelixApiOptions.cs
@@ -1,24 +1,116 @@
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 59674e286e1..b5342f2286c 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
@@ -20,6 +20,7 @@
+
From aedcc61a76b7124105f6fdd7d9412c7b3f2cf95f Mon Sep 17 00:00:00 2001
From: Missy Messa <47990216+missymessa@users.noreply.github.com>
Date: Wed, 9 Sep 2026 17:32:44 -0700
Subject: [PATCH 2/4] [12269] Test Helix Entra token refresh (#17510)
Copilot-Session: a6ad3a92-2023-4c67-8948-f6d34de1a67c
Copilot-Session: 521e657d-a005-4f60-bcff-25a05ebcc390
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../HelixApiAuthenticationTests.cs | 67 +++++++++++++++++++
1 file changed, 67 insertions(+)
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 8772bb4b519..d89e22dbcbc 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
@@ -2,9 +2,13 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System;
+using System.Net;
+using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Azure.Core;
+using Azure.Core.Pipeline;
+using Microsoft.Arcade.Test.Common;
using Microsoft.DotNet.Helix.Client;
using Xunit;
@@ -162,6 +166,41 @@ public void EntraFactoryRejectsPatCredential()
Assert.Contains("GetAuthenticated", explicitScopeException.Message);
}
+ [Fact]
+ public async Task EntraCredentialReacquiresTokenAfterExpiration()
+ {
+ TimeSpan tokenLifetime = TimeSpan.FromMilliseconds(200);
+ TimeSpan expirationMargin = TimeSpan.FromMilliseconds(300);
+ var credential = new ShortLivedTokenCredential(tokenLifetime);
+ using var httpClient = FakeHttpClient.WithResponses(
+ new HttpResponseMessage(HttpStatusCode.OK),
+ new HttpResponseMessage(HttpStatusCode.OK));
+ var options = new HelixApiOptions(
+ new Uri("https://helix.dot.net/"),
+ credential)
+ {
+ Transport = new HttpClientTransport(httpClient),
+ };
+ var api = new HelixApi(options);
+
+ using HttpMessage firstMessage = api.Pipeline.CreateMessage();
+ firstMessage.Request.Method = RequestMethod.Get;
+ firstMessage.Request.Uri.Reset(options.BaseUri);
+ await api.Pipeline.SendAsync(firstMessage, CancellationToken.None);
+ Assert.True(firstMessage.Request.Headers.TryGetValue("Authorization", out string firstAuthorization));
+
+ await Task.Delay(tokenLifetime + expirationMargin);
+
+ using HttpMessage secondMessage = api.Pipeline.CreateMessage();
+ secondMessage.Request.Method = RequestMethod.Get;
+ secondMessage.Request.Uri.Reset(options.BaseUri);
+ await api.Pipeline.SendAsync(secondMessage, CancellationToken.None);
+ Assert.True(secondMessage.Request.Headers.TryGetValue("Authorization", out string secondAuthorization));
+
+ Assert.Equal(2, credential.CallCount);
+ Assert.NotEqual(firstAuthorization, secondAuthorization);
+ }
+
private sealed class TestTokenCredential : TokenCredential
{
public override AccessToken GetToken(
@@ -178,5 +217,33 @@ public override ValueTask GetTokenAsync(
return new ValueTask(GetToken(requestContext, cancellationToken));
}
}
+
+ private sealed class ShortLivedTokenCredential : TokenCredential
+ {
+ private readonly TimeSpan _lifetime;
+
+ public ShortLivedTokenCredential(TimeSpan lifetime)
+ {
+ _lifetime = lifetime;
+ }
+
+ public int CallCount { get; private set; }
+
+ public override AccessToken GetToken(
+ TokenRequestContext requestContext,
+ CancellationToken cancellationToken)
+ {
+ return new AccessToken(
+ $"test-token-{++CallCount}",
+ DateTimeOffset.UtcNow.Add(_lifetime));
+ }
+
+ public override ValueTask GetTokenAsync(
+ TokenRequestContext requestContext,
+ CancellationToken cancellationToken)
+ {
+ return new ValueTask(GetToken(requestContext, cancellationToken));
+ }
+ }
}
}
From d91b6f4729704e8abfa9ea206239194adba79308 Mon Sep 17 00:00:00 2001
From: Missy Messa <47990216+missymessa@users.noreply.github.com>
Date: Mon, 14 Sep 2026 08:17:26 -0700
Subject: [PATCH 3/4] Integrate refreshable Helix Entra auth with SDK tasks
(#17537)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4aa6b2e8-2313-40c0-92f6-1d578db0af15
Copilot-Session: a891597e-6dca-4706-8628-004c5837d0c9
---
.../AzureDevOps/SendingJobsToHelix.md | 23 ++++-
.../steps/send-to-helix.yml | 42 ++++++++-
eng/common/templates/steps/send-to-helix.yml | 42 ++++++++-
.../Microsoft.DotNet.Helix.Client.csproj | 6 +-
.../HelixApiAuthenticationTests.cs | 80 +++++++++++++++-
.../Microsoft.DotNet.Helix.Sdk.Tests.csproj | 2 +-
.../Sdk/CancelHelixJob.cs | 19 +++-
.../Sdk/GetHelixWorkItems.cs | 24 +++--
src/Microsoft.DotNet.Helix/Sdk/HelixTask.cs | 91 ++++++++++++++++++-
.../Sdk/Microsoft.DotNet.Helix.Sdk.csproj | 9 ++
src/Microsoft.DotNet.Helix/Sdk/Readme.md | 14 ++-
.../Sdk/SendHelixJob.cs | 30 ++++--
...crosoft.DotNet.Helix.Sdk.MonoQueue.targets | 1 +
...rosoft.DotNet.Helix.Sdk.MultiQueue.targets | 3 +
.../tools/Microsoft.DotNet.Helix.Sdk.props | 1 +
15 files changed, 350 insertions(+), 37 deletions(-)
diff --git a/Documentation/AzureDevOps/SendingJobsToHelix.md b/Documentation/AzureDevOps/SendingJobsToHelix.md
index 7bfe6f68ba2..6ce6f6e1ef9 100644
--- a/Documentation/AzureDevOps/SendingJobsToHelix.md
+++ b/Documentation/AzureDevOps/SendingJobsToHelix.md
@@ -57,10 +57,30 @@ steps:
### Internal builds
-In the dev.azure.com/dnceng/internal project, you can use the `DotNet-HelixApi-Access` variable group to provide this secret to your build and then specify the `HelixApiAccessToken` secret for the `HelixAccessToken` parameter.
+Internal builds can authenticate with Entra ID through an Azure service connection or with a legacy Helix access token.
Please note that authorized jobs *cannot* be submitted to queues with `IsInternalOnly` set to false. To determine this value for a particular queue, see the list of available queues [here](https://helix.dot.net/api/2018-03-14/info/queues).
+#### Entra ID authentication
+
+Set `HelixUseEntraAuthentication` to `true` and pass an Azure service connection authorized for Helix through `HelixAzureSubscription`. These parameters configure the `send-to-helix.yml` steps template and the SDK tasks that submit jobs.
+
+When Entra authentication is enabled, the template does not forward `HelixAccessToken` to the Helix processes. If a legacy token is still injected by a variable group, explicit Entra opt-in takes precedence and the task ignores the token with a warning.
+
+```yaml
+steps:
+- template: /eng/common/templates/steps/send-to-helix.yml
+ displayName: Send to Helix
+ parameters:
+ HelixUseEntraAuthentication: true
+ HelixAzureSubscription:
+ # other parameters here
+```
+
+#### Legacy access-token authentication
+
+In the dev.azure.com/dnceng/internal project, you can use the `DotNet-HelixApi-Access` variable group to provide this secret to your build and then specify the `HelixApiAccessToken` secret for the `HelixAccessToken` parameter.
+
Example:
```yaml
@@ -155,4 +175,3 @@ As surfaced by the Helix API and backing Kusto (Azure Data Explorer) database, h
- InfraRetry – Work item completed as expected, but on the 2nd-Nth attempt; this can be a requested-by-the-workitem retry, machine being rebooted or deleted during execution, or any number of random Azure components being flaky. Typically ignoreable for test runs.
- PassOnRetry – Special legacy retry functionality which is purposefully obsoleted as it does not play well with Azure DevOps test reporting (reporting the same facts twice causes issues)
- Timeout – Work Item did not complete within its specified timeout and was forcibly killed. Corresponds to exit code -3 (made up value since the process never exited)
-
diff --git a/eng/common/templates-official/steps/send-to-helix.yml b/eng/common/templates-official/steps/send-to-helix.yml
index cd02ae1607f..619896b1aa9 100644
--- a/eng/common/templates-official/steps/send-to-helix.yml
+++ b/eng/common/templates-official/steps/send-to-helix.yml
@@ -4,7 +4,9 @@ parameters:
HelixType: 'tests/default/' # required -- Helix telemetry which identifies what type of data this is; should include "test" for clarity and must end in '/'
HelixBuild: $(Build.BuildNumber) # required -- the build number Helix will use to identify this -- automatically set to the AzDO build number
HelixTargetQueues: '' # required -- semicolon delimited list of Helix queues to test on; see https://helix.dot.net/ for a list of queues
- HelixAccessToken: '' # required -- access token to make Helix API requests; should be provided by the appropriate variable group
+ HelixAccessToken: '' # optional -- legacy access token; not forwarded when HelixUseEntraAuthentication is true
+ HelixUseEntraAuthentication: false # optional -- use refreshable Entra authentication instead of a PAT or anonymous access
+ HelixAzureSubscription: '' # required when HelixUseEntraAuthentication is true -- Azure service connection ID authorized for Helix
HelixConfiguration: '' # optional -- additional property attached to a job
HelixPreCommands: '' # optional -- commands to run before Helix work item execution
HelixPostCommands: '' # optional -- commands to run after Helix work item execution
@@ -30,6 +32,28 @@ parameters:
continueOnError: false # optional -- determines whether to continue the build if the step errors; defaults to false
steps:
+ - ${{ if and(eq(parameters.HelixUseEntraAuthentication, true), eq(parameters.HelixAzureSubscription, '')) }}:
+ - pwsh: throw "HelixAzureSubscription must be set when HelixUseEntraAuthentication is true."
+ displayName: Validate Helix Entra authentication
+ condition: ${{ parameters.condition }}
+
+ - ${{ if eq(parameters.HelixUseEntraAuthentication, true) }}:
+ - task: AzureCLI@2
+ displayName: Initialize Helix Entra authentication
+ inputs:
+ azureSubscription: ${{ parameters.HelixAzureSubscription }}
+ addSpnToEnvironment: true
+ scriptType: pscore
+ scriptLocation: inlineScript
+ inlineScript: |
+ if ([string]::IsNullOrWhiteSpace($env:servicePrincipalId) -or [string]::IsNullOrWhiteSpace($env:tenantId)) {
+ throw "The Helix Azure service connection did not provide a service principal or tenant ID."
+ }
+
+ Write-Host "##vso[task.setvariable variable=HelixEntraClientId]$env:servicePrincipalId"
+ Write-Host "##vso[task.setvariable variable=HelixEntraTenantId]$env:tenantId"
+ condition: ${{ parameters.condition }}
+
- powershell: 'powershell "$env:BUILD_SOURCESDIRECTORY\eng\common\msbuild.ps1 $env:BUILD_SOURCESDIRECTORY\eng\common\helixpublish.proj /restore /t:Test /bl:$env:BUILD_SOURCESDIRECTORY\artifacts\log\$env:BuildConfig\SendToHelix.binlog"'
displayName: ${{ parameters.DisplayNamePrefix }} (Windows)
env:
@@ -39,7 +63,13 @@ steps:
HelixBuild: ${{ parameters.HelixBuild }}
HelixConfiguration: ${{ parameters.HelixConfiguration }}
HelixTargetQueues: ${{ parameters.HelixTargetQueues }}
- HelixAccessToken: ${{ parameters.HelixAccessToken }}
+ HelixUseEntraAuthentication: ${{ parameters.HelixUseEntraAuthentication }}
+ ${{ if eq(parameters.HelixUseEntraAuthentication, false) }}:
+ HelixAccessToken: ${{ parameters.HelixAccessToken }}
+ ${{ if eq(parameters.HelixUseEntraAuthentication, true) }}:
+ AZURESUBSCRIPTION_CLIENT_ID: $(HelixEntraClientId)
+ AZURESUBSCRIPTION_TENANT_ID: $(HelixEntraTenantId)
+ AZURESUBSCRIPTION_SERVICE_CONNECTION_ID: ${{ parameters.HelixAzureSubscription }}
HelixPreCommands: ${{ parameters.HelixPreCommands }}
HelixPostCommands: ${{ parameters.HelixPostCommands }}
WorkItemDirectory: ${{ parameters.WorkItemDirectory }}
@@ -70,7 +100,13 @@ steps:
HelixBuild: ${{ parameters.HelixBuild }}
HelixConfiguration: ${{ parameters.HelixConfiguration }}
HelixTargetQueues: ${{ parameters.HelixTargetQueues }}
- HelixAccessToken: ${{ parameters.HelixAccessToken }}
+ HelixUseEntraAuthentication: ${{ parameters.HelixUseEntraAuthentication }}
+ ${{ if eq(parameters.HelixUseEntraAuthentication, false) }}:
+ HelixAccessToken: ${{ parameters.HelixAccessToken }}
+ ${{ if eq(parameters.HelixUseEntraAuthentication, true) }}:
+ AZURESUBSCRIPTION_CLIENT_ID: $(HelixEntraClientId)
+ AZURESUBSCRIPTION_TENANT_ID: $(HelixEntraTenantId)
+ AZURESUBSCRIPTION_SERVICE_CONNECTION_ID: ${{ parameters.HelixAzureSubscription }}
HelixPreCommands: ${{ parameters.HelixPreCommands }}
HelixPostCommands: ${{ parameters.HelixPostCommands }}
WorkItemDirectory: ${{ parameters.WorkItemDirectory }}
diff --git a/eng/common/templates/steps/send-to-helix.yml b/eng/common/templates/steps/send-to-helix.yml
index cd02ae1607f..619896b1aa9 100644
--- a/eng/common/templates/steps/send-to-helix.yml
+++ b/eng/common/templates/steps/send-to-helix.yml
@@ -4,7 +4,9 @@ parameters:
HelixType: 'tests/default/' # required -- Helix telemetry which identifies what type of data this is; should include "test" for clarity and must end in '/'
HelixBuild: $(Build.BuildNumber) # required -- the build number Helix will use to identify this -- automatically set to the AzDO build number
HelixTargetQueues: '' # required -- semicolon delimited list of Helix queues to test on; see https://helix.dot.net/ for a list of queues
- HelixAccessToken: '' # required -- access token to make Helix API requests; should be provided by the appropriate variable group
+ HelixAccessToken: '' # optional -- legacy access token; not forwarded when HelixUseEntraAuthentication is true
+ HelixUseEntraAuthentication: false # optional -- use refreshable Entra authentication instead of a PAT or anonymous access
+ HelixAzureSubscription: '' # required when HelixUseEntraAuthentication is true -- Azure service connection ID authorized for Helix
HelixConfiguration: '' # optional -- additional property attached to a job
HelixPreCommands: '' # optional -- commands to run before Helix work item execution
HelixPostCommands: '' # optional -- commands to run after Helix work item execution
@@ -30,6 +32,28 @@ parameters:
continueOnError: false # optional -- determines whether to continue the build if the step errors; defaults to false
steps:
+ - ${{ if and(eq(parameters.HelixUseEntraAuthentication, true), eq(parameters.HelixAzureSubscription, '')) }}:
+ - pwsh: throw "HelixAzureSubscription must be set when HelixUseEntraAuthentication is true."
+ displayName: Validate Helix Entra authentication
+ condition: ${{ parameters.condition }}
+
+ - ${{ if eq(parameters.HelixUseEntraAuthentication, true) }}:
+ - task: AzureCLI@2
+ displayName: Initialize Helix Entra authentication
+ inputs:
+ azureSubscription: ${{ parameters.HelixAzureSubscription }}
+ addSpnToEnvironment: true
+ scriptType: pscore
+ scriptLocation: inlineScript
+ inlineScript: |
+ if ([string]::IsNullOrWhiteSpace($env:servicePrincipalId) -or [string]::IsNullOrWhiteSpace($env:tenantId)) {
+ throw "The Helix Azure service connection did not provide a service principal or tenant ID."
+ }
+
+ Write-Host "##vso[task.setvariable variable=HelixEntraClientId]$env:servicePrincipalId"
+ Write-Host "##vso[task.setvariable variable=HelixEntraTenantId]$env:tenantId"
+ condition: ${{ parameters.condition }}
+
- powershell: 'powershell "$env:BUILD_SOURCESDIRECTORY\eng\common\msbuild.ps1 $env:BUILD_SOURCESDIRECTORY\eng\common\helixpublish.proj /restore /t:Test /bl:$env:BUILD_SOURCESDIRECTORY\artifacts\log\$env:BuildConfig\SendToHelix.binlog"'
displayName: ${{ parameters.DisplayNamePrefix }} (Windows)
env:
@@ -39,7 +63,13 @@ steps:
HelixBuild: ${{ parameters.HelixBuild }}
HelixConfiguration: ${{ parameters.HelixConfiguration }}
HelixTargetQueues: ${{ parameters.HelixTargetQueues }}
- HelixAccessToken: ${{ parameters.HelixAccessToken }}
+ HelixUseEntraAuthentication: ${{ parameters.HelixUseEntraAuthentication }}
+ ${{ if eq(parameters.HelixUseEntraAuthentication, false) }}:
+ HelixAccessToken: ${{ parameters.HelixAccessToken }}
+ ${{ if eq(parameters.HelixUseEntraAuthentication, true) }}:
+ AZURESUBSCRIPTION_CLIENT_ID: $(HelixEntraClientId)
+ AZURESUBSCRIPTION_TENANT_ID: $(HelixEntraTenantId)
+ AZURESUBSCRIPTION_SERVICE_CONNECTION_ID: ${{ parameters.HelixAzureSubscription }}
HelixPreCommands: ${{ parameters.HelixPreCommands }}
HelixPostCommands: ${{ parameters.HelixPostCommands }}
WorkItemDirectory: ${{ parameters.WorkItemDirectory }}
@@ -70,7 +100,13 @@ steps:
HelixBuild: ${{ parameters.HelixBuild }}
HelixConfiguration: ${{ parameters.HelixConfiguration }}
HelixTargetQueues: ${{ parameters.HelixTargetQueues }}
- HelixAccessToken: ${{ parameters.HelixAccessToken }}
+ HelixUseEntraAuthentication: ${{ parameters.HelixUseEntraAuthentication }}
+ ${{ if eq(parameters.HelixUseEntraAuthentication, false) }}:
+ HelixAccessToken: ${{ parameters.HelixAccessToken }}
+ ${{ if eq(parameters.HelixUseEntraAuthentication, true) }}:
+ AZURESUBSCRIPTION_CLIENT_ID: $(HelixEntraClientId)
+ AZURESUBSCRIPTION_TENANT_ID: $(HelixEntraTenantId)
+ AZURESUBSCRIPTION_SERVICE_CONNECTION_ID: ${{ parameters.HelixAzureSubscription }}
HelixPreCommands: ${{ parameters.HelixPreCommands }}
HelixPostCommands: ${{ parameters.HelixPostCommands }}
WorkItemDirectory: ${{ parameters.WorkItemDirectory }}
diff --git a/src/Microsoft.DotNet.Helix/Client/CSharp/Microsoft.DotNet.Helix.Client.csproj b/src/Microsoft.DotNet.Helix/Client/CSharp/Microsoft.DotNet.Helix.Client.csproj
index 531ba3203fa..35939952d2d 100644
--- a/src/Microsoft.DotNet.Helix/Client/CSharp/Microsoft.DotNet.Helix.Client.csproj
+++ b/src/Microsoft.DotNet.Helix/Client/CSharp/Microsoft.DotNet.Helix.Client.csproj
@@ -11,11 +11,11 @@
-
-
+
+
-
+
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 d89e22dbcbc..bc0c797ae70 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
@@ -2,14 +2,16 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System;
+using System.Collections.Immutable;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Azure.Core;
using Azure.Core.Pipeline;
-using Microsoft.Arcade.Test.Common;
+using Microsoft.DotNet.Arcade.Test.Common;
using Microsoft.DotNet.Helix.Client;
+using Microsoft.DotNet.Helix.Client.Models;
using Xunit;
namespace Microsoft.DotNet.Helix.Sdk.Tests
@@ -201,6 +203,82 @@ public async Task EntraCredentialReacquiresTokenAfterExpiration()
Assert.NotEqual(firstAuthorization, secondAuthorization);
}
+ [Theory]
+ [InlineData(false, null, HelixApiAuthenticationMode.Anonymous)]
+ [InlineData(false, "legacy-token", HelixApiAuthenticationMode.PersonalAccessToken)]
+ [InlineData(true, null, HelixApiAuthenticationMode.EntraId)]
+ [InlineData(true, "legacy-token", HelixApiAuthenticationMode.EntraId)]
+ public void HelixTaskSelectsRequestedAuthenticationMode(
+ bool useEntraAuthentication,
+ string accessToken,
+ HelixApiAuthenticationMode expectedMode)
+ {
+ var api = Assert.IsType(
+ HelixTask.CreateHelixApi(
+ "https://helix.dot.net/",
+ accessToken,
+ useEntraAuthentication,
+ () => ApiFactory.GetAuthenticatedWithEntra(new TestTokenCredential())));
+
+ Assert.Equal(expectedMode, api.Options.AuthenticationMode);
+ }
+
+ [Theory]
+ [InlineData(false, null, "https://storage/results.trx")]
+ [InlineData(false, "legacy-token", "https://storage/results.trx?access_token=legacy-token")]
+ [InlineData(true, null, "https://storage/results.trx")]
+ [InlineData(true, "legacy-token", "https://storage/results.trx")]
+ public void UploadedFileLinksOnlyContainTokenInPatMode(
+ bool useEntraAuthentication,
+ string accessToken,
+ string expectedLink)
+ {
+ var files = ImmutableList.Create(new UploadedFile("results.trx", "https://storage/results.trx"));
+
+ IImmutableList result = GetHelixWorkItems.AddAccessTokenToFileLinks(
+ files,
+ accessToken,
+ useEntraAuthentication);
+
+ Assert.Equal(expectedLink, Assert.Single(result).Link);
+ }
+
+ [Theory]
+ [InlineData(false, null, false)]
+ [InlineData(false, "legacy-token", true)]
+ [InlineData(true, null, true)]
+ [InlineData(true, "legacy-token", true)]
+ public void CancellationRecognizesConfiguredAuthentication(
+ bool useEntraAuthentication,
+ string accessToken,
+ bool expected)
+ {
+ Assert.Equal(
+ expected,
+ CancelHelixJobs.CanUseAuthenticatedCancellation(useEntraAuthentication, accessToken));
+ }
+
+ [Theory]
+ [InlineData(true, null, null, null)]
+ [InlineData(true, "legacy-token", null, null)]
+ [InlineData(true, null, "creator", "Creator is forbidden when using authenticated access.")]
+ [InlineData(false, "legacy-token", "creator", "Creator is forbidden when using authenticated access.")]
+ [InlineData(false, null, null, "Creator is required when using anonymous access.")]
+ [InlineData(false, null, "creator", null)]
+ public void CreatorValidationRecognizesEntraAsAuthenticated(
+ bool useEntraAuthentication,
+ string accessToken,
+ string creator,
+ string expectedError)
+ {
+ Assert.Equal(
+ expectedError,
+ SendHelixJob.GetCreatorValidationError(
+ useEntraAuthentication,
+ accessToken,
+ creator));
+ }
+
private sealed class TestTokenCredential : TokenCredential
{
public override AccessToken GetToken(
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 b5342f2286c..028b39e724d 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
@@ -13,7 +13,7 @@
-
+
diff --git a/src/Microsoft.DotNet.Helix/Sdk/CancelHelixJob.cs b/src/Microsoft.DotNet.Helix/Sdk/CancelHelixJob.cs
index f7f7600a763..e3e8f492935 100644
--- a/src/Microsoft.DotNet.Helix/Sdk/CancelHelixJob.cs
+++ b/src/Microsoft.DotNet.Helix/Sdk/CancelHelixJob.cs
@@ -42,15 +42,21 @@ protected override async Task ExecuteCore(CancellationToken cancellationToken)
Log.LogMessage(MessageImportance.High, $"Successfully cancelled Helix Job {correlationId} via cancellation token.");
}
// Cancellation via token is preferred as these values are single-use (only work for one job) secrets and don't matter to leak.
- else if (!string.IsNullOrEmpty(AccessToken))
+ else if (CanUseAuthenticatedCancellation(UseEntraAuthentication, AccessToken))
{
- Log.LogMessage(MessageImportance.High, "'HelixJobCancellationToken' metadata not supplied, will attempt to cancel using Access token. (Token must match user id that started the work)");
+ string authenticationMethod = UseEntraAuthentication ? "Entra identity" : "access token";
+ Log.LogMessage(
+ MessageImportance.High,
+ $"'HelixJobCancellationToken' metadata not supplied, will attempt to cancel using the configured {authenticationMethod}.");
await api.Job.CancelAsync(correlationId, null, cancellationToken);
- Log.LogMessage(MessageImportance.High, $"Successfully cancelled Helix Job {correlationId} via access token.");
+ Log.LogMessage(
+ MessageImportance.High,
+ $"Successfully cancelled Helix Job {correlationId} via {authenticationMethod}.");
}
else
{
- Log.LogError($"Cannot cancel job '{job}'; please supply either the Job's cancellation token or the job creator's access token");
+ Log.LogError(
+ $"Cannot cancel job '{job}'; please supply the job's cancellation token or configure PAT or Entra authentication.");
}
}
catch (RestApiException e) when (e.Response.Status == 304)
@@ -75,5 +81,10 @@ protected override async Task ExecuteCore(CancellationToken cancellationToken)
Log.LogMessage(MessageImportance.High, $"Successfully cancelled {Jobs.Count()} Helix jobs");
}
}
+
+ internal static bool CanUseAuthenticatedCancellation(bool useEntraAuthentication, string accessToken)
+ {
+ return useEntraAuthentication || !string.IsNullOrEmpty(accessToken);
+ }
}
}
diff --git a/src/Microsoft.DotNet.Helix/Sdk/GetHelixWorkItems.cs b/src/Microsoft.DotNet.Helix/Sdk/GetHelixWorkItems.cs
index 204afd765c5..23d4c48b8d5 100644
--- a/src/Microsoft.DotNet.Helix/Sdk/GetHelixWorkItems.cs
+++ b/src/Microsoft.DotNet.Helix/Sdk/GetHelixWorkItems.cs
@@ -94,13 +94,7 @@ ITaskItem2 CreateTaskItem(string workItemName, IDictionary metad
// Do this serially with a delay because total failure can hit throttling
var files = await HelixApi.WorkItem.ListFilesAsync(wi, jobName, cancellationToken).ConfigureAwait(false);
- if (!string.IsNullOrEmpty(AccessToken))
- {
- // Add AccessToken to all file links because the api requires auth if we submitted the job with auth
- files = files
- .Select(file => new UploadedFile(file.Name, file.Link + "?access_token=" + AccessToken))
- .ToImmutableList();
- }
+ files = AddAccessTokenToFileLinks(files, AccessToken, UseEntraAuthentication);
metadata["UploadedFiles"] = JsonConvert.SerializeObject(files);
}
@@ -115,5 +109,21 @@ ITaskItem2 CreateTaskItem(string workItemName, IDictionary metad
return workItems;
}
+
+ internal static IImmutableList AddAccessTokenToFileLinks(
+ IImmutableList files,
+ string accessToken,
+ bool useEntraAuthentication)
+ {
+ if (useEntraAuthentication || string.IsNullOrEmpty(accessToken))
+ {
+ return files;
+ }
+
+ // PAT-authenticated jobs require the token on uploaded-file links.
+ return files
+ .Select(file => new UploadedFile(file.Name, file.Link + "?access_token=" + accessToken))
+ .ToImmutableList();
+ }
}
}
diff --git a/src/Microsoft.DotNet.Helix/Sdk/HelixTask.cs b/src/Microsoft.DotNet.Helix/Sdk/HelixTask.cs
index caa5ae3aef1..76eb95d7d00 100644
--- a/src/Microsoft.DotNet.Helix/Sdk/HelixTask.cs
+++ b/src/Microsoft.DotNet.Helix/Sdk/HelixTask.cs
@@ -3,6 +3,10 @@
using System.Net;
using System.Threading;
using Microsoft.Build.Framework;
+#if !DOTNET_BUILD_SOURCE_ONLY
+using Azure.Core;
+using Azure.Identity;
+#endif
using Microsoft.DotNet.Helix.Client;
namespace Microsoft.DotNet.Helix.Sdk
@@ -21,6 +25,11 @@ public abstract class HelixTask : BaseTask, ICancelableTask
///
public string AccessToken { get; set; }
+ ///
+ /// Use a refreshable Entra credential instead of anonymous or PAT authentication.
+ ///
+ public bool UseEntraAuthentication { get; set; }
+
///
/// If , fail when posting jobs to non-existent queues; If allow it and print a warning.
/// Note if an MSBuild sequence starts and waits on jobs, and none are started, this will still fail.
@@ -34,15 +43,85 @@ public abstract class HelixTask : BaseTask, ICancelableTask
private IHelixApi GetHelixApi()
{
+ if (UseEntraAuthentication && !string.IsNullOrEmpty(AccessToken))
+ {
+ Log.LogWarning(
+ "HelixAccessToken is set but ignored because HelixUseEntraAuthentication is enabled.");
+ }
+
+ if (UseEntraAuthentication)
+ {
+ Log.LogMessage(MessageImportance.Low, "Authenticating to helix api using a refreshable Entra credential.");
+ return CreateHelixApi(
+ BaseUri,
+ AccessToken,
+ UseEntraAuthentication,
+ () => CreateEntraHelixApi(BaseUri));
+ }
+
if (string.IsNullOrEmpty(AccessToken))
{
Log.LogMessage(MessageImportance.Low, "No AccessToken provided, using anonymous access to helix api.");
- return ApiFactory.GetAnonymous(BaseUri);
+ }
+ else
+ {
+ Log.LogMessage(MessageImportance.Low, "Authenticating to helix api using provided AccessToken");
+ }
+
+ return CreateHelixApi(BaseUri, AccessToken, UseEntraAuthentication, entraApiFactory: null);
+ }
+
+ internal static IHelixApi CreateHelixApi(
+ string baseUri,
+ string accessToken,
+ bool useEntraAuthentication,
+ Func entraApiFactory)
+ {
+ if (useEntraAuthentication)
+ {
+ return entraApiFactory();
+ }
+
+ return string.IsNullOrEmpty(accessToken)
+ ? ApiFactory.GetAnonymous(baseUri)
+ : ApiFactory.GetAuthenticated(baseUri, accessToken);
+ }
+
+ private static IHelixApi CreateEntraHelixApi(string baseUri)
+ {
+#if DOTNET_BUILD_SOURCE_ONLY
+ throw new PlatformNotSupportedException(
+ "Helix Entra authentication is not available in source-build.");
+#else
+ return ApiFactory.GetAuthenticatedWithEntra(baseUri, CreateDefaultTokenCredential());
+#endif
+ }
+
+#if !DOTNET_BUILD_SOURCE_ONLY
+ private static TokenCredential CreateDefaultTokenCredential()
+ {
+ string systemAccessToken = Environment.GetEnvironmentVariable("SYSTEM_ACCESSTOKEN");
+ string clientId = Environment.GetEnvironmentVariable("AZURESUBSCRIPTION_CLIENT_ID");
+ string tenantId = Environment.GetEnvironmentVariable("AZURESUBSCRIPTION_TENANT_ID");
+ string serviceConnectionId = Environment.GetEnvironmentVariable("AZURESUBSCRIPTION_SERVICE_CONNECTION_ID");
+ string oidcRequestUri = Environment.GetEnvironmentVariable("SYSTEM_OIDCREQUESTURI");
+
+ if (!string.IsNullOrEmpty(systemAccessToken) &&
+ !string.IsNullOrEmpty(clientId) &&
+ !string.IsNullOrEmpty(tenantId) &&
+ !string.IsNullOrEmpty(serviceConnectionId) &&
+ !string.IsNullOrEmpty(oidcRequestUri))
+ {
+ return new AzurePipelinesCredential(
+ tenantId,
+ clientId,
+ serviceConnectionId,
+ systemAccessToken);
}
- Log.LogMessage(MessageImportance.Low, "Authenticating to helix api using provided AccessToken");
- return ApiFactory.GetAuthenticated(BaseUri, AccessToken);
+ return new DefaultAzureCredential();
}
+#endif
public void Cancel()
{
@@ -59,7 +138,11 @@ public sealed override bool Execute()
}
catch (RestApiException ex) when (ex.Response.Status == (int)HttpStatusCode.Unauthorized)
{
- Log.LogError(FailureCategory.Build, "Helix operation returned 'Unauthorized'. Did you forget to set HelixAccessToken?");
+ Log.LogError(
+ FailureCategory.Build,
+ UseEntraAuthentication
+ ? "Helix operation returned 'Unauthorized'. Verify that the configured Entra identity is authorized for Helix."
+ : "Helix operation returned 'Unauthorized'. Did you forget to set HelixAccessToken?");
}
catch (RestApiException ex) when (ex.Response.Status == (int)HttpStatusCode.Forbidden)
{
diff --git a/src/Microsoft.DotNet.Helix/Sdk/Microsoft.DotNet.Helix.Sdk.csproj b/src/Microsoft.DotNet.Helix/Sdk/Microsoft.DotNet.Helix.Sdk.csproj
index 5452a5060d1..89a1d16cda1 100644
--- a/src/Microsoft.DotNet.Helix/Sdk/Microsoft.DotNet.Helix.Sdk.csproj
+++ b/src/Microsoft.DotNet.Helix/Sdk/Microsoft.DotNet.Helix.Sdk.csproj
@@ -8,6 +8,10 @@
true
+
+ $(DefineConstants);DOTNET_BUILD_SOURCE_ONLY
+
+
@@ -15,6 +19,7 @@
+
@@ -22,6 +27,10 @@
+
+
+
+
diff --git a/src/Microsoft.DotNet.Helix/Sdk/Readme.md b/src/Microsoft.DotNet.Helix/Sdk/Readme.md
index 965fe95f5a9..c6f90fbb089 100644
--- a/src/Microsoft.DotNet.Helix/Sdk/Readme.md
+++ b/src/Microsoft.DotNet.Helix/Sdk/Readme.md
@@ -25,7 +25,19 @@ Each of the following examples require dotnet-cli >= 3.1.x, and need the followi
Versions of the package can be found by browsing the feed at https://dev.azure.com/dnceng/public/_packaging?_a=feed&feed=dotnet-eng
-The examples can all be run with `dotnet msbuild` and will require an environment variable or MSBuildProperty `HelixAccessToken` set if a queue with a value of IsInternalOnly=true (usually any not ending in '.Open') is selected for `HelixTargetQueues`. You will also need to set the following environment variables before building:
+### Developing Helix SDK
+
+The examples can all be run with `dotnet msbuild`. Internal queues (usually any queue not ending in `.Open`) require either:
+
+- `HelixUseEntraAuthentication=true` with an available Entra credential, or
+- the legacy `HelixAccessToken` environment variable or MSBuild property.
+
+When Entra authentication is explicitly enabled, any legacy access token still
+injected by an existing variable group is ignored with a warning. Entra
+authentication supports Azure Pipelines workload identity, managed identity,
+and Azure CLI credentials and refreshes access tokens based on their expiry.
+
+You will also need to set the following environment variables before building:
```
BUILD_SOURCEBRANCH
diff --git a/src/Microsoft.DotNet.Helix/Sdk/SendHelixJob.cs b/src/Microsoft.DotNet.Helix/Sdk/SendHelixJob.cs
index 388a43da6c2..2fa9b143a09 100644
--- a/src/Microsoft.DotNet.Helix/Sdk/SendHelixJob.cs
+++ b/src/Microsoft.DotNet.Helix/Sdk/SendHelixJob.cs
@@ -161,15 +161,13 @@ public static class MetadataNames
protected override async Task ExecuteCore(CancellationToken cancellationToken)
{
- if (string.IsNullOrEmpty(AccessToken) && string.IsNullOrEmpty(Creator))
+ string creatorValidationError = GetCreatorValidationError(
+ UseEntraAuthentication,
+ AccessToken,
+ Creator);
+ if (creatorValidationError != null)
{
- Log.LogError(FailureCategory.Build, "Creator is required when using anonymous access.");
- return;
- }
-
- if (!string.IsNullOrEmpty(AccessToken) && !string.IsNullOrEmpty(Creator))
- {
- Log.LogError(FailureCategory.Build, "Creator is forbidden when using authenticated access.");
+ Log.LogError(FailureCategory.Build, creatorValidationError);
return;
}
@@ -276,6 +274,22 @@ protected override async Task ExecuteCore(CancellationToken cancellationToken)
cancellationToken.ThrowIfCancellationRequested();
}
+ internal static string GetCreatorValidationError(
+ bool useEntraAuthentication,
+ string accessToken,
+ string creator)
+ {
+ bool isAuthenticated = useEntraAuthentication || !string.IsNullOrEmpty(accessToken);
+ if (!isAuthenticated && string.IsNullOrEmpty(creator))
+ {
+ return "Creator is required when using anonymous access.";
+ }
+
+ return isAuthenticated && !string.IsNullOrEmpty(creator)
+ ? "Creator is forbidden when using authenticated access."
+ : null;
+ }
+
private IJobDefinition AddBuildVariableProperty(IJobDefinition def, string key, string azdoVariableName)
{
string envName = FromAzdoVariableNameToEnvironmentVariableName(azdoVariableName);
diff --git a/src/Microsoft.DotNet.Helix/Sdk/tools/Microsoft.DotNet.Helix.Sdk.MonoQueue.targets b/src/Microsoft.DotNet.Helix/Sdk/tools/Microsoft.DotNet.Helix.Sdk.MonoQueue.targets
index 744b11ed2a5..4c72f737efe 100644
--- a/src/Microsoft.DotNet.Helix/Sdk/tools/Microsoft.DotNet.Helix.Sdk.MonoQueue.targets
+++ b/src/Microsoft.DotNet.Helix/Sdk/tools/Microsoft.DotNet.Helix.Sdk.MonoQueue.targets
@@ -63,6 +63,7 @@
Creator="$(Creator)"
BaseUri="$(HelixBaseUri)"
AccessToken="$(HelixAccessToken)"
+ UseEntraAuthentication="$(HelixUseEntraAuthentication)"
MaxRetryCount="$(MaxRetryCount)"
PreCommands="$(HelixPreCommands)"
PostCommands="$(HelixPostCommands)"
diff --git a/src/Microsoft.DotNet.Helix/Sdk/tools/Microsoft.DotNet.Helix.Sdk.MultiQueue.targets b/src/Microsoft.DotNet.Helix/Sdk/tools/Microsoft.DotNet.Helix.Sdk.MultiQueue.targets
index 5c07414e5c8..beeff8e5a14 100644
--- a/src/Microsoft.DotNet.Helix/Sdk/tools/Microsoft.DotNet.Helix.Sdk.MultiQueue.targets
+++ b/src/Microsoft.DotNet.Helix/Sdk/tools/Microsoft.DotNet.Helix.Sdk.MultiQueue.targets
@@ -55,6 +55,7 @@
@@ -62,6 +63,7 @@
@@ -78,6 +80,7 @@
Helix
+ false
From cec0b5ad5b243727ac0503326f1534c686548839 Mon Sep 17 00:00:00 2001
From: Missy Messa
Date: Mon, 14 Sep 2026 14:21:11 -0700
Subject: [PATCH 4/4] Treat Entra as authenticated in Helix test projects
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../steps/send-to-helix.yml | 2 +-
eng/common/templates/steps/send-to-helix.yml | 2 +-
.../HelixAuthenticationConfigurationTests.cs | 53 +++++++++++++++++++
.../Microsoft.DotNet.Helix.Sdk.Tests.csproj | 9 ++++
tests/UnitTests.proj | 6 +--
tests/XHarness.Tests.Common.props | 2 +-
6 files changed, 68 insertions(+), 6 deletions(-)
create mode 100644 src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/HelixAuthenticationConfigurationTests.cs
diff --git a/eng/common/templates-official/steps/send-to-helix.yml b/eng/common/templates-official/steps/send-to-helix.yml
index 619896b1aa9..31096cded10 100644
--- a/eng/common/templates-official/steps/send-to-helix.yml
+++ b/eng/common/templates-official/steps/send-to-helix.yml
@@ -24,7 +24,7 @@ parameters:
DotNetCliVersion: '' # optional -- version of the CLI to send to Helix; based on this: https://raw.githubusercontent.com/dotnet/core/main/release-notes/releases-index.json
EnableXUnitReporter: false # optional -- true enables XUnit result reporting to Mission Control
WaitForWorkItemCompletion: true # optional -- true will make the task wait until work items have been completed and fail the build if work items fail. False is "fire and forget."
- IsExternal: false # [DEPRECATED] -- doesn't do anything, jobs are external if HelixAccessToken is empty and Creator is set
+ IsExternal: false # [DEPRECATED] -- doesn't do anything, jobs are external if Entra is disabled, HelixAccessToken is empty, and Creator is set
HelixBaseUri: 'https://helix.dot.net/' # optional -- sets the Helix API base URI (allows targeting int)
Creator: '' # optional -- if the build is external, use this to specify who is sending the job
DisplayNamePrefix: 'Run Tests' # optional -- rename the beginning of the displayName of the steps in AzDO
diff --git a/eng/common/templates/steps/send-to-helix.yml b/eng/common/templates/steps/send-to-helix.yml
index 619896b1aa9..31096cded10 100644
--- a/eng/common/templates/steps/send-to-helix.yml
+++ b/eng/common/templates/steps/send-to-helix.yml
@@ -24,7 +24,7 @@ parameters:
DotNetCliVersion: '' # optional -- version of the CLI to send to Helix; based on this: https://raw.githubusercontent.com/dotnet/core/main/release-notes/releases-index.json
EnableXUnitReporter: false # optional -- true enables XUnit result reporting to Mission Control
WaitForWorkItemCompletion: true # optional -- true will make the task wait until work items have been completed and fail the build if work items fail. False is "fire and forget."
- IsExternal: false # [DEPRECATED] -- doesn't do anything, jobs are external if HelixAccessToken is empty and Creator is set
+ IsExternal: false # [DEPRECATED] -- doesn't do anything, jobs are external if Entra is disabled, HelixAccessToken is empty, and Creator is set
HelixBaseUri: 'https://helix.dot.net/' # optional -- sets the Helix API base URI (allows targeting int)
Creator: '' # optional -- if the build is external, use this to specify who is sending the job
DisplayNamePrefix: 'Run Tests' # optional -- rename the beginning of the displayName of the steps in AzDO
diff --git a/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/HelixAuthenticationConfigurationTests.cs b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/HelixAuthenticationConfigurationTests.cs
new file mode 100644
index 00000000000..2a877f4955e
--- /dev/null
+++ b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/HelixAuthenticationConfigurationTests.cs
@@ -0,0 +1,53 @@
+// 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.IO;
+using System.Linq;
+using System.Xml.Linq;
+using Xunit;
+
+namespace Microsoft.DotNet.Helix.Sdk.Tests
+{
+ public class HelixAuthenticationConfigurationTests
+ {
+ private const string AuthenticatedCondition =
+ "'$(HelixAccessToken)' != '' Or '$(HelixUseEntraAuthentication)' == 'true'";
+ private const string AnonymousCondition =
+ "'$(HelixAccessToken)' == '' And '$(HelixUseEntraAuthentication)' != 'true'";
+
+ [Fact]
+ public void UnitTestQueuesAndCreatorRecognizeEntraAuthentication()
+ {
+ string[] conditions = GetHelixAccessTokenConditions("UnitTests.proj");
+
+ Assert.Equal(3, conditions.Length);
+ Assert.Single(conditions, condition => condition.Contains(AuthenticatedCondition));
+ Assert.Equal(2, conditions.Count(condition => condition.Contains(AnonymousCondition)));
+ }
+
+ [Fact]
+ public void XHarnessCreatorRecognizesEntraAuthentication()
+ {
+ string condition = Assert.Single(
+ GetHelixAccessTokenConditions("XHarness.Tests.Common.props"));
+
+ Assert.Contains(AnonymousCondition, condition);
+ }
+
+ private static string[] GetHelixAccessTokenConditions(string fileName)
+ {
+ string path = Path.Combine(
+ AppContext.BaseDirectory,
+ "testassets",
+ "HelixAuthentication",
+ fileName);
+ return XDocument.Load(path)
+ .Descendants()
+ .Attributes("Condition")
+ .Select(attribute => attribute.Value)
+ .Where(condition => condition.Contains("$(HelixAccessToken)"))
+ .ToArray();
+ }
+ }
+}
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 028b39e724d..0a81b717500 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
@@ -24,4 +24,13 @@
+
+
+
+
+
diff --git a/tests/UnitTests.proj b/tests/UnitTests.proj
index 4d9ed11e036..f5dccd6537f 100644
--- a/tests/UnitTests.proj
+++ b/tests/UnitTests.proj
@@ -62,19 +62,19 @@
-
+
-
+
true
$(BUILD_SOURCEVERSIONAUTHOR)
anon
-
+
diff --git a/tests/XHarness.Tests.Common.props b/tests/XHarness.Tests.Common.props
index 616bc559992..93044e6a435 100644
--- a/tests/XHarness.Tests.Common.props
+++ b/tests/XHarness.Tests.Common.props
@@ -24,7 +24,7 @@
false
-
+
true
$(BUILD_SOURCEVERSIONAUTHOR)
anon