Skip to content
Draft
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
12 changes: 12 additions & 0 deletions schemas/dab.draft.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,18 @@
"issuer": {
"type": "string",
"description": "The expected issuer (iss) claim of incoming JWT tokens."
},
"rolesPath": {
"type": "string",
"description": "The JWT claim name that should be treated as the role claim for authorization checks. Defaults to 'roles'."
},
"rolesSeparator": {
"type": "string",
"description": "Optional separator used when the configured role claim is emitted as a single string containing multiple roles or permissions. If omitted, the role claim is treated as a single scalar value unless it is a JSON array."
},
"jwksUrl": {
"type": "string",
"description": "Optional JWKS endpoint URL. If omitted, DAB derives it from the issuer as '{issuer}/.well-known/jwks.json'."
}
},
"required": [ "audience", "issuer" ]
Expand Down
11 changes: 11 additions & 0 deletions src/Config/ObjectModel/DataSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ public int DatasourceThresholdMs
SetSessionContext: ReadBoolOption(namingPolicy.ConvertName(nameof(MsSqlOptions.SetSessionContext))));
}

if (typeof(TOptionType).IsAssignableFrom(typeof(PostgreSqlOptions)))
{
return (TOptionType)(object)new PostgreSqlOptions(
SetSessionContext: ReadBoolOption(namingPolicy.ConvertName(nameof(PostgreSqlOptions.SetSessionContext))));
}

throw new NotSupportedException($"The type {typeof(TOptionType).FullName} is not a supported strongly typed options object");
}

Expand Down Expand Up @@ -126,6 +132,11 @@ public record CosmosDbNoSQLDataSourceOptions(string? Database, string? Container
/// </summary>
public record MsSqlOptions(bool SetSessionContext = true) : IDataSourceOptions;

/// <summary>
/// Options for PostgreSql database.
/// </summary>
public record PostgreSqlOptions(bool SetSessionContext = true) : IDataSourceOptions;

/// <summary>
/// Options for user-delegated authentication (OBO) for a data source.
///
Expand Down
32 changes: 31 additions & 1 deletion src/Config/ObjectModel/JwtOptions.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,36 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Text.Json.Serialization;

namespace Azure.DataApiBuilder.Config.ObjectModel;

public record JwtOptions(string? Audience, string? Issuer);
public record JwtOptions
{
[JsonPropertyName("audience")]
public string? Audience { get; init; }

[JsonPropertyName("issuer")]
public string? Issuer { get; init; }

[JsonPropertyName("rolesPath")]
public string? RolesPath { get; init; }

[JsonPropertyName("rolesSeparator")]
public string? RolesSeparator { get; init; }

[JsonPropertyName("jwksUrl")]
public string? JwksUrl { get; init; }

public string ResolvedRoleClaimType => string.IsNullOrWhiteSpace(RolesPath)
? AuthenticationOptions.ROLE_CLAIM_TYPE
: RolesPath;

public string? ResolvedRolesSeparator => string.IsNullOrEmpty(RolesSeparator)
? null
: RolesSeparator;

public string? ResolvedJwksUrl => string.IsNullOrWhiteSpace(JwksUrl)
? (string.IsNullOrWhiteSpace(Issuer) ? null : $"{Issuer.TrimEnd('/')}/.well-known/jwks.json")
: JwksUrl;
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ public async Task InvokeAsync(HttpContext httpContext)
// 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
if (authNResult.Succeeded)
if (authNResult.Succeeded && authNResult.Principal is not null)
{
httpContext.User = authNResult.Principal;
}
Expand Down Expand Up @@ -198,7 +198,8 @@ private static string ResolveConfiguredAuthNScheme(string? configuredProviderNam
return UnauthenticatedAuthenticationDefaults.AUTHENTICATIONSCHEME;
}
else if (string.Equals(configuredProviderName, SupportedAuthNProviders.AZURE_AD, StringComparison.OrdinalIgnoreCase) ||
string.Equals(configuredProviderName, SupportedAuthNProviders.ENTRA_ID, StringComparison.OrdinalIgnoreCase))
string.Equals(configuredProviderName, SupportedAuthNProviders.ENTRA_ID, StringComparison.OrdinalIgnoreCase) ||
string.Equals(configuredProviderName, SupportedAuthNProviders.GENERIC_OAUTH, StringComparison.OrdinalIgnoreCase))
{
return JwtBearerDefaults.AuthenticationScheme;
}
Expand Down
42 changes: 38 additions & 4 deletions src/Core/AuthenticationHelpers/ConfigureJwtBearerOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
using Azure.DataApiBuilder.Core.Configurations;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Microsoft.IdentityModel.Validators;
using Azure.DataApiBuilder.Core.AuthenticationHelpers;

namespace Azure.DataApiBuilder.Service;

Expand Down Expand Up @@ -49,14 +51,46 @@ public void Configure(string? name, JwtBearerOptions options)
options.MapInboundClaims = false;
options.Audience = newAuthOptions.Jwt.Audience;
options.Authority = newAuthOptions.Jwt.Issuer;

string? jwksUri = newAuthOptions.Jwt.ResolvedJwksUrl;
if (string.IsNullOrWhiteSpace(jwksUri))
{
return;
}

JsonWebKeySet jwks;

using (HttpClient client = JwtHttpClientFactory.Create())
{
string jwksJson = client.GetStringAsync(jwksUri).GetAwaiter().GetResult();
jwks = new JsonWebKeySet(jwksJson);
}

options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters()
{
ValidAudience = newAuthOptions.Jwt.Audience,
ValidIssuer = newAuthOptions.Jwt.Issuer,
// Instructs the asp.net core middleware to use the data in the "roles" claim for User.IsInRole()
// See https://learn.microsoft.com/en-us/dotnet/api/system.security.claims.claimsprincipal.isinrole#remarks
// This should eventually be configurable to address #2395
RoleClaimType = AuthenticationOptions.ROLE_CLAIM_TYPE
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKeys = jwks.Keys,
// Instructs the asp.net core middleware which JWT claim to use for User.IsInRole()
// Defaults to "roles" when not explicitly configured.
RoleClaimType = newAuthOptions.Jwt.ResolvedRoleClaimType
};

options.Events = new JwtBearerEvents
{
OnTokenValidated = context =>
{
JwtRoleClaimsTransformer.NormalizeRoleClaims(
principal: context.Principal!,
sourceRoleClaimType: newAuthOptions.Jwt.ResolvedRoleClaimType,
separator: newAuthOptions.Jwt.ResolvedRolesSeparator);

return Task.CompletedTask;
}
};

if (newAuthOptions.Provider.Equals("AzureAD") || newAuthOptions.Provider.Equals("EntraID"))
Expand Down
23 changes: 23 additions & 0 deletions src/Core/AuthenticationHelpers/JwtHttpClientFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

namespace Azure.DataApiBuilder.Service;

public static class JwtHttpClientFactory
{
public static HttpClient Create()
{
bool allowSelfSigned = Environment.GetEnvironmentVariable("USE_SELF_SIGNED_CERT")
?.Equals("true", StringComparison.OrdinalIgnoreCase) == true;

HttpClientHandler handler = new();

if (allowSelfSigned)
{
handler.ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
}

return new HttpClient(handler, disposeHandler: true);
}
}
127 changes: 127 additions & 0 deletions src/Core/AuthenticationHelpers/JwtRoleClaimsTransformer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Security.Claims;
using System.Text.Json;

namespace Azure.DataApiBuilder.Core.AuthenticationHelpers;

public static class JwtRoleClaimsTransformer
{
public static void NormalizeRoleClaims(
ClaimsPrincipal principal,
string sourceRoleClaimType,
string? separator)
{
foreach (ClaimsIdentity identity in principal.Identities)
{
if (!identity.IsAuthenticated)
{
continue;
}

List<Claim> sourceClaims = identity.Claims
.Where(c => c.Type.Equals(sourceRoleClaimType, StringComparison.Ordinal))
.ToList();

if (sourceClaims.Count == 0)
{
continue;
}

HashSet<string> normalizedValues = new(StringComparer.OrdinalIgnoreCase);

foreach (Claim claim in sourceClaims)
{
foreach (string expandedValue in ExpandClaimValues(claim.Value, separator))
{
if (!string.IsNullOrWhiteSpace(expandedValue))
{
normalizedValues.Add(expandedValue.Trim());
}
}
}

foreach (string normalizedValue in normalizedValues)
{
bool exactClaimAlreadyExists = identity.Claims.Any(c =>
c.Type.Equals(sourceRoleClaimType, StringComparison.Ordinal) &&
c.Value.Equals(normalizedValue, StringComparison.OrdinalIgnoreCase));

if (!exactClaimAlreadyExists)
{
identity.AddClaim(new Claim(sourceRoleClaimType, normalizedValue, ClaimValueTypes.String));
}
}
}
}

private static IEnumerable<string> ExpandClaimValues(string rawValue, string? separator)
{
if (string.IsNullOrWhiteSpace(rawValue))
{
yield break;
}

string trimmed = rawValue.Trim();

// 1. JSON array support
if (trimmed.StartsWith('[') && trimmed.EndsWith(']'))
{
List<string>? values;
try
{
values = JsonSerializer.Deserialize<List<string>>(trimmed);
}
catch (JsonException)
{
values = null;
}

if (values is not null)
{
foreach (string value in values)
{
if (!string.IsNullOrWhiteSpace(value))
{
yield return value.Trim();
}
}

yield break;
}
}

// 2. Configurable separated string support
if (!string.IsNullOrEmpty(separator))
{
string[] splitValues;

if (separator.Length == 1)
{
splitValues = trimmed.Split(
separator[0],
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
else
{
splitValues = trimmed.Split(
new[] { separator },
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}

foreach (string value in splitValues)
{
if (!string.IsNullOrWhiteSpace(value))
{
yield return value;
}
}

yield break;
}

// 3. Single scalar fallback
yield return trimmed;
}
}
11 changes: 8 additions & 3 deletions src/Core/Authorization/AuthorizationResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -718,12 +718,16 @@ public static Dictionary<string, List<Claim>> GetAllAuthenticatedUserClaims(Http
continue;
}

string resolvedRoleClaimType = string.IsNullOrWhiteSpace(identity.RoleClaimType)
? AuthenticationOptions.ROLE_CLAIM_TYPE
: identity.RoleClaimType;

// DAB will only resolve one 'roles' claim whose value matches the x-ms-api-role header value
// because DAB executes requests in the context of a single role. The `roles` claim
// resolved here can be forwarded to MSSQL's set-session-context. Modifying this behavior
// is a breaking change.
if (!resolvedClaims.ContainsKey(AuthenticationOptions.ROLE_CLAIM_TYPE) &&
identity.HasClaim(type: AuthenticationOptions.ROLE_CLAIM_TYPE, value: clientRoleHeader))
identity.HasClaim(type: resolvedRoleClaimType, value: clientRoleHeader))
{
List<Claim> roleClaim = new()
{
Expand All @@ -737,8 +741,9 @@ public static Dictionary<string, List<Claim>> GetAllAuthenticatedUserClaims(Http
// into a list and storing that in resolvedClaims using the claimType as the key.
foreach (Claim claim in identity.Claims)
{
// 'roles' claim has already been processed. But we preserve the original 'roles' claim.
if (claim.Type.Equals(AuthenticationOptions.ROLE_CLAIM_TYPE))
// The source JWT role claim has already been processed.
// Preserve the original source claim under original_roles.
if (claim.Type.Equals(resolvedRoleClaimType))
{
if (!resolvedClaims.TryAdd(AuthenticationOptions.ORIGINAL_ROLE_CLAIM_TYPE, new List<Claim>() { claim }))
{
Expand Down
8 changes: 7 additions & 1 deletion src/Core/Resolvers/OboSqlTokenProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -222,9 +222,15 @@ private static string ComputeAuthorizationContextHash(ClaimsPrincipal principal)
{
List<string> values = [];

HashSet<string> roleClaimTypes = principal.Identities
.Select(identity => string.IsNullOrWhiteSpace(identity.RoleClaimType)
? AuthenticationOptions.ROLE_CLAIM_TYPE
: identity.RoleClaimType)
.ToHashSet(StringComparer.OrdinalIgnoreCase);

foreach (Claim claim in principal.Claims)
{
if (claim.Type.Equals(AuthenticationOptions.ROLE_CLAIM_TYPE, StringComparison.OrdinalIgnoreCase) ||
if (roleClaimTypes.Contains(claim.Type) ||
claim.Type.Equals("scp", StringComparison.OrdinalIgnoreCase))
{
string[] parts = claim.Value.Split(
Expand Down
Loading