diff --git a/src/Microsoft.DotNet.Helix/Client/CSharp/ApiFactory.cs b/src/Microsoft.DotNet.Helix/Client/CSharp/ApiFactory.cs index f5f6f7cec00..427101e2cef 100644 --- a/src/Microsoft.DotNet.Helix/Client/CSharp/ApiFactory.cs +++ b/src/Microsoft.DotNet.Helix/Client/CSharp/ApiFactory.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using Azure.Core; namespace Microsoft.DotNet.Helix.Client { @@ -20,6 +21,15 @@ public static IHelixApi GetAuthenticated(string accessToken) return new HelixApi(new HelixApiOptions(new HelixApiTokenCredential(accessToken))); } + /// + /// Obtains an API client using an Entra credential for authenticated access to internal queues. + /// The client requests the production Helix API scope and refreshes tokens based on their expiry. + /// + public static IHelixApi GetAuthenticatedWithEntra(TokenCredential credential) + { + return new HelixApi(new HelixApiOptions(ValidateEntraCredential(credential))); + } + /// /// Obtains API client for unauthenticated access to external queues. /// The client will access production Helix instance. @@ -46,6 +56,29 @@ public static IHelixApi GetAuthenticated(string baseUri, string accessToken) return new HelixApi(new HelixApiOptions(new Uri(baseUri), new HelixApiTokenCredential(accessToken))); } + /// + /// Obtains an API client using an Entra credential for authenticated access to the provided Helix instance. + /// Production and staging scopes are selected from the base URI. + /// + public static IHelixApi GetAuthenticatedWithEntra(string baseUri, TokenCredential credential) + { + return new HelixApi(new HelixApiOptions(new Uri(baseUri), ValidateEntraCredential(credential))); + } + + /// + /// Obtains an API client using an Entra credential and explicit scope for a custom Helix instance. + /// + public static IHelixApi GetAuthenticatedWithEntra( + string baseUri, + TokenCredential credential, + string scope) + { + return new HelixApi(new HelixApiOptions( + new Uri(baseUri), + ValidateEntraCredential(credential), + new[] { scope })); + } + /// /// Obtains API client for unauthenticated access to external queues. /// The client will access Helix instance at the provided URI. @@ -58,5 +91,22 @@ public static IHelixApi GetAnonymous(string baseUri) { return new HelixApi(new HelixApiOptions(new Uri(baseUri))); } + + private static TokenCredential ValidateEntraCredential(TokenCredential credential) + { + if (credential == null) + { + throw new ArgumentNullException(nameof(credential)); + } + + if (credential is HelixApiTokenCredential) + { + throw new ArgumentException( + "HelixApiTokenCredential represents a PAT. Use GetAuthenticated(...) for PAT authentication.", + nameof(credential)); + } + + return credential; + } } } diff --git a/src/Microsoft.DotNet.Helix/Client/CSharp/HelixApiOptions.cs b/src/Microsoft.DotNet.Helix/Client/CSharp/HelixApiOptions.cs index e525ec33435..9e44c61994b 100644 --- a/src/Microsoft.DotNet.Helix/Client/CSharp/HelixApiOptions.cs +++ b/src/Microsoft.DotNet.Helix/Client/CSharp/HelixApiOptions.cs @@ -2,26 +2,118 @@ // The .NET Foundation licenses this file to you under the MIT license. using Azure.Core; +using Azure.Core.Pipeline; using System; +using System.Collections.Generic; +using System.Linq; namespace Microsoft.DotNet.Helix.Client { + public enum HelixApiAuthenticationMode + { + Anonymous, + PersonalAccessToken, + EntraId, + } + partial class HelixApiOptions { + public const string ProductionScope = "api://eb70c40b-c265-44f7-842e-1a568f035f33/.default"; + public const string StagingScope = "api://f45b17a4-149b-4f89-91bc-e6331af8d0e8/.default"; + // See https://github.com/Azure/azure-sdk-for-net/blob/master/sdk/core/Azure.Core/src/RetryOptions.cs for values this overrides public const int DefaultRetryDelaySeconds = 10; public const int DefaultMaxRetryCount = 5; + public HelixApiOptions(Uri baseUri, TokenCredential credentials, IEnumerable scopes) + { + BaseUri = ValidateBaseUri(baseUri); + Credentials = credentials ?? throw new ArgumentNullException(nameof(credentials)); + if (credentials is HelixApiTokenCredential) + { + throw new ArgumentException( + "Explicit scopes are only supported for Entra credentials. " + + "For PAT authentication, pass HelixApiTokenCredential without explicit scopes.", + nameof(credentials)); + } + + string[] tokenScopes = scopes?.ToArray() ?? throw new ArgumentNullException(nameof(scopes)); + if (tokenScopes.Length == 0 || tokenScopes.Any(string.IsNullOrWhiteSpace)) + { + throw new ArgumentException("At least one non-empty token scope is required.", nameof(scopes)); + } + TokenScopes = Array.AsReadOnly(tokenScopes); + + InitializeOptions(); + } + + public HelixApiAuthenticationMode AuthenticationMode { get; private set; } + + public IReadOnlyList TokenScopes { get; private set; } = Array.Empty(); + partial void InitializeOptions() { - if (Credentials != null) + if (Credentials == null) { + AuthenticationMode = HelixApiAuthenticationMode.Anonymous; + } + else if (Credentials is HelixApiTokenCredential) + { + AuthenticationMode = HelixApiAuthenticationMode.PersonalAccessToken; + TokenScopes = Array.Empty(); AddPolicy(new HelixApiTokenAuthenticationPolicy(Credentials), HttpPipelinePosition.PerCall); } + else + { + AuthenticationMode = HelixApiAuthenticationMode.EntraId; + if (TokenScopes.Count == 0) + { + TokenScopes = Array.AsReadOnly(new[] { GetDefaultScope(BaseUri) }); + } + + AddPolicy( + new BearerTokenAuthenticationPolicy(Credentials, TokenScopes.ToArray()), + HttpPipelinePosition.PerRetry); + } // Users should not generally need to modify these but can do so after creating a HelixApi object if needed Retry.Delay = TimeSpan.FromSeconds(DefaultRetryDelaySeconds); Retry.MaxRetries = DefaultMaxRetryCount; } + + private static string GetDefaultScope(Uri baseUri) + { + baseUri = ValidateBaseUri(baseUri); + + if (baseUri.Host.Equals("helix.dot.net", StringComparison.OrdinalIgnoreCase)) + { + return ProductionScope; + } + + if (baseUri.Host.Equals("helix.int-dot.net", StringComparison.OrdinalIgnoreCase)) + { + return StagingScope; + } + + throw new ArgumentException( + $"No default Entra scope is known for Helix API host '{baseUri.Host}'. " + + "Use the HelixApiOptions constructor that accepts explicit scopes.", + nameof(baseUri)); + } + + private static Uri ValidateBaseUri(Uri baseUri) + { + if (baseUri == null) + { + throw new ArgumentNullException(nameof(baseUri)); + } + + if (!baseUri.IsAbsoluteUri) + { + throw new ArgumentException("The Helix API base URI must be absolute.", nameof(baseUri)); + } + + return baseUri; + } } } diff --git a/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/HelixApiAuthenticationTests.cs b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/HelixApiAuthenticationTests.cs new file mode 100644 index 00000000000..8772bb4b519 --- /dev/null +++ b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/HelixApiAuthenticationTests.cs @@ -0,0 +1,182 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Microsoft.DotNet.Helix.Client; +using Xunit; + +namespace Microsoft.DotNet.Helix.Sdk.Tests +{ + public class HelixApiAuthenticationTests + { + [Fact] + public void AnonymousOptionsExposeAnonymousMode() + { + var options = new HelixApiOptions(); + + Assert.Equal(HelixApiAuthenticationMode.Anonymous, options.AuthenticationMode); + Assert.Empty(options.TokenScopes); + } + + [Fact] + public void PatCredentialPreservesLegacyAuthenticationMode() + { + var options = new HelixApiOptions(new HelixApiTokenCredential("legacy-token")); + + Assert.Equal(HelixApiAuthenticationMode.PersonalAccessToken, options.AuthenticationMode); + Assert.Empty(options.TokenScopes); + } + + [Fact] + public void ProductionCredentialUsesProductionScope() + { + var options = new HelixApiOptions(new TestTokenCredential()); + + Assert.Equal(HelixApiAuthenticationMode.EntraId, options.AuthenticationMode); + Assert.Equal(new[] { HelixApiOptions.ProductionScope }, options.TokenScopes); + } + + [Fact] + public void StagingCredentialUsesStagingScope() + { + var options = new HelixApiOptions( + new Uri("https://helix.int-dot.net/"), + new TestTokenCredential()); + + Assert.Equal(HelixApiAuthenticationMode.EntraId, options.AuthenticationMode); + Assert.Equal(new[] { HelixApiOptions.StagingScope }, options.TokenScopes); + } + + [Fact] + public void CustomHostRequiresExplicitScope() + { + var exception = Assert.Throws(() => + new HelixApiOptions(new Uri("https://localhost:5001/"), new TestTokenCredential())); + + Assert.Contains("explicit scopes", exception.Message); + } + + [Fact] + public void EntraCredentialRequiresBaseUri() + { + Assert.Throws(() => + new HelixApiOptions(null, new TestTokenCredential())); + } + + [Fact] + public void EntraCredentialRequiresAbsoluteBaseUri() + { + Assert.Throws(() => + new HelixApiOptions(new Uri("relative", UriKind.Relative), new TestTokenCredential())); + } + + [Fact] + public void CustomHostUsesExplicitScope() + { + const string scope = "api://custom-helix/.default"; + var options = new HelixApiOptions( + new Uri("https://localhost:5001/"), + new TestTokenCredential(), + new[] { scope }); + + Assert.Equal(HelixApiAuthenticationMode.EntraId, options.AuthenticationMode); + Assert.Equal(new[] { scope }, options.TokenScopes); + } + + [Fact] + public void ExplicitScopeCannotBeEmpty() + { + Assert.Throws(() => + new HelixApiOptions( + new Uri("https://localhost:5001/"), + new TestTokenCredential(), + new[] { "" })); + } + + [Fact] + public void ExplicitScopeRequiresAbsoluteBaseUri() + { + Assert.Throws(() => + new HelixApiOptions( + new Uri("relative", UriKind.Relative), + new TestTokenCredential(), + new[] { "api://custom-helix/.default" })); + } + + [Fact] + public void ExplicitScopeRejectsPatCredential() + { + var exception = Assert.Throws(() => + new HelixApiOptions( + new Uri("https://localhost:5001/"), + new HelixApiTokenCredential("legacy-token"), + new[] { "api://custom-helix/.default" })); + + Assert.Contains("without explicit scopes", exception.Message); + } + + [Fact] + public void EntraFactoryUsesProductionScope() + { + var api = Assert.IsType( + ApiFactory.GetAuthenticatedWithEntra(new TestTokenCredential())); + + Assert.Equal(HelixApiAuthenticationMode.EntraId, api.Options.AuthenticationMode); + Assert.Equal(new[] { HelixApiOptions.ProductionScope }, api.Options.TokenScopes); + } + + [Fact] + public void EntraFactoryRequiresCredential() + { + Assert.Throws(() => + ApiFactory.GetAuthenticatedWithEntra(null)); + Assert.Throws(() => + ApiFactory.GetAuthenticatedWithEntra("https://helix.dot.net/", null)); + Assert.Throws(() => + ApiFactory.GetAuthenticatedWithEntra( + "https://localhost:5001/", + null, + "api://custom-helix/.default")); + } + + [Fact] + public void EntraFactoryRejectsPatCredential() + { + var credential = new HelixApiTokenCredential("legacy-token"); + + var productionException = Assert.Throws(() => + ApiFactory.GetAuthenticatedWithEntra(credential)); + var hostException = Assert.Throws(() => + ApiFactory.GetAuthenticatedWithEntra("https://helix.dot.net/", credential)); + var explicitScopeException = Assert.Throws(() => + ApiFactory.GetAuthenticatedWithEntra( + "https://localhost:5001/", + credential, + "api://custom-helix/.default")); + + Assert.Contains("GetAuthenticated", productionException.Message); + Assert.Contains("GetAuthenticated", hostException.Message); + Assert.Contains("GetAuthenticated", explicitScopeException.Message); + } + + private sealed class TestTokenCredential : TokenCredential + { + public override AccessToken GetToken( + TokenRequestContext requestContext, + CancellationToken cancellationToken) + { + return new AccessToken("test-token", DateTimeOffset.UtcNow.AddMinutes(30)); + } + + public override ValueTask GetTokenAsync( + TokenRequestContext requestContext, + CancellationToken cancellationToken) + { + return new ValueTask(GetToken(requestContext, cancellationToken)); + } + } + } +} diff --git a/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests.csproj b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests.csproj index c464b912e7d..2038e551ea9 100644 --- a/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests.csproj +++ b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests.csproj @@ -21,6 +21,7 @@ +