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