Skip to content

Commit 549eabd

Browse files
committed
* configurable JWT role claim handling (rolesPath, rolesSeparator)
* optional JWKS URL support for JWT validation (jwksUrl) * JWT role normalization during token validation * related authorization updates to respect configured role claim types * PostgreSQL session-context support for propagated claims * a few smaller robustness fixes in the affected components
1 parent afbffb4 commit 549eabd

13 files changed

Lines changed: 471 additions & 52 deletions

File tree

schemas/dab.draft.schema.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,18 @@
457457
"issuer": {
458458
"type": "string",
459459
"description": "The expected issuer (iss) claim of incoming JWT tokens."
460+
},
461+
"rolesPath": {
462+
"type": "string",
463+
"description": "The JWT claim name that should be treated as the role claim for authorization checks. Defaults to 'roles'."
464+
},
465+
"rolesSeparator": {
466+
"type": "string",
467+
"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."
468+
},
469+
"jwksUri": {
470+
"type": "string",
471+
"description": "Optional JWKS endpoint URI. If omitted, DAB derives it from the issuer as '{issuer}/.well-known/jwks.json'."
460472
}
461473
},
462474
"required": [ "audience", "issuer" ]

src/Config/ObjectModel/DataSource.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ public int DatasourceThresholdMs
8383
SetSessionContext: ReadBoolOption(namingPolicy.ConvertName(nameof(MsSqlOptions.SetSessionContext))));
8484
}
8585

86+
if (typeof(TOptionType).IsAssignableFrom(typeof(PostgreSqlOptions)))
87+
{
88+
return (TOptionType)(object)new PostgreSqlOptions(
89+
SetSessionContext: ReadBoolOption(namingPolicy.ConvertName(nameof(PostgreSqlOptions.SetSessionContext))));
90+
}
91+
8692
throw new NotSupportedException($"The type {typeof(TOptionType).FullName} is not a supported strongly typed options object");
8793
}
8894

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

135+
/// <summary>
136+
/// Options for PostgreSql database.
137+
/// </summary>
138+
public record PostgreSqlOptions(bool SetSessionContext = true) : IDataSourceOptions;
139+
129140
/// <summary>
130141
/// Options for user-delegated authentication (OBO) for a data source.
131142
///
Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,36 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4+
using System.Text.Json.Serialization;
5+
46
namespace Azure.DataApiBuilder.Config.ObjectModel;
57

6-
public record JwtOptions(string? Audience, string? Issuer);
8+
public record JwtOptions
9+
{
10+
[JsonPropertyName("audience")]
11+
public string? Audience { get; init; }
12+
13+
[JsonPropertyName("issuer")]
14+
public string? Issuer { get; init; }
15+
16+
[JsonPropertyName("rolesPath")]
17+
public string? RolesPath { get; init; }
18+
19+
[JsonPropertyName("rolesSeparator")]
20+
public string? RolesSeparator { get; init; }
21+
22+
[JsonPropertyName("jwksUrl")]
23+
public string? JwksUrl { get; init; }
24+
25+
public string ResolvedRoleClaimType => string.IsNullOrWhiteSpace(RolesPath)
26+
? AuthenticationOptions.ROLE_CLAIM_TYPE
27+
: RolesPath;
28+
29+
public string? ResolvedRolesSeparator => string.IsNullOrEmpty(RolesSeparator)
30+
? null
31+
: RolesSeparator;
32+
33+
public string? ResolvedJwksUrl => string.IsNullOrWhiteSpace(JwksUrl)
34+
? (string.IsNullOrWhiteSpace(Issuer) ? null : $"{Issuer.TrimEnd('/')}/.well-known/jwks.json")
35+
: JwksUrl;
36+
}

src/Core/AuthenticationHelpers/ClientRoleHeaderAuthenticationMiddleware.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ public async Task InvokeAsync(HttpContext httpContext)
8989
// Manually set the httpContext.User to the Principal from the AuthenticateResult
9090
// when we exclude setting a default authentication scheme in Startup.cs AddAuthentication().
9191
// https://learn.microsoft.com/aspnet/core/security/authorization/limitingidentitybyscheme
92-
if (authNResult.Succeeded)
92+
if (authNResult.Succeeded && authNResult.Principal is not null)
9393
{
9494
httpContext.User = authNResult.Principal;
9595
}
@@ -198,7 +198,8 @@ private static string ResolveConfiguredAuthNScheme(string? configuredProviderNam
198198
return UnauthenticatedAuthenticationDefaults.AUTHENTICATIONSCHEME;
199199
}
200200
else if (string.Equals(configuredProviderName, SupportedAuthNProviders.AZURE_AD, StringComparison.OrdinalIgnoreCase) ||
201-
string.Equals(configuredProviderName, SupportedAuthNProviders.ENTRA_ID, StringComparison.OrdinalIgnoreCase))
201+
string.Equals(configuredProviderName, SupportedAuthNProviders.ENTRA_ID, StringComparison.OrdinalIgnoreCase) ||
202+
string.Equals(configuredProviderName, SupportedAuthNProviders.GENERIC_OAUTH, StringComparison.OrdinalIgnoreCase))
202203
{
203204
return JwtBearerDefaults.AuthenticationScheme;
204205
}

src/Core/AuthenticationHelpers/ConfigureJwtBearerOptions.cs

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
using Azure.DataApiBuilder.Core.Configurations;
66
using Microsoft.AspNetCore.Authentication.JwtBearer;
77
using Microsoft.Extensions.Options;
8+
using Microsoft.IdentityModel.Tokens;
89
using Microsoft.IdentityModel.Validators;
10+
using Azure.DataApiBuilder.Core.AuthenticationHelpers;
911

1012
namespace Azure.DataApiBuilder.Service;
1113

@@ -49,14 +51,46 @@ public void Configure(string? name, JwtBearerOptions options)
4951
options.MapInboundClaims = false;
5052
options.Audience = newAuthOptions.Jwt.Audience;
5153
options.Authority = newAuthOptions.Jwt.Issuer;
54+
55+
string? jwksUri = newAuthOptions.Jwt.ResolvedJwksUrl;
56+
if (string.IsNullOrWhiteSpace(jwksUri))
57+
{
58+
return;
59+
}
60+
61+
JsonWebKeySet jwks;
62+
63+
using (HttpClient client = JwtHttpClientFactory.Create())
64+
{
65+
string jwksJson = client.GetStringAsync(jwksUri).GetAwaiter().GetResult();
66+
jwks = new JsonWebKeySet(jwksJson);
67+
}
68+
5269
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters()
5370
{
5471
ValidAudience = newAuthOptions.Jwt.Audience,
5572
ValidIssuer = newAuthOptions.Jwt.Issuer,
56-
// Instructs the asp.net core middleware to use the data in the "roles" claim for User.IsInRole()
57-
// See https://learn.microsoft.com/en-us/dotnet/api/system.security.claims.claimsprincipal.isinrole#remarks
58-
// This should eventually be configurable to address #2395
59-
RoleClaimType = AuthenticationOptions.ROLE_CLAIM_TYPE
73+
ValidateIssuer = true,
74+
ValidateAudience = true,
75+
ValidateLifetime = true,
76+
ValidateIssuerSigningKey = true,
77+
IssuerSigningKeys = jwks.Keys,
78+
// Instructs the asp.net core middleware which JWT claim to use for User.IsInRole()
79+
// Defaults to "roles" when not explicitly configured.
80+
RoleClaimType = newAuthOptions.Jwt.ResolvedRoleClaimType
81+
};
82+
83+
options.Events = new JwtBearerEvents
84+
{
85+
OnTokenValidated = context =>
86+
{
87+
JwtRoleClaimsTransformer.NormalizeRoleClaims(
88+
principal: context.Principal!,
89+
sourceRoleClaimType: newAuthOptions.Jwt.ResolvedRoleClaimType,
90+
separator: newAuthOptions.Jwt.ResolvedRolesSeparator);
91+
92+
return Task.CompletedTask;
93+
}
6094
};
6195

6296
if (newAuthOptions.Provider.Equals("AzureAD") || newAuthOptions.Provider.Equals("EntraID"))
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
namespace Azure.DataApiBuilder.Service;
5+
6+
public static class JwtHttpClientFactory
7+
{
8+
public static HttpClient Create()
9+
{
10+
bool allowSelfSigned = Environment.GetEnvironmentVariable("USE_SELF_SIGNED_CERT")
11+
?.Equals("true", StringComparison.OrdinalIgnoreCase) == true;
12+
13+
HttpClientHandler handler = new();
14+
15+
if (allowSelfSigned)
16+
{
17+
handler.ServerCertificateCustomValidationCallback =
18+
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
19+
}
20+
21+
return new HttpClient(handler, disposeHandler: true);
22+
}
23+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System.Security.Claims;
5+
using System.Text.Json;
6+
7+
namespace Azure.DataApiBuilder.Core.AuthenticationHelpers;
8+
9+
public static class JwtRoleClaimsTransformer
10+
{
11+
public static void NormalizeRoleClaims(
12+
ClaimsPrincipal principal,
13+
string sourceRoleClaimType,
14+
string? separator)
15+
{
16+
foreach (ClaimsIdentity identity in principal.Identities)
17+
{
18+
if (!identity.IsAuthenticated)
19+
{
20+
continue;
21+
}
22+
23+
List<Claim> sourceClaims = identity.Claims
24+
.Where(c => c.Type.Equals(sourceRoleClaimType, StringComparison.Ordinal))
25+
.ToList();
26+
27+
if (sourceClaims.Count == 0)
28+
{
29+
continue;
30+
}
31+
32+
HashSet<string> normalizedValues = new(StringComparer.OrdinalIgnoreCase);
33+
34+
foreach (Claim claim in sourceClaims)
35+
{
36+
foreach (string expandedValue in ExpandClaimValues(claim.Value, separator))
37+
{
38+
if (!string.IsNullOrWhiteSpace(expandedValue))
39+
{
40+
normalizedValues.Add(expandedValue.Trim());
41+
}
42+
}
43+
}
44+
45+
foreach (string normalizedValue in normalizedValues)
46+
{
47+
bool exactClaimAlreadyExists = identity.Claims.Any(c =>
48+
c.Type.Equals(sourceRoleClaimType, StringComparison.Ordinal) &&
49+
c.Value.Equals(normalizedValue, StringComparison.OrdinalIgnoreCase));
50+
51+
if (!exactClaimAlreadyExists)
52+
{
53+
identity.AddClaim(new Claim(sourceRoleClaimType, normalizedValue, ClaimValueTypes.String));
54+
}
55+
}
56+
}
57+
}
58+
59+
private static IEnumerable<string> ExpandClaimValues(string rawValue, string? separator)
60+
{
61+
if (string.IsNullOrWhiteSpace(rawValue))
62+
{
63+
yield break;
64+
}
65+
66+
string trimmed = rawValue.Trim();
67+
68+
// 1. JSON array support
69+
if (trimmed.StartsWith('[') && trimmed.EndsWith(']'))
70+
{
71+
List<string>? values;
72+
try
73+
{
74+
values = JsonSerializer.Deserialize<List<string>>(trimmed);
75+
}
76+
catch (JsonException)
77+
{
78+
values = null;
79+
}
80+
81+
if (values is not null)
82+
{
83+
foreach (string value in values)
84+
{
85+
if (!string.IsNullOrWhiteSpace(value))
86+
{
87+
yield return value.Trim();
88+
}
89+
}
90+
91+
yield break;
92+
}
93+
}
94+
95+
// 2. Configurable separated string support
96+
if (!string.IsNullOrEmpty(separator))
97+
{
98+
string[] splitValues;
99+
100+
if (separator.Length == 1)
101+
{
102+
splitValues = trimmed.Split(
103+
separator[0],
104+
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
105+
}
106+
else
107+
{
108+
splitValues = trimmed.Split(
109+
new[] { separator },
110+
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
111+
}
112+
113+
foreach (string value in splitValues)
114+
{
115+
if (!string.IsNullOrWhiteSpace(value))
116+
{
117+
yield return value;
118+
}
119+
}
120+
121+
yield break;
122+
}
123+
124+
// 3. Single scalar fallback
125+
yield return trimmed;
126+
}
127+
}

src/Core/Authorization/AuthorizationResolver.cs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -718,12 +718,16 @@ public static Dictionary<string, List<Claim>> GetAllAuthenticatedUserClaims(Http
718718
continue;
719719
}
720720

721+
string resolvedRoleClaimType = string.IsNullOrWhiteSpace(identity.RoleClaimType)
722+
? AuthenticationOptions.ROLE_CLAIM_TYPE
723+
: identity.RoleClaimType;
724+
721725
// DAB will only resolve one 'roles' claim whose value matches the x-ms-api-role header value
722726
// because DAB executes requests in the context of a single role. The `roles` claim
723727
// resolved here can be forwarded to MSSQL's set-session-context. Modifying this behavior
724728
// is a breaking change.
725729
if (!resolvedClaims.ContainsKey(AuthenticationOptions.ROLE_CLAIM_TYPE) &&
726-
identity.HasClaim(type: AuthenticationOptions.ROLE_CLAIM_TYPE, value: clientRoleHeader))
730+
identity.HasClaim(type: resolvedRoleClaimType, value: clientRoleHeader))
727731
{
728732
List<Claim> roleClaim = new()
729733
{
@@ -737,8 +741,9 @@ public static Dictionary<string, List<Claim>> GetAllAuthenticatedUserClaims(Http
737741
// into a list and storing that in resolvedClaims using the claimType as the key.
738742
foreach (Claim claim in identity.Claims)
739743
{
740-
// 'roles' claim has already been processed. But we preserve the original 'roles' claim.
741-
if (claim.Type.Equals(AuthenticationOptions.ROLE_CLAIM_TYPE))
744+
// The source JWT role claim has already been processed.
745+
// Preserve the original source claim under original_roles.
746+
if (claim.Type.Equals(resolvedRoleClaimType))
742747
{
743748
if (!resolvedClaims.TryAdd(AuthenticationOptions.ORIGINAL_ROLE_CLAIM_TYPE, new List<Claim>() { claim }))
744749
{

src/Core/Resolvers/OboSqlTokenProvider.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,9 +222,15 @@ private static string ComputeAuthorizationContextHash(ClaimsPrincipal principal)
222222
{
223223
List<string> values = [];
224224

225+
HashSet<string> roleClaimTypes = principal.Identities
226+
.Select(identity => string.IsNullOrWhiteSpace(identity.RoleClaimType)
227+
? AuthenticationOptions.ROLE_CLAIM_TYPE
228+
: identity.RoleClaimType)
229+
.ToHashSet(StringComparer.OrdinalIgnoreCase);
230+
225231
foreach (Claim claim in principal.Claims)
226232
{
227-
if (claim.Type.Equals(AuthenticationOptions.ROLE_CLAIM_TYPE, StringComparison.OrdinalIgnoreCase) ||
233+
if (roleClaimTypes.Contains(claim.Type) ||
228234
claim.Type.Equals("scp", StringComparison.OrdinalIgnoreCase))
229235
{
230236
string[] parts = claim.Value.Split(

0 commit comments

Comments
 (0)