From 549eabd193ea767b7625ace79be907f553b932a8 Mon Sep 17 00:00:00 2001 From: Michael Wagner Date: Wed, 17 Jun 2026 23:41:25 +0200 Subject: [PATCH 1/3] * 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 --- schemas/dab.draft.schema.json | 12 ++ src/Config/ObjectModel/DataSource.cs | 11 + src/Config/ObjectModel/JwtOptions.cs | 32 ++- ...lientRoleHeaderAuthenticationMiddleware.cs | 5 +- .../ConfigureJwtBearerOptions.cs | 42 +++- .../JwtHttpClientFactory.cs | 23 +++ .../JwtRoleClaimsTransformer.cs | 127 ++++++++++++ .../Authorization/AuthorizationResolver.cs | 11 +- src/Core/Resolvers/OboSqlTokenProvider.cs | 8 +- src/Core/Resolvers/PostgreSqlExecutor.cs | 191 +++++++++++++++--- src/Core/Resolvers/PostgresQueryBuilder.cs | 2 +- .../Helpers/WebHostBuilderHelper.cs | 7 +- src/Service/Startup.cs | 52 ++++- 13 files changed, 471 insertions(+), 52 deletions(-) create mode 100644 src/Core/AuthenticationHelpers/JwtHttpClientFactory.cs create mode 100644 src/Core/AuthenticationHelpers/JwtRoleClaimsTransformer.cs diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index 6aa8f02641..c6c8e5ce33 100644 --- a/schemas/dab.draft.schema.json +++ b/schemas/dab.draft.schema.json @@ -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." + }, + "jwksUri": { + "type": "string", + "description": "Optional JWKS endpoint URI. If omitted, DAB derives it from the issuer as '{issuer}/.well-known/jwks.json'." } }, "required": [ "audience", "issuer" ] diff --git a/src/Config/ObjectModel/DataSource.cs b/src/Config/ObjectModel/DataSource.cs index e04acdfa37..da749c72f8 100644 --- a/src/Config/ObjectModel/DataSource.cs +++ b/src/Config/ObjectModel/DataSource.cs @@ -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"); } @@ -126,6 +132,11 @@ public record CosmosDbNoSQLDataSourceOptions(string? Database, string? Container /// public record MsSqlOptions(bool SetSessionContext = true) : IDataSourceOptions; +/// +/// Options for PostgreSql database. +/// +public record PostgreSqlOptions(bool SetSessionContext = true) : IDataSourceOptions; + /// /// Options for user-delegated authentication (OBO) for a data source. /// diff --git a/src/Config/ObjectModel/JwtOptions.cs b/src/Config/ObjectModel/JwtOptions.cs index 4529ef6a7a..b48e18ed6e 100644 --- a/src/Config/ObjectModel/JwtOptions.cs +++ b/src/Config/ObjectModel/JwtOptions.cs @@ -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; +} diff --git a/src/Core/AuthenticationHelpers/ClientRoleHeaderAuthenticationMiddleware.cs b/src/Core/AuthenticationHelpers/ClientRoleHeaderAuthenticationMiddleware.cs index fa7fdc9a25..6c6fed493f 100644 --- a/src/Core/AuthenticationHelpers/ClientRoleHeaderAuthenticationMiddleware.cs +++ b/src/Core/AuthenticationHelpers/ClientRoleHeaderAuthenticationMiddleware.cs @@ -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; } @@ -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; } diff --git a/src/Core/AuthenticationHelpers/ConfigureJwtBearerOptions.cs b/src/Core/AuthenticationHelpers/ConfigureJwtBearerOptions.cs index b8be86195c..aaa22880b5 100644 --- a/src/Core/AuthenticationHelpers/ConfigureJwtBearerOptions.cs +++ b/src/Core/AuthenticationHelpers/ConfigureJwtBearerOptions.cs @@ -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; @@ -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")) diff --git a/src/Core/AuthenticationHelpers/JwtHttpClientFactory.cs b/src/Core/AuthenticationHelpers/JwtHttpClientFactory.cs new file mode 100644 index 0000000000..8518746f26 --- /dev/null +++ b/src/Core/AuthenticationHelpers/JwtHttpClientFactory.cs @@ -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); + } +} diff --git a/src/Core/AuthenticationHelpers/JwtRoleClaimsTransformer.cs b/src/Core/AuthenticationHelpers/JwtRoleClaimsTransformer.cs new file mode 100644 index 0000000000..0502403072 --- /dev/null +++ b/src/Core/AuthenticationHelpers/JwtRoleClaimsTransformer.cs @@ -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 sourceClaims = identity.Claims + .Where(c => c.Type.Equals(sourceRoleClaimType, StringComparison.Ordinal)) + .ToList(); + + if (sourceClaims.Count == 0) + { + continue; + } + + HashSet 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 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? values; + try + { + values = JsonSerializer.Deserialize>(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; + } +} diff --git a/src/Core/Authorization/AuthorizationResolver.cs b/src/Core/Authorization/AuthorizationResolver.cs index 205dc3d646..6c6591105c 100644 --- a/src/Core/Authorization/AuthorizationResolver.cs +++ b/src/Core/Authorization/AuthorizationResolver.cs @@ -718,12 +718,16 @@ public static Dictionary> 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 roleClaim = new() { @@ -737,8 +741,9 @@ public static Dictionary> 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 })) { diff --git a/src/Core/Resolvers/OboSqlTokenProvider.cs b/src/Core/Resolvers/OboSqlTokenProvider.cs index f3528261dd..9d1774ace8 100644 --- a/src/Core/Resolvers/OboSqlTokenProvider.cs +++ b/src/Core/Resolvers/OboSqlTokenProvider.cs @@ -222,9 +222,15 @@ private static string ComputeAuthorizationContextHash(ClaimsPrincipal principal) { List values = []; + HashSet 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( diff --git a/src/Core/Resolvers/PostgreSqlExecutor.cs b/src/Core/Resolvers/PostgreSqlExecutor.cs index 70fa0f1079..608d40b463 100644 --- a/src/Core/Resolvers/PostgreSqlExecutor.cs +++ b/src/Core/Resolvers/PostgreSqlExecutor.cs @@ -2,9 +2,11 @@ // Licensed under the MIT License. using System.Data.Common; +using System.Text; using Azure.Core; using Azure.DataApiBuilder.Config; using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.Authorization; using Azure.DataApiBuilder.Core.Configurations; using Azure.DataApiBuilder.Core.Models; using Azure.Identity; @@ -33,6 +35,11 @@ public class PostgreSqlQueryExecutor : QueryExecutor /// private Dictionary _accessTokensFromConfiguration; + /// + /// DatasourceName to boolean value indicating if session context should be set for db. + /// + private Dictionary _dataSourceToSessionContextUsage; + public DefaultAzureCredential AzureCredential { get; set; } = new(); // CodeQL [SM05137]: DefaultAzureCredential will use Managed Identity if available or fallback to default. /// @@ -61,13 +68,15 @@ public PostgreSqlQueryExecutor( ILogger logger, IHttpContextAccessor httpContextAccessor, HotReloadEventHandler? handler = null) - : base(dbExceptionParser, + : base( + dbExceptionParser, logger, runtimeConfigProvider, httpContextAccessor, handler) { _dataSourceAccessTokenUsage = new Dictionary(); + _dataSourceToSessionContextUsage = new Dictionary(); _accessTokensFromConfiguration = runtimeConfigProvider.ManagedIdentityAccessToken; _runtimeConfigProvider = runtimeConfigProvider; ConfigurePostgreSqlQueryExecutor(); @@ -78,7 +87,10 @@ public PostgreSqlQueryExecutor( /// private void ConfigurePostgreSqlQueryExecutor() { - IEnumerable> postgresqldbs = _runtimeConfigProvider.GetConfig().GetDataSourceNamesToDataSourcesIterator().Where(x => x.Value.DatabaseType == DatabaseType.PostgreSQL); + IEnumerable> postgresqldbs = + _runtimeConfigProvider.GetConfig() + .GetDataSourceNamesToDataSourcesIterator() + .Where(x => x.Value.DatabaseType == DatabaseType.PostgreSQL); foreach ((string dataSourceName, DataSource dataSource) in postgresqldbs) { @@ -90,7 +102,12 @@ private void ConfigurePostgreSqlQueryExecutor() } ConnectionStringBuilders.TryAdd(dataSourceName, builder); - MsSqlOptions? msSqlOptions = dataSource.GetTypedOptions(); + + // Transitional reuse of MsSqlOptions because it currently only carries SetSessionContext. + MsSqlOptions? sessionOptions = dataSource.GetTypedOptions(); + _dataSourceToSessionContextUsage[dataSourceName] = + sessionOptions is not null && sessionOptions.SetSessionContext; + _dataSourceAccessTokenUsage[dataSourceName] = ShouldManagedIdentityAccessBeAttempted(builder); } } @@ -104,7 +121,6 @@ private void ConfigurePostgreSqlQueryExecutor() /// Name of datasource for which to set access token. Default dbName taken from config if null public override async Task SetManagedIdentityAccessTokenIfAnyAsync(DbConnection conn, string dataSourceName) { - // using default datasource name for first db - maintaining backward compatibility for single db scenario. if (string.IsNullOrEmpty(dataSourceName)) { dataSourceName = ConfigProvider.GetConfig().DefaultDataSourceName; @@ -112,19 +128,15 @@ public override async Task SetManagedIdentityAccessTokenIfAnyAsync(DbConnection _dataSourceAccessTokenUsage.TryGetValue(dataSourceName, out bool setAccessToken); - // Only attempt to get the access token if the connection string is in the appropriate format if (setAccessToken) { NpgsqlConnection sqlConn = (NpgsqlConnection)conn; - // If the configuration controller provided a managed identity access token use that, - // else use the default saved access token if still valid. - // Get a new token only if the saved token is null or expired. _accessTokensFromConfiguration.TryGetValue(dataSourceName, out string? accessTokenFromController); string? accessToken = accessTokenFromController ?? - (IsDefaultAccessTokenValid() ? - ((AccessToken)_defaultAccessToken!).Token : - await GetAccessTokenAsync(dataSourceName)); + (IsDefaultAccessTokenValid() + ? _defaultAccessToken!.Value.Token + : await GetAccessTokenAsync(dataSourceName)); if (accessToken is not null) { @@ -137,6 +149,14 @@ public override async Task SetManagedIdentityAccessTokenIfAnyAsync(DbConnection } } + /// + /// Sync counterpart for managed identity handling. + /// + public override void SetManagedIdentityAccessTokenIfAny(DbConnection conn, string dataSourceName = "") + { + SetManagedIdentityAccessTokenIfAnyAsync(conn, dataSourceName).GetAwaiter().GetResult(); + } + /// /// Determines if managed identity access should be attempted or not. /// It should only be attempted if the password is not provided @@ -149,20 +169,15 @@ private static bool ShouldManagedIdentityAccessBeAttempted(NpgsqlConnectionStrin /// /// Determines if the saved default azure credential's access token is valid and not expired. /// - /// True if valid, false otherwise. private bool IsDefaultAccessTokenValid() { return _defaultAccessToken is not null && - ((AccessToken)_defaultAccessToken).ExpiresOn.CompareTo(DateTimeOffset.Now) > 0; + _defaultAccessToken.Value.ExpiresOn.CompareTo(DateTimeOffset.Now) > 0; } /// /// Tries to get an access token using DefaultAzureCredentials. - /// Catches any CredentialUnavailableException and logs only a warning - /// since this is best effort. /// - /// The string representation of the access token if found, - /// null otherwise. private async Task GetAccessTokenAsync(string dataSourceName) { bool firstAttemptAtDefaultAccessToken = _defaultAccessToken is null; @@ -173,31 +188,20 @@ private bool IsDefaultAccessTokenValid() await AzureCredential.GetTokenAsync( new TokenRequestContext(new[] { DATABASE_SCOPE })); } - // because there can be scenarios where password is not specified but - // default managed identity is not the intended method of authentication - // so a bunch of different exceptions could occur in that scenario catch (Exception ex) { string messagePrefix = "{correlationId} No password detected in the connection string. Attempt to retrieve a managed identity access token using DefaultAzureCredential failed due to:\n{errorMessage}"; - string messageSuffix = (firstAttemptAtDefaultAccessToken ? $"If authentication with DefaultAzureCrendential is not intended, this warning can be safely ignored." : string.Empty); + string messageSuffix = firstAttemptAtDefaultAccessToken + ? "If authentication with DefaultAzureCrendential is not intended, this warning can be safely ignored." + : string.Empty; string message = messagePrefix + messageSuffix; + QueryExecutorLogger.LogWarning( exception: ex, message: message, HttpContextExtensions.GetLoggerCorrelationId(HttpContextAccessor.HttpContext), ex.Message); - // the config doesn't contain an identity token - // and a default identity token cannot be obtained - // so the application should not attempt to set the token - // for future conntions - // note though that if a default access token has been previously - // obtained successfully (firstAttemptAtDefaultAccessToken == false) - // this might be a transitory failure don't disable attempts to set - // the token - // - // disabling the attempts is useful in scenarios where the user - // has a valid connection string without a password in it if (firstAttemptAtDefaultAccessToken) { _dataSourceAccessTokenUsage[dataSourceName] = false; @@ -206,5 +210,128 @@ await AzureCredential.GetTokenAsync( return _defaultAccessToken?.Token; } + + /// + /// No query prefixing for PostgreSQL. Session state is set via a dedicated command + /// on the same open connection inside PrepareDbCommand(...). + /// + public override string GetSessionParamsQuery( + HttpContext? httpContext, + IDictionary parameters, + string dataSourceName) + { + return string.Empty; + } + + /// + /// PostgreSQL override that first sets session settings on the already-open connection + /// using a dedicated command, then returns the actual data command. + /// + public override DbCommand PrepareDbCommand( + NpgsqlConnection conn, + string sqltext, + IDictionary parameters, + HttpContext? httpContext, + string dataSourceName) + { + SetSessionContext(conn, httpContext, dataSourceName); + + NpgsqlCommand cmd = conn.CreateCommand(); + cmd.CommandType = System.Data.CommandType.Text; + cmd.CommandText = sqltext; + + if (parameters is not null) + { + foreach (KeyValuePair parameterEntry in parameters) + { + DbParameter parameter = cmd.CreateParameter(); + parameter.ParameterName = parameterEntry.Key; + parameter.Value = parameterEntry.Value.Value ?? DBNull.Value; + PopulateDbTypeForParameter(parameterEntry, parameter); + cmd.Parameters.Add(parameter); + } + } + + return cmd; + } + + /// + /// Sets processed user claims into PostgreSQL custom settings on the same open connection. + /// This command's resultsets are consumed and ignored before the actual query command is created. + /// + private void SetSessionContext( + NpgsqlConnection conn, + HttpContext? httpContext, + string dataSourceName) + { + if (string.IsNullOrEmpty(dataSourceName)) + { + dataSourceName = ConfigProvider.GetConfig().DefaultDataSourceName; + } + + if (httpContext is null || + !_dataSourceToSessionContextUsage.TryGetValue(dataSourceName, out bool enabled) || + !enabled) + { + return; + } + + Dictionary sessionParams = AuthorizationResolver.GetProcessedUserClaims(httpContext); + if (sessionParams.Count == 0) + { + return; + } + + using NpgsqlCommand cmd = conn.CreateCommand(); + cmd.CommandType = System.Data.CommandType.Text; + + StringBuilder sql = new(); + int i = 0; + + foreach ((string claimType, string claimValue) in sessionParams) + { + string parameterName = $"p{i++}"; + string sessionSettingKey = ToPostgresSessionSettingKey(claimType); + + sql.Append($"SELECT set_config('{sessionSettingKey}', @{parameterName}, false);"); + cmd.Parameters.AddWithValue(parameterName, claimValue); + } + + cmd.CommandText = sql.ToString(); + + using DbDataReader reader = cmd.ExecuteReader(); + + do + { + while (reader.Read()) + { + // ignore set_config result rows + } + } + while (reader.NextResult()); + } + + /// + /// Normalize a claim name into a valid PostgreSQL custom setting key. + /// Example: "tenant" -> "dab.claims.tenant" + /// + private static string ToPostgresSessionSettingKey(string claimType) + { + StringBuilder keyBuilder = new("dab.claims."); + + foreach (char c in claimType) + { + if (char.IsLetterOrDigit(c) || c == '_' || c == '.') + { + keyBuilder.Append(char.ToLowerInvariant(c)); + } + else + { + keyBuilder.Append('_'); + } + } + + return keyBuilder.ToString(); + } } } diff --git a/src/Core/Resolvers/PostgresQueryBuilder.cs b/src/Core/Resolvers/PostgresQueryBuilder.cs index 244b1a45b8..4815772049 100644 --- a/src/Core/Resolvers/PostgresQueryBuilder.cs +++ b/src/Core/Resolvers/PostgresQueryBuilder.cs @@ -50,7 +50,7 @@ public string Build(SqlQueryStructure structure) StringBuilder result = new(); if (structure.IsListQuery) { - result.Append($"SELECT COALESCE(jsonb_agg(to_jsonb({subqueryName})), '[]') "); + result.Append($"SELECT COALESCE(jsonb_agg(to_jsonb({subqueryName})), '[]'::jsonb) "); } else { diff --git a/src/Service.Tests/Authentication/Helpers/WebHostBuilderHelper.cs b/src/Service.Tests/Authentication/Helpers/WebHostBuilderHelper.cs index 9da39c14c6..d02d7b991a 100644 --- a/src/Service.Tests/Authentication/Helpers/WebHostBuilderHelper.cs +++ b/src/Service.Tests/Authentication/Helpers/WebHostBuilderHelper.cs @@ -138,12 +138,15 @@ public static async Task CreateWebHostCustomIssuer(SecurityKey key) AuthenticationOptions authOptions = new() { Provider = "AzureAD", - Jwt = new(Audience: AUDIENCE, Issuer: LOCAL_ISSUER) + Jwt = new() { Audience = AUDIENCE, Issuer = LOCAL_ISSUER } }; RuntimeConfig runtimeConfig = RuntimeConfigAuthHelper.CreateTestConfigWithAuthNProvider(authOptions); fileSystemRuntimeConfigLoader.RuntimeConfig = runtimeConfig; RuntimeConfigProvider runtimeConfigProvider = new(fileSystemRuntimeConfigLoader); + string resolvedRoleClaimType = string.IsNullOrWhiteSpace(authOptions.Jwt.RolesPath) + ? AuthenticationOptions.ROLE_CLAIM_TYPE + : authOptions.Jwt.RolesPath; return await new HostBuilder() .ConfigureWebHost(webBuilder => @@ -175,7 +178,7 @@ public static async Task CreateWebHostCustomIssuer(SecurityKey key) ValidateLifetime = true, // 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 - RoleClaimType = AuthenticationOptions.ROLE_CLAIM_TYPE + RoleClaimType = resolvedRoleClaimType }; }); services.AddAuthorization(); diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 68364b530e..a6059182f4 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -62,6 +62,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Identity.Client; +using Microsoft.IdentityModel.Tokens; using NodaTime; using OpenTelemetry.Exporter; using OpenTelemetry.Logs; @@ -404,11 +405,9 @@ public void ConfigureServices(IServiceCollection services) bool allowSelfSigned = Environment.GetEnvironmentVariable("USE_SELF_SIGNED_CERT")?.Equals("true", StringComparison.OrdinalIgnoreCase) == true; HttpClientHandler handler = new(); - if (allowSelfSigned) { - handler.ServerCertificateCustomValidationCallback = - HttpClientHandler.DangerousAcceptAnyServerCertificateValidator; + handler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator; } return handler; @@ -1126,6 +1125,12 @@ private void ConfigureAuthentication(IServiceCollection services, RuntimeConfigP runtimeConfig.Runtime?.Host?.Authentication is not null) { AuthenticationOptions authOptions = runtimeConfig.Runtime.Host.Authentication; + + if (authOptions is null || authOptions.Jwt is null) + { + return; + } + HostMode mode = runtimeConfig.Runtime.Host.Mode; if (authOptions.IsJwtConfiguredIdentityProvider()) { @@ -1135,11 +1140,46 @@ private void ConfigureAuthentication(IServiceCollection services, RuntimeConfigP options.MapInboundClaims = false; options.Audience = authOptions.Jwt!.Audience; options.Authority = authOptions.Jwt!.Issuer; + + string? jwksUri = authOptions.Jwt.ResolvedJwksUrl; + if (string.IsNullOrWhiteSpace(jwksUri)) + { + throw new DataApiBuilderException( + message: "JWT configuration requires either issuer or jwksUri.", + statusCode: System.Net.HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError); + } + + 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() { - // 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 - 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 = authOptions.Jwt!.ResolvedRoleClaimType + }; + + options.Events = new JwtBearerEvents + { + OnTokenValidated = context => + { + JwtRoleClaimsTransformer.NormalizeRoleClaims( + principal: context.Principal!, + sourceRoleClaimType: authOptions.Jwt!.ResolvedRoleClaimType, + separator: authOptions.Jwt.ResolvedRolesSeparator); + + return Task.CompletedTask; + } }; }); } From 5a7cb3b6d510b084c17598361aaa02c9b79df349 Mon Sep 17 00:00:00 2001 From: Michael Wagner Date: Thu, 25 Jun 2026 23:09:31 +0200 Subject: [PATCH 2/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- schemas/dab.draft.schema.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index c6c8e5ce33..c534480df5 100644 --- a/schemas/dab.draft.schema.json +++ b/schemas/dab.draft.schema.json @@ -466,9 +466,9 @@ "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." }, - "jwksUri": { + "jwksUrl": { "type": "string", - "description": "Optional JWKS endpoint URI. If omitted, DAB derives it from the issuer as '{issuer}/.well-known/jwks.json'." + "description": "Optional JWKS endpoint URL. If omitted, DAB derives it from the issuer as '{issuer}/.well-known/jwks.json'." } }, "required": [ "audience", "issuer" ] From de480934fbc8da1e6eed25ce8cc490e54102c9c5 Mon Sep 17 00:00:00 2001 From: Michael Wagner Date: Thu, 25 Jun 2026 23:48:01 +0200 Subject: [PATCH 3/3] Changes due to inputs --- src/Core/Resolvers/PostgreSqlExecutor.cs | 9 ++--- .../AuthenticationConfigValidatorUnitTests.cs | 40 ++++++++++++------- src/Service/Startup.cs | 10 ++--- 3 files changed, 34 insertions(+), 25 deletions(-) diff --git a/src/Core/Resolvers/PostgreSqlExecutor.cs b/src/Core/Resolvers/PostgreSqlExecutor.cs index 608d40b463..9b7c81fedf 100644 --- a/src/Core/Resolvers/PostgreSqlExecutor.cs +++ b/src/Core/Resolvers/PostgreSqlExecutor.cs @@ -103,8 +103,7 @@ private void ConfigurePostgreSqlQueryExecutor() ConnectionStringBuilders.TryAdd(dataSourceName, builder); - // Transitional reuse of MsSqlOptions because it currently only carries SetSessionContext. - MsSqlOptions? sessionOptions = dataSource.GetTypedOptions(); + PostgreSqlOptions? sessionOptions = dataSource.GetTypedOptions(); _dataSourceToSessionContextUsage[dataSourceName] = sessionOptions is not null && sessionOptions.SetSessionContext; @@ -260,9 +259,9 @@ public override DbCommand PrepareDbCommand( /// This command's resultsets are consumed and ignored before the actual query command is created. /// private void SetSessionContext( - NpgsqlConnection conn, - HttpContext? httpContext, - string dataSourceName) + NpgsqlConnection conn, + HttpContext? httpContext, + string dataSourceName) { if (string.IsNullOrEmpty(dataSourceName)) { diff --git a/src/Service.Tests/Configuration/AuthenticationConfigValidatorUnitTests.cs b/src/Service.Tests/Configuration/AuthenticationConfigValidatorUnitTests.cs index 9a2a9e57dd..ef1713f640 100644 --- a/src/Service.Tests/Configuration/AuthenticationConfigValidatorUnitTests.cs +++ b/src/Service.Tests/Configuration/AuthenticationConfigValidatorUnitTests.cs @@ -64,9 +64,11 @@ public void ValidateEasyAuthConfig() [DataRow("EntraID")] public void ValidateJwtConfigParamsSet(string authenticationProvider) { - JwtOptions jwt = new( - Audience: "12345", - Issuer: "https://login.microsoftonline.com/common"); + JwtOptions jwt = new() + { + Audience = "12345", + Issuer = "https://login.microsoftonline.com/common" + }; AuthenticationOptions authNConfig = new( Provider: authenticationProvider, Jwt: jwt); @@ -115,9 +117,11 @@ public void ValidateAuthNSectionNotNecessary() [DataRow("EntraID")] public void ValidateFailureWithIncompleteJwtConfig(string authenticationProvider) { - JwtOptions jwt = new( - Audience: "12345", - Issuer: string.Empty); + JwtOptions jwt = new() + { + Audience = "12345", + Issuer = string.Empty + }; AuthenticationOptions authNConfig = new( Provider: authenticationProvider, Jwt: jwt); @@ -136,9 +140,11 @@ public void ValidateFailureWithIncompleteJwtConfig(string authenticationProvider _runtimeConfigValidator.ValidateConfigProperties(); }); - jwt = new( - Audience: string.Empty, - Issuer: DEFAULT_ISSUER); + jwt = new() + { + Audience = string.Empty, + Issuer = DEFAULT_ISSUER + }; authNConfig = new( Provider: authenticationProvider, Jwt: jwt); @@ -153,9 +159,11 @@ public void ValidateFailureWithIncompleteJwtConfig(string authenticationProvider [TestMethod("AuthN validation fails when either Issuer or Audience are provided for EasyAuth")] public void ValidateFailureWithUnneededEasyAuthConfig() { - JwtOptions jwt = new( - Audience: "12345", - Issuer: string.Empty); + JwtOptions jwt = new() + { + Audience = "12345", + Issuer = string.Empty + }; AuthenticationOptions authNConfig = new(Provider: "EasyAuth", Jwt: jwt); RuntimeConfig config = CreateRuntimeConfigWithOptionalAuthN(authNConfig); @@ -171,9 +179,11 @@ public void ValidateFailureWithUnneededEasyAuthConfig() _runtimeConfigValidator.ValidateConfigProperties(); }); - jwt = new( - Audience: string.Empty, - Issuer: DEFAULT_ISSUER); + jwt = new() + { + Audience = string.Empty, + Issuer = DEFAULT_ISSUER + }; authNConfig = new(Provider: "EasyAuth", Jwt: jwt); config = CreateRuntimeConfigWithOptionalAuthN(authNConfig); diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index a6059182f4..7a3c7d9406 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -1126,7 +1126,7 @@ private void ConfigureAuthentication(IServiceCollection services, RuntimeConfigP { AuthenticationOptions authOptions = runtimeConfig.Runtime.Host.Authentication; - if (authOptions is null || authOptions.Jwt is null) + if (authOptions.IsJwtConfiguredIdentityProvider() && authOptions.Jwt is null) { return; } @@ -1141,11 +1141,11 @@ private void ConfigureAuthentication(IServiceCollection services, RuntimeConfigP options.Audience = authOptions.Jwt!.Audience; options.Authority = authOptions.Jwt!.Issuer; - string? jwksUri = authOptions.Jwt.ResolvedJwksUrl; - if (string.IsNullOrWhiteSpace(jwksUri)) + string? jwksUrl = authOptions.Jwt.ResolvedJwksUrl; + if (string.IsNullOrWhiteSpace(jwksUrl)) { throw new DataApiBuilderException( - message: "JWT configuration requires either issuer or jwksUri.", + message: "JWT configuration requires either issuer or jwksUrl.", statusCode: System.Net.HttpStatusCode.ServiceUnavailable, subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError); } @@ -1153,7 +1153,7 @@ private void ConfigureAuthentication(IServiceCollection services, RuntimeConfigP JsonWebKeySet jwks; using (HttpClient client = JwtHttpClientFactory.Create()) { - string jwksJson = client.GetStringAsync(jwksUri).GetAwaiter().GetResult(); + string jwksJson = client.GetStringAsync(jwksUrl).GetAwaiter().GetResult(); jwks = new JsonWebKeySet(jwksJson); }