diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json
index 6aa8f02641..c534480df5 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."
+ },
+ "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" ]
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..9b7c81fedf 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,11 @@ private void ConfigurePostgreSqlQueryExecutor()
}
ConnectionStringBuilders.TryAdd(dataSourceName, builder);
- MsSqlOptions? msSqlOptions = dataSource.GetTypedOptions();
+
+ PostgreSqlOptions? sessionOptions = dataSource.GetTypedOptions();
+ _dataSourceToSessionContextUsage[dataSourceName] =
+ sessionOptions is not null && sessionOptions.SetSessionContext;
+
_dataSourceAccessTokenUsage[dataSourceName] = ShouldManagedIdentityAccessBeAttempted(builder);
}
}
@@ -104,7 +120,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 +127,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 +148,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 +168,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 +187,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 +209,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.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 68364b530e..7a3c7d9406 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.IsJwtConfiguredIdentityProvider() && 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? jwksUrl = authOptions.Jwt.ResolvedJwksUrl;
+ if (string.IsNullOrWhiteSpace(jwksUrl))
+ {
+ throw new DataApiBuilderException(
+ message: "JWT configuration requires either issuer or jwksUrl.",
+ statusCode: System.Net.HttpStatusCode.ServiceUnavailable,
+ subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError);
+ }
+
+ JsonWebKeySet jwks;
+ using (HttpClient client = JwtHttpClientFactory.Create())
+ {
+ string jwksJson = client.GetStringAsync(jwksUrl).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;
+ }
};
});
}