Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AuthN Config V2 -> HotReload Aware Authentication settings in dev mode. #2414

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
2 changes: 0 additions & 2 deletions src/Cli.Tests/TestHelper.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Microsoft.IdentityModel.Tokens;

namespace Cli.Tests
{
public static class TestHelper
Expand Down
24 changes: 24 additions & 0 deletions src/Cli/CollectionUtilities.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

namespace Cli;

/// <summary>
/// A class which contains useful methods for processing collections.
/// Pulled from Microsoft.IdentityModel.JsonWebTokens which changed the
/// helper to be internal.
/// </summary>
/// <seealso cref="https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/blob/dev/src/Microsoft.IdentityModel.Tokens/CollectionUtilities.cs"/>
internal static class CollectionUtilities
{
/// <summary>
/// Checks whether <paramref name="enumerable"/> is null or empty.
/// </summary>
/// <typeparam name="T">The type of the <paramref name="enumerable"/>.</typeparam>
/// <param name="enumerable">The <see cref="IEnumerable{T}"/> to be checked.</param>
/// <returns>True if <paramref name="enumerable"/> is null or empty, false otherwise.</returns>
public static bool IsNullOrEmpty<T>(this IEnumerable<T>? enumerable)
{
return enumerable == null || !enumerable.Any();
}
}
1 change: 0 additions & 1 deletion src/Cli/ConfigGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
using Azure.DataApiBuilder.Service;
using Cli.Commands;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using static Cli.Utils;

namespace Cli
Expand Down
2 changes: 2 additions & 0 deletions src/Config/Azure.DataApiBuilder.Config.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.AspNetCore.Authorization" />
<PackageReference Include="Microsoft.IdentityModel.Protocols" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" />
<PackageReference Include="System.IO.Abstractions" />
<PackageReference Include="System.Drawing.Common" />
<PackageReference Include="Microsoft.Data.SqlClient" />
Expand Down
46 changes: 46 additions & 0 deletions src/Config/DabChangeToken.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Microsoft.Extensions.Primitives;

namespace Azure.DataApiBuilder.Config;

/// <summary>
/// Propagates notifications that a change has occurred.
/// </summary>
/// <seealso cref=""/>
public class DabChangeToken : IChangeToken
{
private CancellationTokenSource _cts = new();

/// <summary>
/// Gets a value that indicates if a change has occurred.
/// </summary>
public bool HasChanged => _cts.IsCancellationRequested;

/// <summary>
/// Indicates if this token will pro-actively raise callbacks. If <c>false</c>, the token consumer must
/// poll <see cref="HasChanged" /> to detect changes.
/// </summary>
public bool ActiveChangeCallbacks => true;

/// <summary>
/// Registers for a callback that will be invoked when the entry has changed.
/// <see cref="HasChanged"/> MUST be set before the callback is invoked.
/// Used by ChangeToken.OnChange callback registration.
/// </summary>
/// <param name="callback">The <see cref="Action{Object}"/> to invoke.</param>
/// <param name="state">State to be passed into the callback.</param>
/// <returns>An <see cref="IDisposable"/> that is used to unregister the callback.</returns>
/// <seealso cref="https://github.com/dotnet/runtime/blob/2e0276cbbaeef01afc4cfabfb224ced729963c79/src/libraries/Microsoft.Extensions.Primitives/src/ChangeToken.cs"/>
public IDisposable RegisterChangeCallback(Action<object?> callback, object? state)
{
return _cts.Token.Register(callback, state);
}

public void SignalChange()
{
_cts.Cancel();
}
}

8 changes: 6 additions & 2 deletions src/Config/FileSystemRuntimeConfigLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,11 @@ public class FileSystemRuntimeConfigLoader : RuntimeConfigLoader
/// </summary>
public string ConfigFilePath { get; internal set; }

public FileSystemRuntimeConfigLoader(IFileSystem fileSystem, HotReloadEventHandler<HotReloadEventArgs>? handler = null, string baseConfigFilePath = DEFAULT_CONFIG_FILE_NAME, string? connectionString = null)
public FileSystemRuntimeConfigLoader(
IFileSystem fileSystem,
HotReloadEventHandler<HotReloadEventArgs>? handler = null,
string baseConfigFilePath = DEFAULT_CONFIG_FILE_NAME,
string? connectionString = null)
: base(handler, connectionString)
{
_fileSystem = fileSystem;
Expand Down Expand Up @@ -154,7 +158,7 @@ public bool TryLoadConfig(

if (!string.IsNullOrEmpty(defaultDataSourceName))
{
RuntimeConfig.DefaultDataSourceName = defaultDataSourceName;
RuntimeConfig.UpdateDefaultDataSourceName(defaultDataSourceName);
}

config = RuntimeConfig;
Expand Down
45 changes: 38 additions & 7 deletions src/Config/RuntimeConfigLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@
using Azure.DataApiBuilder.Service.Exceptions;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.Primitives;
using Npgsql;

[assembly: InternalsVisibleTo("Azure.DataApiBuilder.Service.Tests")]
namespace Azure.DataApiBuilder.Config;

public abstract class RuntimeConfigLoader
{
private DabChangeToken _changeToken;
private HotReloadEventHandler<HotReloadEventArgs>? _handler;
protected readonly string? _connectionString;

Expand All @@ -30,6 +31,39 @@ public abstract class RuntimeConfigLoader
// state in place of using out params.
public RuntimeConfig? RuntimeConfig;

public RuntimeConfigLoader(HotReloadEventHandler<HotReloadEventArgs>? handler = null, string? connectionString = null)
{
_changeToken = new DabChangeToken();
_handler = handler;
_connectionString = connectionString;
}

/// <summary>
/// Change token producer which returns an uncancelled/unsignalled change token.
/// </summary>
/// <returns>DabChangeToken</returns>
#pragma warning disable CA1024 // Use properties where appropriate
public IChangeToken GetChangeToken()
#pragma warning restore CA1024 // Use properties where appropriate
{
return _changeToken;
}

/// <summary>
/// Swaps out the old change token with a new change token and
/// signals that a change has occurred.
/// </summary>
/// <seealso cref="https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Configuration/src/ConfigurationProvider.cs">
/// Example usage of Interlocked.Exchange(...) to refresh change token.</seealso>
/// <seealso cref="https://learn.microsoft.com/en-us/dotnet/api/system.threading.interlocked.exchange?view=net-8.0">
/// Sets a variable to a specified value as an atomic operation.
/// </seealso>
private void RaiseChanged()
{
DabChangeToken previousToken = Interlocked.Exchange(ref _changeToken, new DabChangeToken());
seantleonard marked this conversation as resolved.
Show resolved Hide resolved
previousToken.SignalChange();
}

// Signals a hot reload event for OpenApiDocumentor due to config change.
protected virtual void DocumentorOnConfigChanged(HotReloadEventArgs args)
{
Expand All @@ -41,12 +75,9 @@ public void SendEventNotification(string message = "")
{
HotReloadEventArgs args = new(message);
DocumentorOnConfigChanged(args);
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: rename to indicate this function also handles signaling the change token. Currently, it says send event notification only.

public RuntimeConfigLoader(HotReloadEventHandler<HotReloadEventArgs>? handler = null, string? connectionString = null)
{
_handler = handler;
_connectionString = connectionString;
// Signal that a change has occurred to all change token listeners.
RaiseChanged();
}

/// <summary>
Expand Down Expand Up @@ -299,7 +330,7 @@ internal static string GetPgSqlConnectionStringWithApplicationName(string connec

// If the connection string does not contain the `Application Name` property, add it.
// or if the connection string contains the `Application Name` property, replace it with the DataApiBuilder Application Name.
if (connectionStringBuilder.ApplicationName.IsNullOrEmpty())
if (string.IsNullOrEmpty(connectionStringBuilder.ApplicationName))
{
connectionStringBuilder.ApplicationName = applicationName;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ protected override Task<AuthenticateResult> HandleAuthenticateAsync()

// AuthenticationTicket is Asp.Net Core Abstraction of Authentication information
// of the authenticated user.
AuthenticationTicket ticket = new(claimsPrincipal, EasyAuthAuthenticationDefaults.AUTHENTICATIONSCHEME);
AuthenticationTicket ticket = new(claimsPrincipal, SimulatorAuthenticationDefaults.AUTHENTICATIONSCHEME);
seantleonard marked this conversation as resolved.
Show resolved Hide resolved
AuthenticateResult success = AuthenticateResult.Success(ticket);
return Task.FromResult(success);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@

using System.Security.Claims;
using Azure.DataApiBuilder.Config.ObjectModel;
using Azure.DataApiBuilder.Core.AuthenticationHelpers.AuthenticationSimulator;
using Azure.DataApiBuilder.Core.Authorization;
using Azure.DataApiBuilder.Core.Configurations;
using Azure.DataApiBuilder.Core.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
Expand All @@ -23,11 +25,11 @@ namespace Azure.DataApiBuilder.Core.AuthenticationHelpers;
/// </summary>
public class ClientRoleHeaderAuthenticationMiddleware
{
private const string ANONYOUMOUS_ROLE = "Anonymous";
private const string AUTHENTICATED_ROLE = "Authenticated";
private readonly RequestDelegate _nextMiddleware;

private ILogger<ClientRoleHeaderAuthenticationMiddleware> _logger;

private bool _isLateConfigured;
private RuntimeConfigProvider _runtimeConfigProvider;

// Identity provider used for identities added to the ClaimsPrincipal object for the current user by DAB.
private const string INTERNAL_DAB_IDENTITY_PROVIDER = "DAB-VERIFIED";
Expand All @@ -38,7 +40,7 @@ public ClientRoleHeaderAuthenticationMiddleware(RequestDelegate next,
{
_nextMiddleware = next;
_logger = logger;
_isLateConfigured = runtimeConfigProvider.IsLateConfigured;
_runtimeConfigProvider = runtimeConfigProvider;
}

/// <summary>
Expand All @@ -57,23 +59,37 @@ public ClientRoleHeaderAuthenticationMiddleware(RequestDelegate next,
/// <param name="httpContext">Request metadata</param>
public async Task InvokeAsync(HttpContext httpContext)
{
// Determine the authentication scheme to use based on dab-config.json.
// Compatible with both ConfigureAuthentication and ConfigureAuthenticationV2 in startup.cs.
// This means that this code is resilient to whether or not the default authentication scheme is set in startup.
AuthenticationOptions? dabAuthNOptions = _runtimeConfigProvider.GetConfig().Runtime?.Host?.Authentication;
string scheme = ResolveConfiguredAuthNScheme(dabAuthNOptions?.Provider);

// authNResult will be one of:
// 1. Succeeded - Authenticated
// 2. Failure - Token issue
// 3. None - No token provided, no auth result.
AuthenticateResult authNResult = await httpContext.AuthenticateAsync();
AuthenticateResult authNResult = await httpContext.AuthenticateAsync(scheme);

// Reject and terminate the request when an invalid token is provided
// Write challenge response metadata (HTTP 401 Unauthorized response code
// and www-authenticate headers) to the HTTP Context via JwtBearerHandler code
// https://github.com/dotnet/aspnetcore/blob/3fe12b935c03138f76364dc877a7e069e254b5b2/src/Security/Authentication/JwtBearer/src/JwtBearerHandler.cs#L217
if (authNResult.Failure is not null)
{
await httpContext.ChallengeAsync();
await httpContext.ChallengeAsync(scheme);
return;
}

string clientDefinedRole = AuthorizationType.Anonymous.ToString();
// Manually set the httpContext.User to the Principal from the AuthenticateResult
// when we exclude setting a default authentication scheme in Startup.cs AddAuthentication().
// https://learn.microsoft.com/aspnet/core/security/authorization/limitingidentitybyscheme?view=aspnetcore-8.0
if (authNResult.Succeeded)
{
httpContext.User = authNResult.Principal;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This setup of httpContext.User is the only required change for hot reload in this file,right? Is my understanding correct that the rest of the changes in this file are good to have but not really relevant to hot reload?

I dont mind the changes but trying to understand how the value of scheme affects hot reload..

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need all the code in this file and on lines that precede this code. The following line is important because we must pass scheme to authenticateAsync to ensure the proper authentication handler is used. Scheme is determined using the runtime config. If the provider changes in runtime config, DAB must resolve that change and ensure the request is authenticated using the hotreloaded scheme.

await httpContext.AuthenticateAsync(scheme);

}

string clientDefinedRole = ANONYOUMOUS_ROLE;

// A request can be authenticated in 2 cases:
// 1. When the request has a valid jwt/easyauth token,
Expand All @@ -82,7 +98,7 @@ public async Task InvokeAsync(HttpContext httpContext)

if (isAuthenticatedRequest)
{
clientDefinedRole = AuthorizationType.Authenticated.ToString();
clientDefinedRole = AUTHENTICATED_ROLE;
}

// Attempt to inject CLIENT_ROLE_HEADER:clientDefinedRole into the httpContext
Expand All @@ -106,17 +122,16 @@ public async Task InvokeAsync(HttpContext httpContext)

// Log the request's authenticated status (anonymous/authenticated) and user role,
// only in the non-hosted scenario.
if (!_isLateConfigured)
if (!_runtimeConfigProvider.IsLateConfigured)
{
string correlationId = HttpContextExtensions.GetLoggerCorrelationId(httpContext);
string requestAuthStatus = isAuthenticatedRequest ? AuthorizationType.Authenticated.ToString() : AuthorizationType.Anonymous.ToString();
_logger.LogDebug(
message: "{correlationId} Request authentication state: {requestAuthStatus}.",
correlationId,
requestAuthStatus);
_logger.LogDebug("{correlationId} The request will be executed in the context of the role: {clientDefinedRole}",
message: "{correlationId} AuthN state: {requestAuthStatus}. Role: {clientDefinedRole}. Scheme: {scheme}",
correlationId,
clientDefinedRole);
requestAuthStatus,
clientDefinedRole,
scheme);
}

// When the user is not in the clientDefinedRole and the client role header
Expand Down Expand Up @@ -151,6 +166,34 @@ public static bool IsSystemRole(string roleName)
return roleName.Equals(AuthorizationType.Authenticated.ToString(), StringComparison.OrdinalIgnoreCase) ||
roleName.Equals(AuthorizationType.Anonymous.ToString(), StringComparison.OrdinalIgnoreCase);
}

/// <summary>
/// Uses the dab-config.json's Authentication provider name to resolve the
/// authentication scheme to use with httpContext.AuthenticateAsync(scheme).
/// </summary>
/// <param name="configuredProviderName">Dab config defined authentication provider name.</param>
/// <returns>Authentication Scheme</returns>
private static string ResolveConfiguredAuthNScheme(string? configuredProviderName)
{
switch (configuredProviderName)
{
case AuthenticationOptions.SIMULATOR_AUTHENTICATION:
return SimulatorAuthenticationDefaults.AUTHENTICATIONSCHEME;
case nameof(EasyAuthType.AppService):
return EasyAuthAuthenticationDefaults.APPSERVICEAUTHSCHEME;
case nameof(EasyAuthType.StaticWebApps):
return EasyAuthAuthenticationDefaults.SWAAUTHSCHEME;
case "AzureAD":
case "EntraID":
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we make these constants and use case-insensitive matching?

return JwtBearerDefaults.AuthenticationScheme;
case "Custom":
default:
// Changing this value is a breaking change because non-out of box
// authentication provider names supplied in dab-config.json indicate
// that JWT bearer authentication should be used.
return GenericOAuthDefaults.AUTHENTICATIONSCHEME;
}
}
}

// Extension method used to add the middleware to the HTTP request pipeline.
Expand Down
Loading