diff --git a/Documentation/AzureDevOps/SendingJobsToHelix.md b/Documentation/AzureDevOps/SendingJobsToHelix.md index 6b93e46321a..c3446a75502 100644 --- a/Documentation/AzureDevOps/SendingJobsToHelix.md +++ b/Documentation/AzureDevOps/SendingJobsToHelix.md @@ -57,10 +57,51 @@ 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. + +The production and staging Entra scopes are inferred for `https://helix.dot.net/` +and `https://helix.int-dot.net/`. When `HelixBaseUri` targets another HTTPS +host, set its scope through `HelixEntraScope`. + +```yaml +steps: +- template: /eng/common/templates/steps/send-to-helix.yml + displayName: Send to Helix + parameters: + HelixUseEntraAuthentication: true + HelixAzureSubscription: + # HelixEntraScope: + # other parameters here +``` + +If the pipeline also uses the standalone Helix Job Monitor, configure its +separate job template with `useEntraAuthentication` and `azureSubscription`. +For a custom `helixBaseUri`, also pass the same scope through `helixEntraScope`. +Enabling Entra authentication on `send-to-helix.yml` does not automatically +configure the monitor job. + +```yaml +jobs: +- template: /eng/common/core-templates/job/helix-job-monitor.yml + parameters: + useEntraAuthentication: true + azureSubscription: + # helixEntraScope: + # 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 diff --git a/eng/common/core-templates/job/helix-job-monitor.yml b/eng/common/core-templates/job/helix-job-monitor.yml index 53bbf74927e..1e685f59331 100644 --- a/eng/common/core-templates/job/helix-job-monitor.yml +++ b/eng/common/core-templates/job/helix-job-monitor.yml @@ -52,11 +52,29 @@ parameters: type: string default: https://helix.dot.net/ -# Helix API access token forwarded to the tool via the HELIX_ACCESSTOKEN environment variable. +# Helix API access token forwarded via HELIX_ACCESSTOKEN. Not forwarded when +# useEntraAuthentication is true. - name: helixAccessToken type: string default: '' +# Use a refreshable Entra credential instead of a PAT or anonymous access. +- name: useEntraAuthentication + type: boolean + default: false + +# Explicit Entra scope for a custom Helix API host (--helix-entra-scope). +# Production and staging scopes are inferred when omitted. +- name: helixEntraScope + type: string + default: '' + +# Azure service connection ID authorized for Helix. Required when +# useEntraAuthentication is true. +- name: azureSubscription + type: string + default: '' + # Polling interval in seconds (--polling-interval-seconds). - name: pollingIntervalSeconds type: number @@ -141,6 +159,26 @@ jobs: - checkout: self fetchDepth: 1 + - ${{ if and(eq(parameters.useEntraAuthentication, true), eq(parameters.azureSubscription, '')) }}: + - pwsh: throw "azureSubscription must be set when useEntraAuthentication is true." + displayName: Validate Helix Entra authentication + + - ${{ if eq(parameters.useEntraAuthentication, true) }}: + - task: AzureCLI@2 + displayName: Initialize Helix Entra authentication + inputs: + azureSubscription: ${{ parameters.azureSubscription }} + 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" + - ${{ if ne(parameters.toolNupkgArtifactName, '') }}: - task: DownloadPipelineArtifact@2 displayName: Download Helix Job Monitor artifact @@ -214,6 +252,8 @@ jobs: toolArgs=( --helix-base-uri '${{ parameters.helixBaseUri }}' + --use-entra-authentication '${{ parameters.useEntraAuthentication }}' + --helix-entra-scope '${{ parameters.helixEntraScope }}' --polling-interval-seconds '${{ parameters.pollingIntervalSeconds }}' --fail-on-failed-tests '${{ parameters.failWorkItemsWithFailedTests }}' --allow-no-helix-jobs '${{ parameters.allowNoHelixJobs }}' @@ -275,4 +315,9 @@ jobs: displayName: Monitor Helix Jobs env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) - HELIX_ACCESSTOKEN: ${{ parameters.helixAccessToken }} + ${{ if eq(parameters.useEntraAuthentication, false) }}: + HELIX_ACCESSTOKEN: ${{ parameters.helixAccessToken }} + ${{ if eq(parameters.useEntraAuthentication, true) }}: + AZURESUBSCRIPTION_CLIENT_ID: $(HelixEntraClientId) + AZURESUBSCRIPTION_TENANT_ID: $(HelixEntraTenantId) + AZURESUBSCRIPTION_SERVICE_CONNECTION_ID: ${{ parameters.azureSubscription }} diff --git a/eng/common/core-templates/job/job.yml b/eng/common/core-templates/job/job.yml index eaed6d87e65..a64f1c496a7 100644 --- a/eng/common/core-templates/job/job.yml +++ b/eng/common/core-templates/job/job.yml @@ -109,7 +109,8 @@ jobs: - name: ${{ pair.key }} value: ${{ pair.value }} - # DotNet-HelixApi-Access provides 'HelixApiAccessToken' for internal builds + # DotNet-HelixApi-Access provides 'HelixApiAccessToken' for internal builds. + # Entra-enabled Helix templates do not forward this value to their processes. - ${{ if and(eq(parameters.enableTelemetry, 'true'), eq(parameters.runAsPublic, 'false'), ne(variables['System.TeamProject'], 'public'), notin(variables['Build.Reason'], 'PullRequest')) }}: - group: DotNet-HelixApi-Access diff --git a/eng/common/core-templates/steps/send-to-helix.yml b/eng/common/core-templates/steps/send-to-helix.yml index 37678b0389b..3951e47104a 100644 --- a/eng/common/core-templates/steps/send-to-helix.yml +++ b/eng/common/core-templates/steps/send-to-helix.yml @@ -4,7 +4,10 @@ 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 + HelixEntraScope: '' # optional -- explicit Entra scope required for custom HelixBaseUri hosts + HelixAzureSubscription: '' # required when HelixUseEntraAuthentication is true -- Azure service connection ID authorized for Helix HelixProjectPath: 'eng/common/helixpublish.proj' # optional -- path to the project file to build relative to BUILD_SOURCESDIRECTORY HelixProjectArguments: '' # optional -- arguments passed to the build command HelixConfiguration: '' # optional -- additional property attached to a job @@ -32,12 +35,35 @@ 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: > $(Build.SourcesDirectory)\eng\common\msbuild.ps1 $(Build.SourcesDirectory)/${{ parameters.HelixProjectPath }} /restore /p:TreatWarningsAsErrors=false /p:EnableHelixJobMonitor=${{ parameters.UseHelixMonitor }} + /p:HelixUseEntraAuthentication=${{ parameters.HelixUseEntraAuthentication }} ${{ parameters.HelixProjectArguments }} /t:Test /bl:$(Build.SourcesDirectory)/artifacts/log/$(_BuildConfig)/SendToHelix.binlog @@ -49,7 +75,13 @@ steps: HelixBuild: ${{ parameters.HelixBuild }} HelixConfiguration: ${{ parameters.HelixConfiguration }} HelixTargetQueues: ${{ parameters.HelixTargetQueues }} - HelixAccessToken: ${{ parameters.HelixAccessToken }} + ${{ 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 }} + HelixEntraScope: ${{ parameters.HelixEntraScope }} HelixPreCommands: ${{ parameters.HelixPreCommands }} HelixPostCommands: ${{ parameters.HelixPostCommands }} WorkItemDirectory: ${{ parameters.WorkItemDirectory }} @@ -76,6 +108,7 @@ steps: /restore /p:TreatWarningsAsErrors=false /p:EnableHelixJobMonitor=${{ parameters.UseHelixMonitor }} + /p:HelixUseEntraAuthentication=${{ parameters.HelixUseEntraAuthentication }} ${{ parameters.HelixProjectArguments }} /t:Test /bl:$(Build.SourcesDirectory)/artifacts/log/$(_BuildConfig)/SendToHelix.binlog @@ -87,7 +120,13 @@ steps: HelixBuild: ${{ parameters.HelixBuild }} HelixConfiguration: ${{ parameters.HelixConfiguration }} HelixTargetQueues: ${{ parameters.HelixTargetQueues }} - HelixAccessToken: ${{ parameters.HelixAccessToken }} + ${{ 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 }} + HelixEntraScope: ${{ parameters.HelixEntraScope }} HelixPreCommands: ${{ parameters.HelixPreCommands }} HelixPostCommands: ${{ parameters.HelixPostCommands }} WorkItemDirectory: ${{ parameters.WorkItemDirectory }} diff --git a/src/Microsoft.DotNet.ArcadeAzureIntegration/Microsoft.DotNet.ArcadeAzureIntegration.csproj b/src/Microsoft.DotNet.ArcadeAzureIntegration/Microsoft.DotNet.ArcadeAzureIntegration.csproj index 514a08a6c5a..73f94bad5aa 100644 --- a/src/Microsoft.DotNet.ArcadeAzureIntegration/Microsoft.DotNet.ArcadeAzureIntegration.csproj +++ b/src/Microsoft.DotNet.ArcadeAzureIntegration/Microsoft.DotNet.ArcadeAzureIntegration.csproj @@ -1,7 +1,7 @@ - $(NetToolCurrent);$(NetFrameworkToolCurrent) + $(NetToolCurrent);$(NetMinimum);$(NetFrameworkToolCurrent) diff --git a/src/Microsoft.DotNet.Helix/Client/CSharp/ApiFactory.cs b/src/Microsoft.DotNet.Helix/Client/CSharp/ApiFactory.cs index 3e3fe9d3dd4..1f765aa4650 100644 --- a/src/Microsoft.DotNet.Helix/Client/CSharp/ApiFactory.cs +++ b/src/Microsoft.DotNet.Helix/Client/CSharp/ApiFactory.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using Azure.Core; namespace Microsoft.DotNet.Helix.Client; @@ -20,6 +21,18 @@ 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( + new Uri("https://helix.dot.net/"), + ValidateEntraCredential(credential), + new[] { HelixApiOptions.ProductionScope })); + } + /// /// Obtains API client for unauthenticated access to external queues. /// The client will access production Helix instance. @@ -46,6 +59,33 @@ 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) + { + var uri = new Uri(baseUri); + return new HelixApi(new HelixApiOptions( + uri, + ValidateEntraCredential(credential), + new[] { HelixApiOptions.GetDefaultScope(uri) })); + } + + /// + /// Obtains an API client using an Entra credential and explicit scope for a custom Helix instance. + /// + public static IHelixApi GetAuthenticatedWithEntra( + string baseUri, + TokenCredential credential, + string scope) + { + return new HelixApi(new HelixApiOptions( + new Uri(baseUri), + ValidateEntraCredential(credential), + new[] { scope })); + } + /// /// Obtains API client for unauthenticated access to external queues. /// The client will access Helix instance at the provided URI. @@ -58,4 +98,21 @@ 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 5440d096b0a..615892b8d34 100644 --- a/src/Microsoft.DotNet.Helix/Client/CSharp/HelixApiOptions.cs +++ b/src/Microsoft.DotNet.Helix/Client/CSharp/HelixApiOptions.cs @@ -2,25 +2,118 @@ // The .NET Foundation licenses this file to you under the MIT license. using Azure.Core; +using Azure.Core.Pipeline; using System; +using System.Collections.Generic; +using System.Linq; namespace Microsoft.DotNet.Helix.Client; +public enum HelixApiAuthenticationMode +{ + Anonymous, + PersonalAccessToken, + EntraId, +} + partial class HelixApiOptions { + public const string ProductionScope = "api://eb70c40b-c265-44f7-842e-1a568f035f33/.default"; + public const string StagingScope = "api://f45b17a4-149b-4f89-91bc-e6331af8d0e8/.default"; + // See https://github.com/Azure/azure-sdk-for-net/blob/master/sdk/core/Azure.Core/src/RetryOptions.cs for values this overrides public const int DefaultRetryDelaySeconds = 10; public const int DefaultMaxRetryCount = 5; + public HelixApiOptions(Uri baseUri, TokenCredential credentials, IEnumerable scopes) + { + BaseUri = ValidateBaseUri(baseUri); + Credentials = credentials ?? throw new ArgumentNullException(nameof(credentials)); + if (credentials is HelixApiTokenCredential) + { + throw new ArgumentException( + "Explicit scopes are only supported for Entra credentials. " + + "For PAT authentication, pass HelixApiTokenCredential without explicit scopes.", + nameof(credentials)); + } + + string[] tokenScopes = scopes?.ToArray() ?? throw new ArgumentNullException(nameof(scopes)); + if (tokenScopes.Length == 0 || tokenScopes.Any(string.IsNullOrWhiteSpace)) + { + throw new ArgumentException("At least one non-empty token scope is required.", nameof(scopes)); + } + TokenScopes = Array.AsReadOnly(tokenScopes); + + InitializeOptions(); + } + + public HelixApiAuthenticationMode AuthenticationMode { get; private set; } + + public IReadOnlyList TokenScopes { get; private set; } = Array.Empty(); + partial void InitializeOptions() { - if (Credentials != null) + if (Credentials == null) + { + AuthenticationMode = HelixApiAuthenticationMode.Anonymous; + } + else if (TokenScopes.Count == 0) { + AuthenticationMode = HelixApiAuthenticationMode.PersonalAccessToken; AddPolicy(new HelixApiTokenAuthenticationPolicy(Credentials), HttpPipelinePosition.PerCall); } + else + { + AuthenticationMode = HelixApiAuthenticationMode.EntraId; + 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; } + + internal 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)); + } + + if (!baseUri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + "The Helix API base URI must use HTTPS for Entra authentication.", + nameof(baseUri)); + } + + return baseUri; + } } diff --git a/src/Microsoft.DotNet.Helix/JobMonitor/JobMonitorOptions.cs b/src/Microsoft.DotNet.Helix/JobMonitor/JobMonitorOptions.cs index 4b1b2ae9977..14715e83f1f 100644 --- a/src/Microsoft.DotNet.Helix/JobMonitor/JobMonitorOptions.cs +++ b/src/Microsoft.DotNet.Helix/JobMonitor/JobMonitorOptions.cs @@ -15,6 +15,10 @@ public sealed class JobMonitorOptions // Helix API access token public string HelixAccessToken { get; set; } + public bool UseEntraAuthentication { get; set; } + + public string HelixEntraScope { get; set; } + /// /// Azure DevOps build token /// @@ -138,6 +142,17 @@ public static JobMonitorOptions Parse(string[] args) DefaultValueFactory = _ => "https://helix.dot.net/" }; + Option useEntraAuthenticationOption = new("--use-entra-authentication") + { + Description = "Use a refreshable Entra credential for Helix API authentication. Defaults to the HELIX_USE_ENTRA_AUTHENTICATION environment variable.", + DefaultValueFactory = _ => ParseBoolean(Environment.GetEnvironmentVariable("HELIX_USE_ENTRA_AUTHENTICATION")) + }; + + Option helixEntraScopeOption = new("--helix-entra-scope") + { + Description = "Explicit Entra scope for a custom Helix API host. Production and staging scopes are inferred when omitted." + }; + Option pollingIntervalSecondsOption = new("--polling-interval-seconds") { Description = "Polling interval in seconds.", @@ -223,6 +238,8 @@ public static JobMonitorOptions Parse(string[] args) rootCommand.Options.Add(collectionUriOption); rootCommand.Options.Add(teamProjectOption); rootCommand.Options.Add(helixBaseUriOption); + rootCommand.Options.Add(useEntraAuthenticationOption); + rootCommand.Options.Add(helixEntraScopeOption); rootCommand.Options.Add(pollingIntervalSecondsOption); rootCommand.Options.Add(maximumWaitMinutesOption); rootCommand.Options.Add(jobMonitorNameOption); @@ -249,6 +266,8 @@ public static JobMonitorOptions Parse(string[] args) CollectionUri = parseResult.GetValue(collectionUriOption), TeamProject = parseResult.GetValue(teamProjectOption), HelixBaseUri = parseResult.GetValue(helixBaseUriOption), + UseEntraAuthentication = parseResult.GetValue(useEntraAuthenticationOption), + HelixEntraScope = parseResult.GetValue(helixEntraScopeOption), PollingIntervalSeconds = parseResult.GetValue(pollingIntervalSecondsOption), MaximumWaitMinutes = parseResult.GetValue(maximumWaitMinutesOption), JobMonitorName = parseResult.GetValue(jobMonitorNameOption), diff --git a/src/Microsoft.DotNet.Helix/JobMonitor/JobMonitorRunner.cs b/src/Microsoft.DotNet.Helix/JobMonitor/JobMonitorRunner.cs index b6778eb1d16..7d8cb43aed8 100644 --- a/src/Microsoft.DotNet.Helix/JobMonitor/JobMonitorRunner.cs +++ b/src/Microsoft.DotNet.Helix/JobMonitor/JobMonitorRunner.cs @@ -7,6 +7,9 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +#if !DOTNET_BUILD_SOURCE_ONLY +using Microsoft.DotNet.ArcadeAzureIntegration; +#endif using Microsoft.DotNet.Helix.AzureDevOpsTestPublisher; using Microsoft.DotNet.Helix.Client; using Microsoft.DotNet.Helix.Client.Models; @@ -779,10 +782,14 @@ private static ProductionDependencies CreateProductionDependencies( options.UseFullyQualifiedTestName, azureDevOps, metrics); + if (options.UseEntraAuthentication && !string.IsNullOrEmpty(options.HelixAccessToken)) + { + logger.LogWarning( + "{Prefix}HELIX_ACCESSTOKEN is set but ignored because Entra authentication is enabled.", + AzdoWarningPrefix); + } var helix = new HelixService( - string.IsNullOrEmpty(options.HelixAccessToken) - ? ApiFactory.GetAnonymous(options.HelixBaseUri) - : ApiFactory.GetAuthenticated(options.HelixBaseUri, options.HelixAccessToken), + CreateHelixApi(options, () => CreateEntraHelixApi(options.HelixBaseUri, options.HelixEntraScope)), logger, metrics); return new ProductionDependencies( @@ -793,6 +800,33 @@ private static ProductionDependencies CreateProductionDependencies( metrics); } + internal static IHelixApi CreateHelixApi( + JobMonitorOptions options, + Func entraApiFactory) + { + if (options.UseEntraAuthentication) + { + return entraApiFactory(); + } + + return string.IsNullOrEmpty(options.HelixAccessToken) + ? ApiFactory.GetAnonymous(options.HelixBaseUri) + : ApiFactory.GetAuthenticated(options.HelixBaseUri, options.HelixAccessToken); + } + + internal static IHelixApi CreateEntraHelixApi(string baseUri, string entraScope) + { +#if DOTNET_BUILD_SOURCE_ONLY + throw new PlatformNotSupportedException( + "Helix Entra authentication is not available in source-build."); +#else + var credential = new DefaultIdentityTokenCredential(); + return string.IsNullOrWhiteSpace(entraScope) + ? ApiFactory.GetAuthenticatedWithEntra(baseUri, credential) + : ApiFactory.GetAuthenticatedWithEntra(baseUri, credential, entraScope); +#endif + } + public void Dispose() { _uploads.Cancel(); diff --git a/src/Microsoft.DotNet.Helix/JobMonitor/Microsoft.DotNet.Helix.JobMonitor.csproj b/src/Microsoft.DotNet.Helix/JobMonitor/Microsoft.DotNet.Helix.JobMonitor.csproj index 222d59d525b..e0ce90437ee 100644 --- a/src/Microsoft.DotNet.Helix/JobMonitor/Microsoft.DotNet.Helix.JobMonitor.csproj +++ b/src/Microsoft.DotNet.Helix/JobMonitor/Microsoft.DotNet.Helix.JobMonitor.csproj @@ -12,6 +12,10 @@ Standalone Helix Job Monitor tool for Azure DevOps pipelines + + $(DefineConstants);DOTNET_BUILD_SOURCE_ONLY + + @@ -26,6 +30,7 @@ + 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..b82f361410a --- /dev/null +++ b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/HelixApiAuthenticationTests.cs @@ -0,0 +1,457 @@ +// 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.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.Helix.Client; +using Microsoft.DotNet.Helix.Client.Models; +using Microsoft.DotNet.Helix.JobMonitor; +using Microsoft.DotNet.Helix.Sdk; +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 ExplicitProductionCredentialUsesProductionScope() + { + var options = new HelixApiOptions( + new Uri("https://helix.dot.net/"), + new TestTokenCredential(), + new[] { HelixApiOptions.ProductionScope }); + + Assert.Equal(HelixApiAuthenticationMode.EntraId, options.AuthenticationMode); + Assert.Equal(new[] { HelixApiOptions.ProductionScope }, options.TokenScopes); + } + + [Fact] + public void ExplicitStagingCredentialUsesStagingScope() + { + var options = new HelixApiOptions( + new Uri("https://helix.int-dot.net/"), + new TestTokenCredential(), + new[] { HelixApiOptions.StagingScope }); + + Assert.Equal(HelixApiAuthenticationMode.EntraId, options.AuthenticationMode); + Assert.Equal(new[] { HelixApiOptions.StagingScope }, options.TokenScopes); + } + + [Theory] + [InlineData(null)] + [InlineData("https://helix.dot.net/")] + [InlineData("http://localhost:5001/")] + public async Task LegacyCredentialPreservesTokenAuthentication(string baseUri) + { + var credential = new TestTokenCredential(); + using var httpClient = FakeHttpClient.WithResponses( + new HttpResponseMessage(HttpStatusCode.OK)); + var options = baseUri == null + ? new HelixApiOptions(credential) + : new HelixApiOptions(new Uri(baseUri), credential); + options.Transport = new HttpClientTransport(httpClient); + var api = new HelixApi(options); + + using HttpMessage message = api.Pipeline.CreateMessage(); + message.Request.Method = RequestMethod.Get; + message.Request.Uri.Reset(options.BaseUri); + await api.Pipeline.SendAsync(message, CancellationToken.None); + + Assert.Equal(HelixApiAuthenticationMode.PersonalAccessToken, options.AuthenticationMode); + Assert.Empty(options.TokenScopes); + Assert.Equal(1, credential.CallCount); + Assert.Empty(credential.RequestedScopes); + Assert.True(message.Request.Headers.TryGetValue("Authorization", out string authorization)); + Assert.Equal("token test-token", authorization); + } + + [Fact] + public async Task ExplicitEntraCredentialUsesBearerAuthentication() + { + var credential = new TestTokenCredential(); + using var httpClient = FakeHttpClient.WithResponses( + new HttpResponseMessage(HttpStatusCode.OK)); + var options = new HelixApiOptions( + new Uri("https://helix.dot.net/"), + credential, + new[] { HelixApiOptions.ProductionScope }) + { + Transport = new HttpClientTransport(httpClient), + }; + var api = new HelixApi(options); + + using HttpMessage message = api.Pipeline.CreateMessage(); + message.Request.Method = RequestMethod.Get; + message.Request.Uri.Reset(options.BaseUri); + await api.Pipeline.SendAsync(message, CancellationToken.None); + + Assert.Equal(HelixApiAuthenticationMode.EntraId, options.AuthenticationMode); + Assert.Equal(1, credential.CallCount); + Assert.Equal(new[] { HelixApiOptions.ProductionScope }, credential.RequestedScopes); + Assert.True(message.Request.Headers.TryGetValue("Authorization", out string authorization)); + Assert.Equal("Bearer test-token", authorization); + } + + [Fact] + public void EntraCredentialRequiresBaseUri() + { + Assert.Throws(() => + new HelixApiOptions( + null, + new TestTokenCredential(), + new[] { HelixApiOptions.ProductionScope })); + } + + [Fact] + public void EntraCredentialRequiresAbsoluteBaseUri() + { + Assert.Throws(() => + new HelixApiOptions( + new Uri("relative", UriKind.Relative), + new TestTokenCredential(), + new[] { HelixApiOptions.ProductionScope })); + } + + [Fact] + public void EntraCredentialRequiresHttpsBaseUri() + { + var defaultScopeException = Assert.Throws(() => + ApiFactory.GetAuthenticatedWithEntra( + "http://helix.dot.net/", + new TestTokenCredential())); + var explicitScopeException = Assert.Throws(() => + new HelixApiOptions( + new Uri("http://localhost:5001/"), + new TestTokenCredential(), + new[] { "api://custom-helix/.default" })); + + Assert.Contains("HTTPS", defaultScopeException.Message); + Assert.Contains("HTTPS", explicitScopeException.Message); + } + + [Fact] + public void CustomHostUsesExplicitScope() + { + const string scope = "api://custom-helix/.default"; + var options = new HelixApiOptions( + new Uri("https://localhost:5001/"), + new TestTokenCredential(), + new[] { scope }); + + Assert.Equal(HelixApiAuthenticationMode.EntraId, options.AuthenticationMode); + Assert.Equal(new[] { scope }, options.TokenScopes); + } + + [Fact] + public void ExplicitScopeCannotBeEmpty() + { + Assert.Throws(() => + new HelixApiOptions( + new Uri("https://localhost:5001/"), + new TestTokenCredential(), + new[] { "" })); + } + + [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 EntraFactoryUsesStagingScope() + { + var api = Assert.IsType( + ApiFactory.GetAuthenticatedWithEntra( + "https://helix.int-dot.net/", + new TestTokenCredential())); + + Assert.Equal(HelixApiAuthenticationMode.EntraId, api.Options.AuthenticationMode); + Assert.Equal(new[] { HelixApiOptions.StagingScope }, api.Options.TokenScopes); + } + + [Fact] + public void EntraFactoryRequiresExplicitScopeForCustomHost() + { + var exception = Assert.Throws(() => + ApiFactory.GetAuthenticatedWithEntra( + "https://localhost:5001/", + new TestTokenCredential())); + + Assert.Contains("explicit scopes", exception.Message); + } + + [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); + } + + [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, + new[] { HelixApiOptions.ProductionScope }) + { + 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); + } + + [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); + } + + [Fact] + public void HelixTaskUsesExplicitScopeForCustomHost() + { + const string scope = "api://custom-helix/.default"; + + var api = Assert.IsType( + HelixTask.CreateEntraHelixApi("https://custom.helix.example/", scope)); + + Assert.Equal(new[] { scope }, api.Options.TokenScopes); + } + + [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, HelixApiAuthenticationMode.Anonymous)] + [InlineData(false, "legacy-token", HelixApiAuthenticationMode.PersonalAccessToken)] + [InlineData(true, null, HelixApiAuthenticationMode.EntraId)] + [InlineData(true, "legacy-token", HelixApiAuthenticationMode.EntraId)] + public void JobMonitorSelectsRequestedAuthenticationMode( + bool useEntraAuthentication, + string accessToken, + HelixApiAuthenticationMode expectedMode) + { + var options = new JobMonitorOptions + { + HelixBaseUri = "https://helix.dot.net/", + HelixAccessToken = accessToken, + UseEntraAuthentication = useEntraAuthentication, + }; + + var api = Assert.IsType( + JobMonitorRunner.CreateHelixApi( + options, + () => ApiFactory.GetAuthenticatedWithEntra(new TestTokenCredential()))); + + Assert.Equal(expectedMode, api.Options.AuthenticationMode); + } + + [Fact] + public void JobMonitorUsesExplicitScopeForCustomHost() + { + const string scope = "api://custom-helix/.default"; + + var api = Assert.IsType( + JobMonitorRunner.CreateEntraHelixApi("https://custom.helix.example/", scope)); + + Assert.Equal(new[] { scope }, api.Options.TokenScopes); + } + + [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)); + } + + private sealed class TestTokenCredential : TokenCredential + { + public int CallCount { get; private set; } + + public ImmutableArray RequestedScopes { get; private set; } = ImmutableArray.Empty; + + public override AccessToken GetToken( + TokenRequestContext requestContext, + CancellationToken cancellationToken) + { + CallCount++; + RequestedScopes = requestContext.Scopes.ToImmutableArray(); + return new AccessToken("test-token", DateTimeOffset.UtcNow.AddMinutes(30)); + } + + public override ValueTask GetTokenAsync( + TokenRequestContext requestContext, + CancellationToken cancellationToken) + { + 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)); + } + } +} 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 d6ab231a80e..bcd18be4f1b 100644 --- a/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests.csproj +++ b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests.csproj @@ -21,6 +21,7 @@ + diff --git a/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/QueueStatsLoggingTests.cs b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/QueueStatsLoggingTests.cs index 98894388a00..2c75b88f5c5 100644 --- a/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/QueueStatsLoggingTests.cs +++ b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/QueueStatsLoggingTests.cs @@ -10,6 +10,27 @@ namespace Microsoft.DotNet.Helix.Sdk.Tests; public class QueueStatsLoggingTests { + [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)); + } + // Exercises the callback pair SendHelixJob hands to JobDefinition.SendAsync. Routine // submission progress must always stay at Normal; the opt-in queue-health summary must be // elevated to High only when EnableShowHelixQueueStats is set, so it survives the default diff --git a/src/Microsoft.DotNet.Helix/Sdk/CancelHelixJob.cs b/src/Microsoft.DotNet.Helix/Sdk/CancelHelixJob.cs index 30981366f57..06a7988811b 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/CancelHelixJob.cs +++ b/src/Microsoft.DotNet.Helix/Sdk/CancelHelixJob.cs @@ -45,15 +45,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) @@ -78,4 +84,9 @@ 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 193faf96b1a..88bcf817f75 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/GetHelixWorkItems.cs +++ b/src/Microsoft.DotNet.Helix/Sdk/GetHelixWorkItems.cs @@ -98,13 +98,7 @@ ITaskItem2 CreateTaskItem(string workItemName, IDictionary metad // latestOnly parameter is set false here to download all possible files var files = await HelixApi.WorkItem.ListFilesAsync(wi, jobName, false, 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); } @@ -119,4 +113,20 @@ 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 ff7a6f7eb8f..f3f12900503 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/HelixTask.cs +++ b/src/Microsoft.DotNet.Helix/Sdk/HelixTask.cs @@ -6,6 +6,9 @@ using System.Net; using System.Threading; using Microsoft.Build.Framework; +#if !DOTNET_BUILD_SOURCE_ONLY && !NET472_OR_GREATER +using Microsoft.DotNet.ArcadeAzureIntegration; +#endif using Microsoft.DotNet.Helix.Client; namespace Microsoft.DotNet.Helix.Sdk; @@ -24,6 +27,16 @@ 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; } + + /// + /// The explicit Entra scope to request for a custom Helix API host. + /// + public string EntraScope { 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. @@ -37,14 +50,64 @@ 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, EntraScope)); + } + 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); + } - Log.LogMessage(MessageImportance.Low, "Authenticating to helix api using provided AccessToken"); - return ApiFactory.GetAuthenticated(BaseUri, AccessToken); + 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); + } + + internal static IHelixApi CreateEntraHelixApi(string baseUri, string entraScope) + { +#if DOTNET_BUILD_SOURCE_ONLY + throw new PlatformNotSupportedException( + "Helix Entra authentication is not available in source-build."); +#elif NET472_OR_GREATER + throw new PlatformNotSupportedException( + "Helix Entra authentication is not available on .NET Framework."); +#else + var credential = new DefaultIdentityTokenCredential(); + return string.IsNullOrWhiteSpace(entraScope) + ? ApiFactory.GetAuthenticatedWithEntra(baseUri, credential) + : ApiFactory.GetAuthenticatedWithEntra(baseUri, credential, entraScope); +#endif } public void Cancel() @@ -62,7 +125,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 c7652939013..8192ef638c9 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/Microsoft.DotNet.Helix.Sdk.csproj +++ b/src/Microsoft.DotNet.Helix/Sdk/Microsoft.DotNet.Helix.Sdk.csproj @@ -7,6 +7,10 @@ **/*.Desktop.* + + $(DefineConstants);DOTNET_BUILD_SOURCE_ONLY + + @@ -25,6 +29,8 @@ + diff --git a/src/Microsoft.DotNet.Helix/Sdk/Readme.md b/src/Microsoft.DotNet.Helix/Sdk/Readme.md index ea921582162..b8e02166f6b 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/Readme.md +++ b/src/Microsoft.DotNet.Helix/Sdk/Readme.md @@ -27,7 +27,19 @@ Versions of the package can be found by browsing the feed at https://dev.azure.c ### Developing Helix SDK -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: +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 `DefaultIdentityTokenCredential`, 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. +The SDK infers the scope for the production and staging Helix hosts. Set +`HelixEntraScope` when `HelixBaseUri` identifies another HTTPS host. + +You will also need to set the following environment variables before building: ``` BUILD_SOURCEBRANCH @@ -75,7 +87,10 @@ jobs: Useful parameters: - `helixBaseUri`: base URI for the Helix service. Defaults to `https://helix.dot.net/`. -- `helixAccessToken`: optional token for authenticated Helix access on internal builds. +- `helixAccessToken`: optional token for authenticated Helix access on internal builds; ignored with a warning when `useEntraAuthentication` is enabled. +- `useEntraAuthentication`: use a refreshable Entra credential for authenticated Helix access. +- `helixEntraScope`: explicit Entra scope required when `helixBaseUri` identifies a custom host. +- `azureSubscription`: Azure service connection ID authorized for Helix; required when `useEntraAuthentication` is enabled. - `pollingIntervalSeconds`: how often the job monitor checks for new completed jobs. - `timeoutInMinutes`: overall timeout for the job monitor. - `continueOnError`: allow the pipeline to continue when the monitor job fails. Defaults to `false`. diff --git a/src/Microsoft.DotNet.Helix/Sdk/SendHelixJob.cs b/src/Microsoft.DotNet.Helix/Sdk/SendHelixJob.cs index 397ba716736..6bd52677ba0 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/SendHelixJob.cs +++ b/src/Microsoft.DotNet.Helix/Sdk/SendHelixJob.cs @@ -158,15 +158,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; } @@ -283,6 +281,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; + } + /// /// Builds the pair of log callbacks used when submitting a Helix job. Routine submission /// progress is logged at ; the opt-in queue-health 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 23d745f645e..43569ed8369 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 @@ -83,6 +83,8 @@ Creator="$(Creator)" BaseUri="$(HelixBaseUri)" AccessToken="$(HelixAccessToken)" + UseEntraAuthentication="$(HelixUseEntraAuthentication)" + EntraScope="$(HelixEntraScope)" MaxRetryCount="$(MaxRetryCount)" EnableShowHelixQueueStats="$(EnableShowHelixQueueStats)" PreCommands="$(HelixPreCommands)" 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 45f4c54b7c7..e148e4328e1 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 @@ -77,6 +77,8 @@ @@ -84,6 +86,8 @@ @@ -100,6 +104,8 @@ false + false