Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/Microsoft.DotNet.Helix/Client/CSharp/ApiFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -20,6 +21,15 @@ public static IHelixApi GetAuthenticated(string accessToken)
return new HelixApi(new HelixApiOptions(new HelixApiTokenCredential(accessToken)));
}

/// <summary>
/// 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.
/// </summary>
public static IHelixApi GetAuthenticatedWithEntra(TokenCredential credential)
{
Comment thread
missymessa marked this conversation as resolved.
return new HelixApi(new HelixApiOptions(ValidateEntraCredential(credential)));
}
Comment thread
missymessa marked this conversation as resolved.

/// <summary>
/// Obtains API client for unauthenticated access to external queues.
/// The client will access production Helix instance.
Expand All @@ -46,6 +56,29 @@ public static IHelixApi GetAuthenticated(string baseUri, string accessToken)
return new HelixApi(new HelixApiOptions(new Uri(baseUri), new HelixApiTokenCredential(accessToken)));
}

/// <summary>
/// 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.
/// </summary>
public static IHelixApi GetAuthenticatedWithEntra(string baseUri, TokenCredential credential)
{
return new HelixApi(new HelixApiOptions(new Uri(baseUri), ValidateEntraCredential(credential)));
}

/// <summary>
/// Obtains an API client using an Entra credential and explicit scope for a custom Helix instance.
/// </summary>
public static IHelixApi GetAuthenticatedWithEntra(
string baseUri,
TokenCredential credential,
string scope)
{
return new HelixApi(new HelixApiOptions(
new Uri(baseUri),
ValidateEntraCredential(credential),
new[] { scope }));
}

/// <summary>
/// Obtains API client for unauthenticated access to external queues.
/// The client will access Helix instance at the provided URI.
Expand All @@ -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;
}
}
}
94 changes: 93 additions & 1 deletion src/Microsoft.DotNet.Helix/Client/CSharp/HelixApiOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> 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<string> TokenScopes { get; private set; } = Array.Empty<string>();

partial void InitializeOptions()
{
if (Credentials != null)
if (Credentials == null)
{
AuthenticationMode = HelixApiAuthenticationMode.Anonymous;
}
else if (Credentials is HelixApiTokenCredential)
{
AuthenticationMode = HelixApiAuthenticationMode.PersonalAccessToken;
TokenScopes = Array.Empty<string>();
AddPolicy(new HelixApiTokenAuthenticationPolicy(Credentials), HttpPipelinePosition.PerCall);
}
else
{
AuthenticationMode = HelixApiAuthenticationMode.EntraId;
if (TokenScopes.Count == 0)
{
TokenScopes = Array.AsReadOnly(new[] { GetDefaultScope(BaseUri) });
}
Comment thread
missymessa marked this conversation as resolved.

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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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<ArgumentException>(() =>
new HelixApiOptions(new Uri("https://localhost:5001/"), new TestTokenCredential()));

Assert.Contains("explicit scopes", exception.Message);
}

[Fact]
public void EntraCredentialRequiresBaseUri()
{
Assert.Throws<ArgumentNullException>(() =>
new HelixApiOptions(null, new TestTokenCredential()));
}

[Fact]
public void EntraCredentialRequiresAbsoluteBaseUri()
{
Assert.Throws<ArgumentException>(() =>
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<ArgumentException>(() =>
new HelixApiOptions(
new Uri("https://localhost:5001/"),
new TestTokenCredential(),
new[] { "" }));
}

[Fact]
public void ExplicitScopeRequiresAbsoluteBaseUri()
{
Assert.Throws<ArgumentException>(() =>
new HelixApiOptions(
new Uri("relative", UriKind.Relative),
new TestTokenCredential(),
new[] { "api://custom-helix/.default" }));
}

[Fact]
public void ExplicitScopeRejectsPatCredential()
{
var exception = Assert.Throws<ArgumentException>(() =>
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<HelixApi>(
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<ArgumentNullException>(() =>
ApiFactory.GetAuthenticatedWithEntra(null));
Assert.Throws<ArgumentNullException>(() =>
ApiFactory.GetAuthenticatedWithEntra("https://helix.dot.net/", null));
Assert.Throws<ArgumentNullException>(() =>
ApiFactory.GetAuthenticatedWithEntra(
"https://localhost:5001/",
null,
"api://custom-helix/.default"));
}

[Fact]
public void EntraFactoryRejectsPatCredential()
{
var credential = new HelixApiTokenCredential("legacy-token");

var productionException = Assert.Throws<ArgumentException>(() =>
ApiFactory.GetAuthenticatedWithEntra(credential));
var hostException = Assert.Throws<ArgumentException>(() =>
ApiFactory.GetAuthenticatedWithEntra("https://helix.dot.net/", credential));
var explicitScopeException = Assert.Throws<ArgumentException>(() =>
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<AccessToken> GetTokenAsync(
TokenRequestContext requestContext,
CancellationToken cancellationToken)
{
return new ValueTask<AccessToken>(GetToken(requestContext, cancellationToken));
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<ProjectReference Include="..\..\..\Common\Microsoft.Arcade.Common\Microsoft.Arcade.Common.csproj" />
<ProjectReference Include="..\..\..\Common\Microsoft.Arcade.Test.Common\Microsoft.Arcade.Test.Common.csproj" />
<ProjectReference Include="..\..\..\Microsoft.DotNet.Internal.DependencyInjection.Testing\Microsoft.DotNet.Internal.DependencyInjection.Testing.csproj" />
<ProjectReference Include="..\..\Client\CSharp\Microsoft.DotNet.Helix.Client.csproj" />
<ProjectReference Include="..\..\Sdk\Microsoft.DotNet.Helix.Sdk.csproj" />
<ProjectReference Include="..\..\JobMonitor\Microsoft.DotNet.Helix.JobMonitor.csproj" />
</ItemGroup>
Expand Down
Loading