diff --git a/src/Core/Resolvers/MsSqlQueryExecutor.cs b/src/Core/Resolvers/MsSqlQueryExecutor.cs index a6684144c1..368e5d6b00 100644 --- a/src/Core/Resolvers/MsSqlQueryExecutor.cs +++ b/src/Core/Resolvers/MsSqlQueryExecutor.cs @@ -12,6 +12,7 @@ using Azure.DataApiBuilder.Core.Authorization; using Azure.DataApiBuilder.Core.Configurations; using Azure.DataApiBuilder.Core.Models; +using Azure.DataApiBuilder.Product; using Azure.DataApiBuilder.Service.Exceptions; using Azure.Identity; using Microsoft.AspNetCore.Http; @@ -69,6 +70,13 @@ public override IDictionary ConnectionStringB /// private Dictionary _dataSourceUserDelegatedAuth; + /// + /// DatasourceName to base Application Name for OBO per-user pooling. + /// Only populated for data sources with user-delegated-auth enabled. + /// Used as a prefix when constructing user-specific Application Names. + /// + private Dictionary _dataSourceBaseAppName; + /// /// Optional OBO token provider for user-delegated authentication. /// @@ -94,6 +102,7 @@ public MsSqlQueryExecutor( _dataSourceAccessTokenUsage = new Dictionary(); _dataSourceToSessionContextUsage = new Dictionary(); _dataSourceUserDelegatedAuth = new Dictionary(); + _dataSourceBaseAppName = new Dictionary(); _accessTokensFromConfiguration = runtimeConfigProvider.ManagedIdentityAccessToken; _runtimeConfigProvider = runtimeConfigProvider; _oboTokenProvider = oboTokenProvider; @@ -114,9 +123,11 @@ public override SqlConnection CreateConnection(string dataSourceName) throw new DataApiBuilderException("Query execution failed. Could not find datasource to execute query against", HttpStatusCode.BadRequest, DataApiBuilderException.SubStatusCodes.DataSourceNotFound); } + string connectionString = GetConnectionStringForCurrentUser(dataSourceName); + SqlConnection conn = new() { - ConnectionString = ConnectionStringBuilders[dataSourceName].ConnectionString, + ConnectionString = connectionString, }; // Extract info message from SQLConnection @@ -150,6 +161,136 @@ public override SqlConnection CreateConnection(string dataSourceName) return conn; } + /// + /// Gets the connection string for the current user. For OBO-enabled data sources, + /// this returns a connection string with a user-specific Application Name to isolate + /// connection pools per user identity. + /// + /// The name of the data source. + /// The connection string to use for the current request. + private string GetConnectionStringForCurrentUser(string dataSourceName) + { + string baseConnectionString = ConnectionStringBuilders[dataSourceName].ConnectionString; + + // Per-user pooling is automatic when OBO is enabled. + // _dataSourceBaseAppName is only populated for data sources with user-delegated-auth enabled. + if (!_dataSourceBaseAppName.TryGetValue(dataSourceName, out string? baseAppName)) + { + // OBO not enabled for this data source, use the standard connection string + return baseConnectionString; + } + + // Extract user pool key from current HTTP context (prefers oid, falls back to sub) + string? poolKeyHash = GetUserPoolKeyHash(dataSourceName); + if (string.IsNullOrEmpty(poolKeyHash)) + { + // For OBO-enabled data sources, we must have a user context for actual requests. + // Null poolKeyHash is only acceptable during startup/metadata phase when there's no HttpContext. + // If we have an HttpContext with a User but missing required claims, fail-safe to prevent + // potential cross-user connection pool contamination. + if (HttpContextAccessor?.HttpContext?.User?.Identity?.IsAuthenticated == true) + { + throw new DataApiBuilderException( + message: "User-delegated authentication requires 'iss' and user identifier (oid/sub) claims for connection pool isolation.", + statusCode: System.Net.HttpStatusCode.Unauthorized, + subStatusCode: DataApiBuilderException.SubStatusCodes.OboAuthenticationFailure); + } + + // No user context (startup/metadata phase), use base connection string + return baseConnectionString; + } + + // Create a user-specific connection string with per-user pool isolation. + // Format: {hash}|{user-custom-appname} where hash is placed FIRST to ensure it's never truncated. + // SQL Server limits Application Name to 128 characters. By placing the hash first, we guarantee + // per-user pool isolation even if the user's custom app name gets truncated. + // The hash is a URL-safe Base64-encoded SHA256 hash (16 bytes = ~22 chars). + const int maxApplicationNameLength = 128; + string hashPrefix = $"{poolKeyHash}|"; + int allowedBaseAppNameLength = Math.Max(0, maxApplicationNameLength - hashPrefix.Length); + string effectiveBaseAppName = baseAppName.Length > allowedBaseAppNameLength + ? baseAppName[..allowedBaseAppNameLength] + : baseAppName; + + SqlConnectionStringBuilder userBuilder = new(baseConnectionString) + { + ApplicationName = $"{hashPrefix}{effectiveBaseAppName}" + }; + + return userBuilder.ConnectionString; + } + + /// + /// Generates a pool key hash from the current user's claims for OBO per-user pooling. + /// Uses iss|(oid||sub) to ensure each unique user identity gets its own connection pool. + /// Prefers 'oid' (stable GUID) but falls back to 'sub' for guest/B2B users. + /// + /// The data source name for logging purposes. + /// A URL-safe Base64-encoded hash, or null if no user context is available. + private string? GetUserPoolKeyHash(string dataSourceName) + { + if (HttpContextAccessor?.HttpContext?.User is null) + { + QueryExecutorLogger.LogDebug( + "Cannot create per-user pool key for data source {DataSourceName}: no HTTP context or user available.", + dataSourceName); + return null; + } + + ClaimsPrincipal user = HttpContextAccessor.HttpContext.User; + + // Extract issuer claim - required for tenant isolation and connection pool security. + // The "iss" claim must be present along with a user identifier (oid/sub) for per-user pooling. + // Callers are responsible for enforcing fail-safe behavior when claims are missing. + string? iss = user.FindFirst("iss")?.Value; + + // User identifier claim resolution (in priority order): + // 1. "oid" - Short claim name for object ID, used in Entra ID v2.0 tokens + // 2. Full URI form - "http://schemas.microsoft.com/identity/claims/objectidentifier" + // Used in Entra ID v1.0 tokens and some SAML-based flows + // 3. "sub" - Subject claim, unique per user per application. Used as fallback for + // guest/B2B users where oid may not be present or stable across tenants + // 4. ClaimTypes.NameIdentifier - .NET standard claim type (maps to various underlying claims) + // Acts as a last-resort fallback for non-Entra identity providers + string? userKey = user.FindFirst("oid")?.Value + ?? user.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value + ?? user.FindFirst("sub")?.Value + ?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + + if (string.IsNullOrEmpty(iss) || string.IsNullOrEmpty(userKey)) + { + // Cannot create a pool key without both claims + QueryExecutorLogger.LogDebug( + "Cannot create per-user pool key for data source {DataSourceName}: missing {MissingClaim} claim.", + dataSourceName, + string.IsNullOrEmpty(iss) ? "iss" : "user identifier (oid/sub)"); + return null; + } + + // Create the pool key as iss|userKey and hash it to keep connection string small + string poolKey = $"{iss}|{userKey}"; + return HashPoolKey(poolKey); + } + + /// + /// Hashes the pool key using SHA256 truncated to 16 bytes for a compact, URL-safe identifier. + /// Uses SHA256 (SHA-2 family) with 128-bit truncation per Microsoft security requirements. + /// This produces a ~22 character hash (16 bytes Base64-encoded) that fits well within SQL Server's + /// 128-char Application Name limit while providing sufficient collision resistance. + /// + /// The pool key to hash (format: iss|oid or iss|sub). + /// A URL-safe Base64-encoded hash of the key (~22 characters). + private static string HashPoolKey(string key) + { + byte[] fullHash = System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes(key)); + // Truncate to 16 bytes (128 bits) per MS security requirements for SHA-2 family + return Convert.ToBase64String(fullHash, 0, 16) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + /// /// Configure during construction or a hot-reload scenario. /// @@ -177,9 +318,12 @@ private void ConfigureMsSqlQueryExecutor() { _dataSourceUserDelegatedAuth[dataSourceName] = dataSource.UserDelegatedAuth!; - // Disable connection pooling for OBO connections since each connection - // uses a user-specific token and cannot be shared across users - builder.Pooling = false; + // Per-user pooling: Store the base Application Name for hash prefixing at connection time. + // We'll prepend the user's iss|oid (or iss|sub) hash to create isolated pools per user. + // Note: ApplicationName is typically already set by RuntimeConfigLoader (e.g., "CustomerApp,dab_oss_2.0.0") + // but we use GetDataApiBuilderUserAgent() as fallback for consistency. + // We respect the user's Pooling setting from the connection string. + _dataSourceBaseAppName[dataSourceName] = builder.ApplicationName ?? ProductInfo.GetDataApiBuilderUserAgent(); } } } diff --git a/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs b/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs index 573ac4c8f4..bd1cd28b88 100644 --- a/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs +++ b/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs @@ -671,6 +671,348 @@ public void ValidateStreamingLogicForEmptyCellsAsync() Assert.AreEqual(availableSize, (int)runtimeConfig.MaxResponseSizeMB() * 1024 * 1024); } + #region Per-User Connection Pooling Tests + + /// + /// Creates MsSqlQueryExecutor with the specified configuration for per-user connection pooling tests. + /// + /// The connection string to use. + /// Whether to enable user-delegated-auth (OBO). + /// The HttpContextAccessor mock to use. + /// A tuple containing the query executor and runtime config provider. + private static (MsSqlQueryExecutor QueryExecutor, RuntimeConfigProvider Provider) CreateQueryExecutorForPoolingTest( + string connectionString, + bool enableObo, + Mock httpContextAccessor) + { + DataSource dataSource = new( + DatabaseType: DatabaseType.MSSQL, + ConnectionString: connectionString, + Options: null) + { + UserDelegatedAuth = enableObo + ? new UserDelegatedAuthOptions( + Enabled: true, + Provider: "EntraId", + DatabaseAudience: "https://database.windows.net") + : null + }; + + RuntimeConfig mockConfig = new( + Schema: "", + DataSource: dataSource, + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new(), + Host: new(null, null) + ), + Entities: new(new Dictionary())); + + MockFileSystem fileSystem = new(); + fileSystem.AddFile(FileSystemRuntimeConfigLoader.DEFAULT_CONFIG_FILE_NAME, new MockFileData(mockConfig.ToJson())); + FileSystemRuntimeConfigLoader loader = new(fileSystem); + RuntimeConfigProvider provider = new(loader); + + Mock>> queryExecutorLogger = new(); + DbExceptionParser dbExceptionParser = new MsSqlDbExceptionParser(provider); + + MsSqlQueryExecutor queryExecutor = new(provider, dbExceptionParser, queryExecutorLogger.Object, httpContextAccessor.Object); + return (queryExecutor, provider); + } + + /// + /// Creates an HttpContextAccessor mock with the specified user claims. + /// + /// The issuer claim value, or empty string for no context. + /// The oid claim value, or empty string for no context. + /// A configured HttpContextAccessor mock. + private static Mock CreateHttpContextAccessorWithClaims(string issuer, string objectId) + { + Mock httpContextAccessor = new(); + + if (string.IsNullOrEmpty(issuer) && string.IsNullOrEmpty(objectId)) + { + httpContextAccessor.Setup(x => x.HttpContext).Returns(value: null); + } + else + { + DefaultHttpContext context = new(); + System.Security.Claims.ClaimsIdentity identity = new("TestAuth"); + if (!string.IsNullOrEmpty(issuer)) + { + identity.AddClaim(new System.Security.Claims.Claim("iss", issuer)); + } + + if (!string.IsNullOrEmpty(objectId)) + { + identity.AddClaim(new System.Security.Claims.Claim("oid", objectId)); + } + + context.User = new System.Security.Claims.ClaimsPrincipal(identity); + httpContextAccessor.Setup(x => x.HttpContext).Returns(context); + } + + return httpContextAccessor; + } + + /// + /// Test that the Pooling property from the connection string is never modified by DAB, + /// regardless of whether OBO is enabled or disabled. If Pooling=true, it stays true. + /// If Pooling=false, it stays false. DAB respects the user's explicit configuration. + /// + [DataTestMethod, TestCategory(TestCategory.MSSQL)] + [DataRow(true, true, DisplayName = "OBO enabled, Pooling=true stays true")] + [DataRow(true, false, DisplayName = "OBO enabled, Pooling=false stays false")] + [DataRow(false, true, DisplayName = "OBO disabled, Pooling=true stays true")] + [DataRow(false, false, DisplayName = "OBO disabled, Pooling=false stays false")] + public void TestPoolingPropertyIsNeverModified(bool enableObo, bool poolingValue) + { + // Arrange + Mock httpContextAccessor = new(); + string connectionString = $"Server=localhost;Database=test;Pooling={poolingValue};"; + + // Act + (MsSqlQueryExecutor queryExecutor, RuntimeConfigProvider provider) = CreateQueryExecutorForPoolingTest( + connectionString: connectionString, + enableObo: enableObo, + httpContextAccessor: httpContextAccessor); + + SqlConnectionStringBuilder connBuilder = new( + queryExecutor.ConnectionStringBuilders[provider.GetConfig().DefaultDataSourceName].ConnectionString); + + // Assert - Pooling property should be unchanged from the original connection string + Assert.AreEqual(poolingValue, connBuilder.Pooling, + $"Pooling={poolingValue} should remain unchanged when OBO is {(enableObo ? "enabled" : "disabled")}"); + } + + /// + /// Test that when OBO is enabled and user claims are present, CreateConnection returns + /// a connection string with a user-specific Application Name containing the pool hash. + /// + [TestMethod, TestCategory(TestCategory.MSSQL)] + public void TestOboWithUserClaims_ConnectionStringHasUserSpecificAppName() + { + // Arrange & Act + Mock httpContextAccessor = CreateHttpContextAccessorWithClaims( + issuer: "https://login.microsoftonline.com/tenant-id/v2.0", + objectId: "user-object-id-12345"); + + (MsSqlQueryExecutor queryExecutor, RuntimeConfigProvider provider) = CreateQueryExecutorForPoolingTest( + connectionString: "Server=localhost;Database=test;Application Name=TestApp;", + enableObo: true, + httpContextAccessor: httpContextAccessor); + + SqlConnection conn = queryExecutor.CreateConnection(provider.GetConfig().DefaultDataSourceName); + SqlConnectionStringBuilder connBuilder = new(conn.ConnectionString); + + // Assert - Application Name should have hash prefix followed by the base name + // Format: {hash}|{user-custom-appname} + // Hash is 16 bytes truncated SHA256, Base64-encoded to ~22 chars (16 bytes * 4/3 = 21.3) + // Hash is placed first to ensure it's never truncated if app name exceeds 128 chars + Assert.IsTrue(connBuilder.ApplicationName.Contains("|"), + $"Application Name should contain '|' separator but was '{connBuilder.ApplicationName}'"); + Assert.IsTrue(connBuilder.ApplicationName.Contains("TestApp"), + $"Application Name should contain 'TestApp' but was '{connBuilder.ApplicationName}'"); + // Hash should be at the start (before the | separator) + // 16 bytes Base64-encoded (without padding) = ~22 characters + string hashPart = connBuilder.ApplicationName.Split('|')[0]; + Assert.IsTrue(hashPart.Length >= 20 && hashPart.Length <= 25, + $"Hash prefix should be ~22 chars (16 bytes Base64) but was {hashPart.Length} chars: '{hashPart}'"); + Assert.IsTrue(connBuilder.Pooling, "Pooling should be enabled"); + } + + /// + /// Test that when the base Application Name + hash prefix exceeds 128 characters, + /// the base app name is truncated (not the hash) to fit within SQL Server's limit. + /// This verifies the hash-first format ensures pool isolation even with long app names. + /// + [TestMethod, TestCategory(TestCategory.MSSQL)] + public void TestOboWithLongAppName_TruncatesToFitWithinLimit() + { + // Arrange - Create an Application Name that would exceed 128 chars when hash is added + // Hash prefix is ~22 chars + "|" = 23 chars, so base app name of 120 chars would exceed limit + string longAppName = new('A', 120); // 120 chars, plus 23 for hash = 143 total + + Mock httpContextAccessor = CreateHttpContextAccessorWithClaims( + issuer: "https://login.microsoftonline.com/tenant-id/v2.0", + objectId: "user-object-id-12345"); + + (MsSqlQueryExecutor queryExecutor, RuntimeConfigProvider provider) = CreateQueryExecutorForPoolingTest( + connectionString: $"Server=localhost;Database=test;Application Name={longAppName};", + enableObo: true, + httpContextAccessor: httpContextAccessor); + + // Act + SqlConnection conn = queryExecutor.CreateConnection(provider.GetConfig().DefaultDataSourceName); + SqlConnectionStringBuilder connBuilder = new(conn.ConnectionString); + + // Assert - Application Name should be truncated to 128 chars max + Assert.IsTrue(connBuilder.ApplicationName.Length <= 128, + $"Application Name should be <= 128 chars but was {connBuilder.ApplicationName.Length} chars"); + + // Hash should still be at the start and complete (not truncated) + string[] parts = connBuilder.ApplicationName.Split('|'); + Assert.AreEqual(2, parts.Length, "Application Name should have exactly one '|' separator"); + + string hashPart = parts[0]; + Assert.IsTrue(hashPart.Length >= 20 && hashPart.Length <= 25, + $"Hash prefix should be ~22 chars (16 bytes Base64) but was {hashPart.Length} chars: '{hashPart}'"); + + // The base app name should be truncated, not the hash + string truncatedAppName = parts[1]; + Assert.IsTrue(truncatedAppName.Length < longAppName.Length, + $"Base app name should be truncated from {longAppName.Length} chars but was {truncatedAppName.Length} chars"); + Assert.IsTrue(truncatedAppName.All(c => c == 'A'), + "Truncated app name should contain only the original characters (no corruption)"); + } + + /// + /// Test that different users get different pool hashes (different Application Names). + /// + [TestMethod, TestCategory(TestCategory.MSSQL)] + public void TestObo_DifferentUsersGetDifferentPoolHashes() + { + // Arrange & Act - User 1 + Mock httpContextAccessor1 = CreateHttpContextAccessorWithClaims( + issuer: "https://login.microsoftonline.com/tenant-id/v2.0", + objectId: "user1-oid-aaaa"); + + (MsSqlQueryExecutor queryExecutor1, RuntimeConfigProvider provider) = CreateQueryExecutorForPoolingTest( + connectionString: "Server=localhost;Database=test;Application Name=DAB;", + enableObo: true, + httpContextAccessor: httpContextAccessor1); + + SqlConnection conn1 = queryExecutor1.CreateConnection(provider.GetConfig().DefaultDataSourceName); + SqlConnectionStringBuilder connBuilder1 = new(conn1.ConnectionString); + + // Arrange & Act - User 2 + Mock httpContextAccessor2 = CreateHttpContextAccessorWithClaims( + issuer: "https://login.microsoftonline.com/tenant-id/v2.0", + objectId: "user2-oid-bbbb"); + + (MsSqlQueryExecutor queryExecutor2, RuntimeConfigProvider provider2) = CreateQueryExecutorForPoolingTest( + connectionString: "Server=localhost;Database=test;Application Name=DAB;", + enableObo: true, + httpContextAccessor: httpContextAccessor2); + + SqlConnection conn2 = queryExecutor2.CreateConnection(provider2.GetConfig().DefaultDataSourceName); + SqlConnectionStringBuilder connBuilder2 = new(conn2.ConnectionString); + + // Assert - both should have hash prefix and different hashes + // Format: {hash}|{appname} - hash is first to prevent truncation + Assert.IsTrue(connBuilder1.ApplicationName.Contains("|"), "User 1 should have hash prefix"); + Assert.IsTrue(connBuilder2.ApplicationName.Contains("|"), "User 2 should have hash prefix"); + Assert.AreNotEqual(connBuilder1.ApplicationName, connBuilder2.ApplicationName, + "Different users should have different Application Names (different pool hashes)"); + } + + /// + /// Test that when no user context is present (e.g., startup), connection string uses base Application Name. + /// + [TestMethod, TestCategory(TestCategory.MSSQL)] + public void TestOboNoUserContext_UsesBaseConnectionString() + { + // Arrange & Act + Mock httpContextAccessor = CreateHttpContextAccessorWithClaims(issuer: string.Empty, objectId: string.Empty); + + (MsSqlQueryExecutor queryExecutor, RuntimeConfigProvider provider) = CreateQueryExecutorForPoolingTest( + connectionString: "Server=localhost;Database=test;Application Name=BaseApp;", + enableObo: true, + httpContextAccessor: httpContextAccessor); + + SqlConnection conn = queryExecutor.CreateConnection(provider.GetConfig().DefaultDataSourceName); + SqlConnectionStringBuilder connBuilder = new(conn.ConnectionString); + + // Assert - without user context, should use base Application Name (no hash prefix) + // Note: The actual format includes version suffix, e.g., "BaseApp,dab_oss_2.0.0" + Assert.IsTrue(connBuilder.ApplicationName.StartsWith("BaseApp"), + $"Without user context, Application Name should start with 'BaseApp' but was '{connBuilder.ApplicationName}'"); + // When no user context, the app name should NOT have the hash prefix pattern + // (hash prefix is 16 bytes Base64-encoded = ~22 chars, followed by |) + string[] parts = connBuilder.ApplicationName.Split('|'); + bool hasHashPrefix = parts.Length > 1 && parts[0].Length >= 20 && parts[0].Length <= 25; + Assert.IsFalse(hasHashPrefix, + $"Without user context, Application Name should not have hash prefix but was '{connBuilder.ApplicationName}'"); + } + + /// + /// Test that when OBO is enabled and a user is authenticated but missing required claims + /// (iss or oid/sub), CreateConnection throws DataApiBuilderException with OboAuthenticationFailure. + /// This fail-safe behavior prevents cross-user connection pool contamination. + /// + [DataTestMethod, TestCategory(TestCategory.MSSQL)] + [DataRow("https://login.microsoftonline.com/tenant/v2.0", null, "oid/sub", + DisplayName = "Authenticated user with iss but missing oid/sub throws OboAuthenticationFailure")] + [DataRow(null, "user-object-id", "iss", + DisplayName = "Authenticated user with oid but missing iss throws OboAuthenticationFailure")] + [DataRow(null, null, "iss and oid/sub", + DisplayName = "Authenticated user with no claims throws OboAuthenticationFailure")] + public void TestOboEnabled_AuthenticatedUserMissingClaims_ThrowsException( + string? issuer, + string? objectId, + string missingClaimDescription) + { + // Arrange - Create an authenticated HttpContext with incomplete claims + Mock httpContextAccessor = CreateHttpContextAccessorWithAuthenticatedUserMissingClaims( + issuer: issuer, + objectId: objectId); + + (MsSqlQueryExecutor queryExecutor, RuntimeConfigProvider provider) = CreateQueryExecutorForPoolingTest( + connectionString: "Server=localhost;Database=test;Application Name=TestApp;", + enableObo: true, + httpContextAccessor: httpContextAccessor); + + // Act & Assert - CreateConnection should throw DataApiBuilderException + DataApiBuilderException exception = Assert.ThrowsException(() => + { + queryExecutor.CreateConnection(provider.GetConfig().DefaultDataSourceName); + }); + + Assert.AreEqual(HttpStatusCode.Unauthorized, exception.StatusCode, + $"Expected Unauthorized status code when missing {missingClaimDescription}"); + Assert.AreEqual(DataApiBuilderException.SubStatusCodes.OboAuthenticationFailure, exception.SubStatusCode, + $"Expected OboAuthenticationFailure sub-status code when missing {missingClaimDescription}"); + Assert.IsTrue(exception.Message.Contains("iss") && exception.Message.Contains("oid"), + $"Exception message should mention required claims. Actual: {exception.Message}"); + } + + /// + /// Creates an HttpContextAccessor mock with an authenticated user that has incomplete claims. + /// Used to test fail-safe behavior when OBO is enabled but required claims are missing. + /// + /// The issuer claim value, or null to omit. + /// The oid claim value, or null to omit. + /// A configured HttpContextAccessor mock with authenticated user. + private static Mock CreateHttpContextAccessorWithAuthenticatedUserMissingClaims( + string? issuer, + string? objectId) + { + Mock httpContextAccessor = new(); + DefaultHttpContext context = new(); + + // Create an authenticated identity (passing authenticationType makes IsAuthenticated = true) + System.Security.Claims.ClaimsIdentity identity = new("TestAuth"); + + // Only add claims if they are provided (non-null) + if (!string.IsNullOrEmpty(issuer)) + { + identity.AddClaim(new System.Security.Claims.Claim("iss", issuer)); + } + + if (!string.IsNullOrEmpty(objectId)) + { + identity.AddClaim(new System.Security.Claims.Claim("oid", objectId)); + } + + context.User = new System.Security.Claims.ClaimsPrincipal(identity); + httpContextAccessor.Setup(x => x.HttpContext).Returns(context); + + return httpContextAccessor; + } + + #endregion + [TestCleanup] public void CleanupAfterEachTest() {