From 69e69055bebe585c016dade7e2fc3426f787caa0 Mon Sep 17 00:00:00 2001 From: Matthew Boentoro Date: Fri, 23 Jan 2026 13:54:52 -0800 Subject: [PATCH 1/2] [PostgreSQL] Add Microsoft.Azure.PostgreSQL.Auth library --- .../CHANGELOG.md | 27 + .../Microsoft.Azure.PostgreSQL.Auth/README.md | 78 +++ .../dotnet.sln | 102 ++++ .../CreateDbConnectionNpgsql.cs | 72 +++ .../GettingStarted/GettingStarted.csproj | 30 ++ .../GettingStarted/appsettings.sample.json | 5 + .../Azure/PostgreSQL/Auth/EntraIdExtension.cs | 216 ++++++++ .../Microsoft.Azure.PostgreSQL.Auth.csproj | 36 ++ .../PostgreSQL/Auth/EntraIdExtensionTests.cs | 474 ++++++++++++++++++ .../Microsoft.Azure.PostgreSQL.Auth.csproj | 30 ++ .../Azure/PostgreSQL/Auth/TestUtilities.cs | 60 +++ 11 files changed, 1130 insertions(+) create mode 100644 sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/CHANGELOG.md create mode 100644 sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/README.md create mode 100644 sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/dotnet.sln create mode 100644 sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/samples/GettingStarted/CreateDbConnectionNpgsql.cs create mode 100644 sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/samples/GettingStarted/GettingStarted.csproj create mode 100644 sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/samples/GettingStarted/appsettings.sample.json create mode 100644 sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/src/Microsoft/Azure/PostgreSQL/Auth/EntraIdExtension.cs create mode 100644 sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/src/Microsoft/Azure/PostgreSQL/Auth/Microsoft.Azure.PostgreSQL.Auth.csproj create mode 100644 sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/tests/Microsoft/Azure/PostgreSQL/Auth/EntraIdExtensionTests.cs create mode 100644 sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/tests/Microsoft/Azure/PostgreSQL/Auth/Microsoft.Azure.PostgreSQL.Auth.csproj create mode 100644 sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/tests/Microsoft/Azure/PostgreSQL/Auth/TestUtilities.cs diff --git a/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/CHANGELOG.md b/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/CHANGELOG.md new file mode 100644 index 000000000000..8f3a8244c499 --- /dev/null +++ b/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on keep-a-changelog principles and this project adheres to semantic versioning. + +## [Unreleased] + +### Added +- Implement Entra ID authentication libraries for Npgsql to connect to a PostgreSQL database + +### Changed +- + +### Fixed +- Remove dependency on DefaultAzureCredential in source library + +### Deprecated +- + +### Removed +- + +### Security +- + +--- \ No newline at end of file diff --git a/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/README.md b/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/README.md new file mode 100644 index 000000000000..474942ef638e --- /dev/null +++ b/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/README.md @@ -0,0 +1,78 @@ +## Overview + +This library enables Microsoft Entra ID authentication for Azure Database for PostgreSQL using the Npgsql driver. It eliminates database password management by using secure, token-based authentication through Azure's identity platform. + +### Key Benefits + +- **Passwordless Authentication**: Uses OAuth 2.0 access tokens instead of database passwords +- **Centralized Identity Management**: Leverages existing Entra ID users and groups +- **Zero Secrets**: No database credentials stored in application code +- **Automatic Token Handling**: Manages token acquisition and renewal transparently + +## Prerequisites + +**Azure Database for PostgreSQL Setup:** +- Azure Database for PostgreSQL server with Entra ID authentication enabled +- Entra ID administrator configured (to set up database users) +- Application's Entra ID identity created as a database user with appropriate permissions + +**Application Identity (choose one):** +- **Managed Identity**: For applications running in Azure (App Service, Functions, VMs) +- **Service Principal**: For applications with client credentials +- **User Identity**: For development or interactive scenarios + +**Example PostgreSQL Setup:** +```sql +-- Connect as Entra ID administrator and create database user +CREATE ROLE "myapp@domain.com" WITH LOGIN; +GRANT CONNECT ON DATABASE mydb TO "myapp@domain.com"; +GRANT USAGE ON SCHEMA public TO "myapp@domain.com"; +-- Grant additional permissions as needed +``` + +## Usage + +In your program, import the namespace `Microsoft.Azure.PostgreSQL.Auth` + +```csharp +using Microsoft.Azure.PostgreSQL.Auth; +``` +Use the extension methods as needed: + +### Asynchronous Authentication (Recommended) +```csharp +using Azure.Identity; + +// Fill in with connection information to Azure PostgreSQL server +// Note: No username/password in connection string - authentication handled by Entra ID +var connectionString = "Host=myserver.postgres.database.azure.com;Database=mydb;Port=5432;SSL Mode=Require;"; +var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString); + +// Use the async extension method for Entra authentication +// This will automatically: +// - Detect the current Azure identity (managed identity, service principal, or user) +// - Acquire a PostgreSQL-scoped access token +// - Configure the connection to use token-based authentication +var credential = new DefaultAzureCredential(); +await dataSourceBuilder.UseEntraAuthenticationAsync(credential); +``` + +### Synchronous Authentication +```csharp +using Azure.Identity; + +// Fill in with connection information to Azure PostgreSQL server +var connectionString = "Host=myserver.postgres.database.azure.com;Database=mydb;Port=5432;SSL Mode=Require;"; +var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString); + +// Use the sync extension method for Entra authentication +var credential = new DefaultAzureCredential(); +dataSourceBuilder.UseEntraAuthentication(credential); +``` + +## Benefits + +- **Enhanced Security**: No database passwords to manage or rotate +- **Simplified Deployment**: Works seamlessly with Azure managed identities +- **Compliance**: Supports enterprise identity governance and MFA requirements +- **Developer Experience**: Transparent authentication - existing Npgsql code works unchanged diff --git a/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/dotnet.sln b/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/dotnet.sln new file mode 100644 index 000000000000..d2193433c73c --- /dev/null +++ b/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/dotnet.sln @@ -0,0 +1,102 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Files", "Files", "{4D180A10-CB16-4781-BEB7-EAE2038A0993}" + ProjectSection(SolutionItems) = preProject + README.md = README.md + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GettingStarted", "samples\GettingStarted\GettingStarted.csproj", "{8528AA1A-1D38-48FF-9234-F49492A460C1}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Microsoft", "Microsoft", "{7ECBF5AD-AE5E-07AC-33F2-F258A53C5EA9}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Azure", "Azure", "{A542B900-C6AB-CFD7-234F-C4052AF02FF1}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "PostgreSQL", "PostgreSQL", "{4F6131AE-B29D-8566-B8CA-1A2AABE27274}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Auth", "Auth", "{93934517-16C9-C51A-8F2B-54760F50BDEB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Azure.PostgreSQL.Entra", "src\Microsoft\Azure\PostgreSQL\Auth\Microsoft.Azure.PostgreSQL.Auth.csproj", "{3E862DB4-B843-4361-94B5-8CF34402B511}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Microsoft", "Microsoft", "{8FEB4F0F-C974-64A2-0863-8577ABAC15AD}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Azure", "Azure", "{AC05A953-B9EF-C104-E53F-E15EBB9C3478}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "PostgreSQL", "PostgreSQL", "{7164C26A-6C7C-D37D-98D2-1150AFE094DD}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Auth", "Auth", "{290860F1-0C73-540D-3A79-AA6C3ABBD9C3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Azure.PostgreSQL.Entra.Tests", "tests\Microsoft\Azure\PostgreSQL\Auth\Microsoft.Azure.PostgreSQL.Auth.csproj", "{750B2A4F-9EF5-4CC5-8EF9-A93F4A1748F6}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8528AA1A-1D38-48FF-9234-F49492A460C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8528AA1A-1D38-48FF-9234-F49492A460C1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8528AA1A-1D38-48FF-9234-F49492A460C1}.Debug|x64.ActiveCfg = Debug|Any CPU + {8528AA1A-1D38-48FF-9234-F49492A460C1}.Debug|x64.Build.0 = Debug|Any CPU + {8528AA1A-1D38-48FF-9234-F49492A460C1}.Debug|x86.ActiveCfg = Debug|Any CPU + {8528AA1A-1D38-48FF-9234-F49492A460C1}.Debug|x86.Build.0 = Debug|Any CPU + {8528AA1A-1D38-48FF-9234-F49492A460C1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8528AA1A-1D38-48FF-9234-F49492A460C1}.Release|Any CPU.Build.0 = Release|Any CPU + {8528AA1A-1D38-48FF-9234-F49492A460C1}.Release|x64.ActiveCfg = Release|Any CPU + {8528AA1A-1D38-48FF-9234-F49492A460C1}.Release|x64.Build.0 = Release|Any CPU + {8528AA1A-1D38-48FF-9234-F49492A460C1}.Release|x86.ActiveCfg = Release|Any CPU + {8528AA1A-1D38-48FF-9234-F49492A460C1}.Release|x86.Build.0 = Release|Any CPU + {3E862DB4-B843-4361-94B5-8CF34402B511}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3E862DB4-B843-4361-94B5-8CF34402B511}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3E862DB4-B843-4361-94B5-8CF34402B511}.Debug|x64.ActiveCfg = Debug|Any CPU + {3E862DB4-B843-4361-94B5-8CF34402B511}.Debug|x64.Build.0 = Debug|Any CPU + {3E862DB4-B843-4361-94B5-8CF34402B511}.Debug|x86.ActiveCfg = Debug|Any CPU + {3E862DB4-B843-4361-94B5-8CF34402B511}.Debug|x86.Build.0 = Debug|Any CPU + {3E862DB4-B843-4361-94B5-8CF34402B511}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3E862DB4-B843-4361-94B5-8CF34402B511}.Release|Any CPU.Build.0 = Release|Any CPU + {3E862DB4-B843-4361-94B5-8CF34402B511}.Release|x64.ActiveCfg = Release|Any CPU + {3E862DB4-B843-4361-94B5-8CF34402B511}.Release|x64.Build.0 = Release|Any CPU + {3E862DB4-B843-4361-94B5-8CF34402B511}.Release|x86.ActiveCfg = Release|Any CPU + {3E862DB4-B843-4361-94B5-8CF34402B511}.Release|x86.Build.0 = Release|Any CPU + {750B2A4F-9EF5-4CC5-8EF9-A93F4A1748F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {750B2A4F-9EF5-4CC5-8EF9-A93F4A1748F6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {750B2A4F-9EF5-4CC5-8EF9-A93F4A1748F6}.Debug|x64.ActiveCfg = Debug|Any CPU + {750B2A4F-9EF5-4CC5-8EF9-A93F4A1748F6}.Debug|x64.Build.0 = Debug|Any CPU + {750B2A4F-9EF5-4CC5-8EF9-A93F4A1748F6}.Debug|x86.ActiveCfg = Debug|Any CPU + {750B2A4F-9EF5-4CC5-8EF9-A93F4A1748F6}.Debug|x86.Build.0 = Debug|Any CPU + {750B2A4F-9EF5-4CC5-8EF9-A93F4A1748F6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {750B2A4F-9EF5-4CC5-8EF9-A93F4A1748F6}.Release|Any CPU.Build.0 = Release|Any CPU + {750B2A4F-9EF5-4CC5-8EF9-A93F4A1748F6}.Release|x64.ActiveCfg = Release|Any CPU + {750B2A4F-9EF5-4CC5-8EF9-A93F4A1748F6}.Release|x64.Build.0 = Release|Any CPU + {750B2A4F-9EF5-4CC5-8EF9-A93F4A1748F6}.Release|x86.ActiveCfg = Release|Any CPU + {750B2A4F-9EF5-4CC5-8EF9-A93F4A1748F6}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {8528AA1A-1D38-48FF-9234-F49492A460C1} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + {7ECBF5AD-AE5E-07AC-33F2-F258A53C5EA9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {A542B900-C6AB-CFD7-234F-C4052AF02FF1} = {7ECBF5AD-AE5E-07AC-33F2-F258A53C5EA9} + {4F6131AE-B29D-8566-B8CA-1A2AABE27274} = {A542B900-C6AB-CFD7-234F-C4052AF02FF1} + {93934517-16C9-C51A-8F2B-54760F50BDEB} = {4F6131AE-B29D-8566-B8CA-1A2AABE27274} + {3E862DB4-B843-4361-94B5-8CF34402B511} = {93934517-16C9-C51A-8F2B-54760F50BDEB} + {8FEB4F0F-C974-64A2-0863-8577ABAC15AD} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {AC05A953-B9EF-C104-E53F-E15EBB9C3478} = {8FEB4F0F-C974-64A2-0863-8577ABAC15AD} + {7164C26A-6C7C-D37D-98D2-1150AFE094DD} = {AC05A953-B9EF-C104-E53F-E15EBB9C3478} + {290860F1-0C73-540D-3A79-AA6C3ABBD9C3} = {7164C26A-6C7C-D37D-98D2-1150AFE094DD} + {750B2A4F-9EF5-4CC5-8EF9-A93F4A1748F6} = {290860F1-0C73-540D-3A79-AA6C3ABBD9C3} + EndGlobalSection +EndGlobal diff --git a/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/samples/GettingStarted/CreateDbConnectionNpgsql.cs b/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/samples/GettingStarted/CreateDbConnectionNpgsql.cs new file mode 100644 index 000000000000..8bacc2702d55 --- /dev/null +++ b/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/samples/GettingStarted/CreateDbConnectionNpgsql.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Identity; +using Npgsql; +using Microsoft.Azure.PostgreSQL.Auth; +using Microsoft.Extensions.Configuration; + +namespace GettingStarted; + +/// +/// This example enables Entra authentication before connecting to the database via NpgsqlConnection. +/// +public class CreateDbConnectionNpgsql +{ + public static async Task Main(string[] args) + { + Console.WriteLine("=== Getting Started with Azure Entra Authentication for PostgreSQL ===\n"); + + // Build configuration + var configuration = new ConfigurationBuilder() + .SetBasePath(Environment.CurrentDirectory) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) + .AddEnvironmentVariables() + .Build(); + + // Read configuration values and build connection string once + var server = configuration["Host"]; + var database = configuration["Database"] ?? "postgres"; + var port = configuration.GetValue("Port", 5432); + var connectionString = $"Host={server};Database={database};Port={port};SSL Mode=Require;"; + + Console.WriteLine("--- Testing UseEntraAuthentication (sync) ---"); + await ExecuteQueriesWithEntraAuth(connectionString, useAsync: false); + + Console.WriteLine("\n--- Testing UseEntraAuthenticationAsync ---"); + await ExecuteQueriesWithEntraAuth(connectionString, useAsync: true); + + Console.WriteLine("\n=== Sample completed ==="); + } + + /// + /// Show how to create a connection to the database with Entra authentication and execute some prompts. + /// + /// The PostgreSQL connection string + /// If true, uses UseEntraAuthenticationAsync; otherwise uses UseEntraAuthentication + private static async Task ExecuteQueriesWithEntraAuth(string connectionString, bool useAsync = false) + { + + var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString); + + // Here, we use the appropriate extension method provided by NpgsqlDataSourceBuilderExtensions.cs + // to enable Entra Authentication. This will handle username extraction and token refresh as needed. + var credential = new DefaultAzureCredential(); + if (useAsync) + { + await dataSourceBuilder.UseEntraAuthenticationAsync(credential); + } + else + { + dataSourceBuilder.UseEntraAuthentication(credential); + } + + using var dataSource = dataSourceBuilder.Build(); + await using var connection = await dataSource.OpenConnectionAsync(); + + // Get PostgreSQL version + using var cmd1 = new NpgsqlCommand("SELECT version()", connection); + var version = await cmd1.ExecuteScalarAsync(); + Console.WriteLine($"PostgreSQL Version: {version}"); + } +} diff --git a/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/samples/GettingStarted/GettingStarted.csproj b/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/samples/GettingStarted/GettingStarted.csproj new file mode 100644 index 000000000000..5349e7adf6db --- /dev/null +++ b/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/samples/GettingStarted/GettingStarted.csproj @@ -0,0 +1,30 @@ + + + + Exe + net8.0;net9.0 + enable + enable + + + + + + + + + + + + + + + PreserveNewest + + + + + + + + \ No newline at end of file diff --git a/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/samples/GettingStarted/appsettings.sample.json b/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/samples/GettingStarted/appsettings.sample.json new file mode 100644 index 000000000000..bafb2fb9642e --- /dev/null +++ b/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/samples/GettingStarted/appsettings.sample.json @@ -0,0 +1,5 @@ +{ + "Host": "myserver.postgres.database.azure.com", + "Database": "mydatabase", + "Port": 5432 +} \ No newline at end of file diff --git a/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/src/Microsoft/Azure/PostgreSQL/Auth/EntraIdExtension.cs b/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/src/Microsoft/Azure/PostgreSQL/Auth/EntraIdExtension.cs new file mode 100644 index 000000000000..0f8e6879d066 --- /dev/null +++ b/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/src/Microsoft/Azure/PostgreSQL/Auth/EntraIdExtension.cs @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using System.Text.Json; +using Azure.Core; +using Npgsql; + +namespace Microsoft.Azure.PostgreSQL.Auth; + +/// +/// Class with extension methods for configuring Entra ID authentication with Npgsql data sources +/// synchronously and asynchronously. +/// +public static class EntraIdExtension +{ + private const string AzureDatabaseForPostgresSqlScope = "https://ossrdbms-aad.database.windows.net/.default"; + private const string AzureManagementScope = "https://management.azure.com/.default"; + + private static readonly TokenRequestContext s_azureDBForPostgresTokenRequestContext = new([AzureDatabaseForPostgresSqlScope]); + private static readonly TokenRequestContext s_managementTokenRequestContext = new([AzureManagementScope]); + + /// + /// Configures the NpgsqlDataSourceBuilder to use Entra ID authentication synchronously. + /// + /// The NpgsqlDataSourceBuilder to configure. + /// The TokenCredential to use for authentication. + /// A cancellation token that can be used to cancel the operation. + /// The configured NpgsqlDataSourceBuilder. + public static NpgsqlDataSourceBuilder UseEntraAuthentication(this NpgsqlDataSourceBuilder dataSourceBuilder, TokenCredential credential, CancellationToken cancellationToken = default) + { + + if (dataSourceBuilder.ConnectionStringBuilder.Username == null) + { + + // Ensure to use the management scope, so the token contains user names for all managed identity types - e.g. user and service principal + var token = credential.GetToken(s_managementTokenRequestContext, cancellationToken); + var username = TryGetUsernameFromToken(token.Token); + + if (username != null) + { + dataSourceBuilder.ConnectionStringBuilder.Username = username; + } + else + { + // Otherwise check using the PostgresSql scope + token = credential.GetToken(s_azureDBForPostgresTokenRequestContext, cancellationToken); + SetUsernameFromToken(dataSourceBuilder, token.Token); + } + } + + SetPasswordProvider(dataSourceBuilder, credential, s_azureDBForPostgresTokenRequestContext); + + return dataSourceBuilder; + } + + /// + /// Configures the NpgsqlDataSourceBuilder to use Entra ID authentication asynchronously. + /// + /// The NpgsqlDataSourceBuilder to configure. + /// The TokenCredential to use for authentication. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the configured NpgsqlDataSourceBuilder. + public static async Task UseEntraAuthenticationAsync(this NpgsqlDataSourceBuilder dataSourceBuilder, TokenCredential credential, CancellationToken cancellationToken = default) + { + + if (dataSourceBuilder.ConnectionStringBuilder.Username == null) + { + + // Ensure to use the management scope, so the token contains user names for all managed identity types - e.g. user and service principal + var token = await credential.GetTokenAsync(s_managementTokenRequestContext, cancellationToken).ConfigureAwait(false); + var username = TryGetUsernameFromToken(token.Token); + + if (username != null) + { + dataSourceBuilder.ConnectionStringBuilder.Username = username; + } + else + { + // Otherwise check using the PostgresSql scope + token = await credential.GetTokenAsync(s_azureDBForPostgresTokenRequestContext, cancellationToken).ConfigureAwait(false); + SetUsernameFromToken(dataSourceBuilder, token.Token); + } + } + + SetPasswordProvider(dataSourceBuilder, credential, s_azureDBForPostgresTokenRequestContext); + + return dataSourceBuilder; + } + + private static void SetPasswordProvider(NpgsqlDataSourceBuilder dataSourceBuilder, TokenCredential credential, TokenRequestContext tokenRequestContext) + { + dataSourceBuilder.UsePasswordProvider(_ => + { + var token = credential.GetToken(tokenRequestContext, default); + return token.Token; + }, async (_, ct) => + { + // Use the cancellation token provided by Npgsql for async operations + var token = await credential.GetTokenAsync(tokenRequestContext, ct).ConfigureAwait(false); + return token.Token; + }); + } + + private static void SetUsernameFromToken(NpgsqlDataSourceBuilder dataSourceBuilder, string token) + { + var username = TryGetUsernameFromToken(token); + + if (username != null) + { + dataSourceBuilder.ConnectionStringBuilder.Username = username; + } + else + { + throw new Exception("Could not determine username from token claims"); + } + } + + private static string? TryGetUsernameFromToken(string jwtToken) + { + // Split the token into its parts (Header, Payload, Signature) + var tokenParts = jwtToken.Split('.'); + if (tokenParts.Length != 3) + { + return null; + } + + // The payload is the second part, Base64Url encoded + var payload = tokenParts[1]; + if (string.IsNullOrWhiteSpace(payload)) + { + return null; // empty payload + } + + try + { + // Add padding if necessary + payload = AddBase64Padding(payload); + + // Convert from Base64Url to standard Base64 + payload = payload.Replace('-', '+').Replace('_', '/'); + + // Decode the payload from Base64 + var decodedBytes = Convert.FromBase64String(payload); + var decodedPayload = Encoding.UTF8.GetString(decodedBytes); + + if (string.IsNullOrWhiteSpace(decodedPayload)) + { + return null; // nothing to parse + } + + // Parse the decoded payload as JSON + var payloadJson = JsonSerializer.Deserialize(decodedPayload); + + // Try to get the username from 'xms_mirid', 'upn', 'preferred_username', or 'unique_name' claims + if (payloadJson.TryGetProperty("xms_mirid", out var xms_mirid) && + xms_mirid.GetString() is string xms_miridString && + ParsePrincipalName(xms_miridString) is string principalName) + { + return principalName; + } + else if (payloadJson.TryGetProperty("upn", out var upn)) + { + return upn.GetString(); + } + else if (payloadJson.TryGetProperty("preferred_username", out var preferredUsername)) + { + return preferredUsername.GetString(); + } + else if (payloadJson.TryGetProperty("unique_name", out var uniqueName)) + { + return uniqueName.GetString(); + } + + return null; // no relevant claims + } + catch (FormatException) + { + // Invalid Base64 content + return null; + } + catch (JsonException) + { + // Invalid JSON content + return null; + } + } + + private static string? ParsePrincipalName(string xms_mirid) + { + // parse the xms_mirid claim which looks like + // /subscriptions/{subId}/resourcegroups/{resourceGroup}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{principalName} + var lastSlashIndex = xms_mirid.LastIndexOf('/'); + if (lastSlashIndex == -1) + { + return null; + } + + var beginning = xms_mirid.AsSpan(0, lastSlashIndex); + var principalName = xms_mirid.AsSpan(lastSlashIndex + 1); + + if (principalName.IsEmpty || !beginning.EndsWith("providers/Microsoft.ManagedIdentity/userAssignedIdentities", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return principalName.ToString(); + } + + private static string AddBase64Padding(string base64) => (base64.Length % 4) switch + { + 2 => base64 + "==", + 3 => base64 + "=", + _ => base64, + }; +} diff --git a/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/src/Microsoft/Azure/PostgreSQL/Auth/Microsoft.Azure.PostgreSQL.Auth.csproj b/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/src/Microsoft/Azure/PostgreSQL/Auth/Microsoft.Azure.PostgreSQL.Auth.csproj new file mode 100644 index 000000000000..12f7335d1617 --- /dev/null +++ b/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/src/Microsoft/Azure/PostgreSQL/Auth/Microsoft.Azure.PostgreSQL.Auth.csproj @@ -0,0 +1,36 @@ + + + + net8.0;net9.0 + true + enable + enable + $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb + true + Microsoft.Azure.PostgreSQL.Auth + Microsoft + 1.0.0 + + Azure Entra ID authentication extension for Npgsql driver + + azure;entra;PostgreSQL;Npgsql + https://github.com/Azure/postgres-entra-auth.git + https://github.com/Azure/postgres-entra-auth + git + https://github.com/Azure/postgres-entra-auth/releases + © Microsoft Corporation. All rights reserved. + README.md + MIT + true + + + + + + + + + + + + diff --git a/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/tests/Microsoft/Azure/PostgreSQL/Auth/EntraIdExtensionTests.cs b/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/tests/Microsoft/Azure/PostgreSQL/Auth/EntraIdExtensionTests.cs new file mode 100644 index 000000000000..9bcb9b8a0c25 --- /dev/null +++ b/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/tests/Microsoft/Azure/PostgreSQL/Auth/EntraIdExtensionTests.cs @@ -0,0 +1,474 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using Azure.Core; +using FluentAssertions; +using Moq; +using Npgsql; +using Testcontainers.PostgreSql; +using Xunit; +using static Microsoft.Azure.PostgreSQL.Auth.TestJwtTokenGenerator; +using static Microsoft.Azure.PostgreSQL.Auth.TestUsers; + +namespace Microsoft.Azure.PostgreSQL.Auth; + +/// +/// Integration tests showcasing Entra ID authentication with PostgreSQL Docker instance. +/// These tests demonstrate token-based authentication and username extraction. +/// +public class NpgsqlEntraIdExtensionTests : IAsyncLifetime +{ + private PostgreSqlContainer _postgresContainer = null!; + private string _connectionString = null!; + + public async Task InitializeAsync() + { + _postgresContainer = new PostgreSqlBuilder() + .WithImage("postgres:15") + .WithDatabase("testdb") + .WithUsername("testuser") + .WithPassword("testpass") + .Build(); + + await _postgresContainer.StartAsync(); + _connectionString = _postgresContainer.GetConnectionString(); + + // Set up test users that simulate Azure Database for PostgreSQL users + await SetupEntraTestUsersAsync(); + } + + public async Task DisposeAsync() + { + await _postgresContainer.DisposeAsync(); + } + + private async Task SetupEntraTestUsersAsync() + { + // Create users that match what would be extracted from JWT tokens + // This simulates how Azure Database for PostgreSQL creates users for Entra ID principals + using var connection = new NpgsqlConnection(_connectionString); + await connection.OpenAsync(); + + // Generate JWT tokens for each user + var testUserToken = CreateValidJwtToken(EntraUser); + var managedIdentityToken = CreateJwtTokenWithXmsMirid(ManagedIdentityPath); + var fallbackUserToken = CreateValidJwtToken(FallbackUser); + + var setupCommands = new[] + { + $@"CREATE USER ""{EntraUser}"" WITH PASSWORD '{testUserToken}';", + $@"CREATE USER ""{ManagedIdentityName}"" WITH PASSWORD '{managedIdentityToken}';", + $@"CREATE USER ""{FallbackUser}"" WITH PASSWORD '{fallbackUserToken}';", + $@"GRANT CONNECT ON DATABASE testdb TO ""{EntraUser}"";", + $@"GRANT CONNECT ON DATABASE testdb TO ""{ManagedIdentityName}"";", + $@"GRANT CONNECT ON DATABASE testdb TO ""{FallbackUser}"";", + $@"GRANT ALL PRIVILEGES ON DATABASE testdb TO ""{EntraUser}"";", + $@"GRANT ALL PRIVILEGES ON DATABASE testdb TO ""{ManagedIdentityName}"";", + $@"GRANT ALL PRIVILEGES ON DATABASE testdb TO ""{FallbackUser}"";", + // Grant schema permissions for creating tables + $@"GRANT ALL ON SCHEMA public TO ""{EntraUser}"";", + $@"GRANT ALL ON SCHEMA public TO ""{ManagedIdentityName}"";", + $@"GRANT ALL ON SCHEMA public TO ""{FallbackUser}"";", + // Grant permissions on all tables in the schema + $@"GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ""{EntraUser}"";", + $@"GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ""{ManagedIdentityName}"";", + $@"GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ""{FallbackUser}"";", + // Grant permissions on all sequences in the schema + $@"GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ""{EntraUser}"";", + $@"GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ""{ManagedIdentityName}"";", + $@"GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ""{FallbackUser}"";" + }; + + foreach (var sql in setupCommands) + { + try + { + using var cmd = new NpgsqlCommand(sql, connection); + await cmd.ExecuteNonQueryAsync(); + } + catch (PostgresException ex) when (ex.SqlState == "42710") + { + // User already exists, this is expected in test reruns + continue; + } + catch (Exception ex) + { + // Log unexpected errors to help debugging + Console.Error.WriteLine($"Setup command failed: {sql}"); + Console.Error.WriteLine($"Error: {ex.Message}"); + throw; + } + } + } + + /// + /// Helper method to test end-to-end connection with Entra authentication. + /// Verifies username extraction, connection establishment, and database operations. + /// + private async Task TestEntraAuthenticationFlow(string token, string expectedUsername, bool useAsync = false) + { + // Arrange - Create base connection string without credentials + var baseConnectionString = new NpgsqlConnectionStringBuilder(_connectionString) + { + Username = null, + Password = null + }.ToString(); + + var builder = new NpgsqlDataSourceBuilder(baseConnectionString); + var credential = new TestTokenCredential(token); + + // Act - Configure Entra authentication (sync or async) + if (useAsync) + { + await builder.UseEntraAuthenticationAsync(credential); + } + else + { + builder.UseEntraAuthentication(credential); + } + + // Build data source with Entra configuration + using var dataSource = builder.Build(); + + // Assert - Username should be extracted from the token + builder.ConnectionStringBuilder.Username.Should().Be(expectedUsername); + + // Opens a new connection from the data source + using var connection = await dataSource.OpenConnectionAsync(); + connection.State.Should().Be(System.Data.ConnectionState.Open); + + // Test basic operations + using var cmd = new NpgsqlCommand("SELECT current_user, current_database()", connection); + await using var reader = await cmd.ExecuteReaderAsync(); + + if (await reader.ReadAsync()) + { + var currentUser = reader.GetString(0); + var currentDb = reader.GetString(1); + + currentUser.Should().Be(expectedUsername); + currentDb.Should().Be("testdb"); + } + } + + [Fact] + public async Task ConnectWithEntraUser() + { + // Showcases connecting with an Entra user using UseEntraAuthentication + + var testToken = CreateValidJwtToken(EntraUser); + await TestEntraAuthenticationFlow(testToken, EntraUser); + } + + [Fact] + public async Task ConnectWithEntraUser_Async() + { + // Showcases connecting with an Entra user using UseEntraAuthenticationAsync + + var testToken = CreateValidJwtToken(EntraUser); + await TestEntraAuthenticationFlow(testToken, EntraUser, useAsync: true); + } + + [Fact] + public async Task ConnectWithManagedIdentity() + { + // Showcases connecting with a managed identity using UseEntraAuthentication + + var miToken = CreateJwtTokenWithXmsMirid(ManagedIdentityPath); + await TestEntraAuthenticationFlow(miToken, ManagedIdentityName); + } + + [Fact] + public async Task ConnectWithManagedIdentity_Async() + { + // Showcases connecting with a managed identity using UseEntraAuthenticationAsync + + var miToken = CreateJwtTokenWithXmsMirid(ManagedIdentityPath); + await TestEntraAuthenticationFlow(miToken, ManagedIdentityName, useAsync: true); + } + + [Fact] + public void ThrowMeaningfulErrorForInvalidJwtTokenFormat() + { + // Showcases error handling for invalid JWT token format + + var invalidToken = "not.a.valid.token"; + var credential = new TestTokenCredential(invalidToken); + + var baseConnectionString = new NpgsqlConnectionStringBuilder(_connectionString) + { + Username = null, + Password = null + }.ToString(); + + var builder = new NpgsqlDataSourceBuilder(baseConnectionString); + + var act = () => builder.UseEntraAuthentication(credential); + + act.Should().Throw(); + } + + [Fact] + public async Task ThrowMeaningfulErrorForInvalidJwtTokenFormat_Async() + { + // Showcases error handling for invalid JWT token format (async) + + var invalidToken = "not.a.valid.token"; + var credential = new TestTokenCredential(invalidToken); + + var baseConnectionString = new NpgsqlConnectionStringBuilder(_connectionString) + { + Username = null, + Password = null + }.ToString(); + + var builder = new NpgsqlDataSourceBuilder(baseConnectionString); + + var act = async () => await builder.UseEntraAuthenticationAsync(credential); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task HandleConnectionFailureWithClearError() + { + // Showcases error handling for connection failures + + var testToken = CreateValidJwtToken(EntraUser); + var credential = new TestTokenCredential(testToken); + + var invalidConnectionString = "Host=invalid-host;Port=9999;Database=testdb"; + var builder = new NpgsqlDataSourceBuilder(invalidConnectionString); + builder.UseEntraAuthentication(credential); + + using var dataSource = builder.Build(); + + var act = async () => await dataSource.OpenConnectionAsync(); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task HandleConnectionFailureWithClearError_Async() + { + // Showcases error handling for connection failures (async) + + var testToken = CreateValidJwtToken(EntraUser); + var credential = new TestTokenCredential(testToken); + + var invalidConnectionString = "Host=invalid-host;Port=9999;Database=testdb"; + var builder = new NpgsqlDataSourceBuilder(invalidConnectionString); + await builder.UseEntraAuthenticationAsync(credential); + + using var dataSource = builder.Build(); + + var act = async () => await dataSource.OpenConnectionAsync(); + + await act.Should().ThrowAsync(); + } + + [Fact] + public void TokenCachingBehavior() + { + // Showcases that credentials are invoked for each connection using UseEntraAuthentication + // Note: Npgsql's password provider calls GetToken for each connection + // Token caching should be implemented by the credential itself + + var testToken = CreateValidJwtToken(EntraUser); + var mockCredential = new Mock(); + mockCredential + .Setup(c => c.GetToken(It.IsAny(), It.IsAny())) + .Returns(new AccessToken(testToken, DateTimeOffset.UtcNow.AddHours(1))); + + var baseConnectionString = new NpgsqlConnectionStringBuilder(_connectionString) + { + Username = null, + Password = null + }.ToString(); + + var builder = new NpgsqlDataSourceBuilder(baseConnectionString); + builder.UseEntraAuthentication(mockCredential.Object); + + using var dataSource = builder.Build(); + + // Open multiple connections (using sync method to test sync password provider) + using (var connection1 = dataSource.OpenConnection()) + { + connection1.State.Should().Be(System.Data.ConnectionState.Open); + } + + using (var connection2 = dataSource.OpenConnection()) + { + connection2.State.Should().Be(System.Data.ConnectionState.Open); + } + + // Verify token was fetched for each connection + mockCredential.Verify( + c => c.GetToken(It.IsAny(), It.IsAny()), + Times.Exactly(2)); + } + + [Fact] + public async Task TokenCachingBehavior_Async() + { + // Showcases that credentials are invoked for each connection using UseEntraAuthenticationAsync + // Note: Npgsql's password provider calls GetToken for each connection + // Token caching should be implemented by the credential itself + + var testToken = CreateValidJwtToken(EntraUser); + var mockCredential = new Mock(); + mockCredential + .Setup(c => c.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new AccessToken(testToken, DateTimeOffset.UtcNow.AddHours(1))); + + var baseConnectionString = new NpgsqlConnectionStringBuilder(_connectionString) + { + Username = null, + Password = null + }.ToString(); + + var builder = new NpgsqlDataSourceBuilder(baseConnectionString); + await builder.UseEntraAuthenticationAsync(mockCredential.Object); + + using var dataSource = builder.Build(); + + // Open multiple connections + using (var connection1 = await dataSource.OpenConnectionAsync()) + { + connection1.State.Should().Be(System.Data.ConnectionState.Open); + } + + using (var connection2 = await dataSource.OpenConnectionAsync()) + { + connection2.State.Should().Be(System.Data.ConnectionState.Open); + } + + // Verify token was fetched for each connection + mockCredential.Verify( + c => c.GetTokenAsync(It.IsAny(), It.IsAny()), + Times.Exactly(2)); + } + + [Fact] + public void MultipleJwtClaimTypes() + { + // Showcases support for different JWT claim types (preferred_username, unique_name) + + // Test with preferred_username claim + var preferredUsernameToken = CreateBase64UrlString("{\"alg\":\"RS256\",\"typ\":\"JWT\"}") + "." + + CreateBase64UrlString($"{{\"preferred_username\":\"{EntraUser}\",\"iat\":1234567890,\"exp\":9999999999}}") + "." + + "fake-signature"; + + var baseConnectionString = new NpgsqlConnectionStringBuilder(_connectionString) + { + Username = null, + Password = null + }.ToString(); + + var builder1 = new NpgsqlDataSourceBuilder(baseConnectionString); + var credential1 = new TestTokenCredential(preferredUsernameToken); + builder1.UseEntraAuthentication(credential1); + + builder1.ConnectionStringBuilder.Username.Should().Be(EntraUser); + + // Test with unique_name claim + var uniqueNameToken = CreateBase64UrlString("{\"alg\":\"RS256\",\"typ\":\"JWT\"}") + "." + + CreateBase64UrlString($"{{\"unique_name\":\"{EntraUser}\",\"iat\":1234567890,\"exp\":9999999999}}") + "." + + "fake-signature"; + + var builder2 = new NpgsqlDataSourceBuilder(baseConnectionString); + var credential2 = new TestTokenCredential(uniqueNameToken); + builder2.UseEntraAuthentication(credential2); + + builder2.ConnectionStringBuilder.Username.Should().Be(EntraUser); + } + + [Fact] + public async Task MultipleJwtClaimTypes_Async() + { + // Showcases support for different JWT claim types (preferred_username, unique_name) using async + + // Test with preferred_username claim + var preferredUsernameToken = CreateBase64UrlString("{\"alg\":\"RS256\",\"typ\":\"JWT\"}") + "." + + CreateBase64UrlString($"{{\"preferred_username\":\"{EntraUser}\",\"iat\":1234567890,\"exp\":9999999999}}") + "." + + "fake-signature"; + + var baseConnectionString = new NpgsqlConnectionStringBuilder(_connectionString) + { + Username = null, + Password = null + }.ToString(); + + var builder1 = new NpgsqlDataSourceBuilder(baseConnectionString); + var credential1 = new TestTokenCredential(preferredUsernameToken); + await builder1.UseEntraAuthenticationAsync(credential1); + + builder1.ConnectionStringBuilder.Username.Should().Be(EntraUser); + + // Test with unique_name claim + var uniqueNameToken = CreateBase64UrlString("{\"alg\":\"RS256\",\"typ\":\"JWT\"}") + "." + + CreateBase64UrlString($"{{\"unique_name\":\"{EntraUser}\",\"iat\":1234567890,\"exp\":9999999999}}") + "." + + "fake-signature"; + + var builder2 = new NpgsqlDataSourceBuilder(baseConnectionString); + var credential2 = new TestTokenCredential(uniqueNameToken); + await builder2.UseEntraAuthenticationAsync(credential2); + + builder2.ConnectionStringBuilder.Username.Should().Be(EntraUser); + } + + [Fact] + public void PreserveExistingCredentials() + { + // Documents that building data source with existing password fails when Entra auth is configured + // This is by design in Npgsql - you cannot register a password provider when password is set + + var testToken = CreateValidJwtToken("test@example.com"); + var credential = new TestTokenCredential(testToken); + + // Connection string already has username and password + var connectionStringWithCreds = new NpgsqlConnectionStringBuilder(_connectionString) + { + Username = "testuser", + Password = "testpass" + }.ToString(); + + var builder = new NpgsqlDataSourceBuilder(connectionStringWithCreds); + builder.UseEntraAuthentication(credential); + + // Building the data source should throw because password provider can't be registered + // when password is already set + var act = () => builder.Build(); + + act.Should().Throw() + .WithMessage("*password provider*password*"); + } + + [Fact] + public async Task PreserveExistingCredentials_Async() + { + // Documents that building data source with existing password fails when Entra auth is configured (async) + // This is by design in Npgsql - you cannot register a password provider when password is set + + var testToken = CreateValidJwtToken("test@example.com"); + var credential = new TestTokenCredential(testToken); + + // Connection string already has username and password + var connectionStringWithCreds = new NpgsqlConnectionStringBuilder(_connectionString) + { + Username = "testuser", + Password = "testpass" + }.ToString(); + + var builder = new NpgsqlDataSourceBuilder(connectionStringWithCreds); + await builder.UseEntraAuthenticationAsync(credential); + + // Building the data source should throw because password provider can't be registered + // when password is already set + var act = () => builder.Build(); + + act.Should().Throw() + .WithMessage("*password provider*password*"); + } +} diff --git a/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/tests/Microsoft/Azure/PostgreSQL/Auth/Microsoft.Azure.PostgreSQL.Auth.csproj b/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/tests/Microsoft/Azure/PostgreSQL/Auth/Microsoft.Azure.PostgreSQL.Auth.csproj new file mode 100644 index 000000000000..6cdbe9492050 --- /dev/null +++ b/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/tests/Microsoft/Azure/PostgreSQL/Auth/Microsoft.Azure.PostgreSQL.Auth.csproj @@ -0,0 +1,30 @@ + + + + net8.0;net9.0 + enable + enable + false + true + Microsoft.Azure.PostgreSQL.Entra.Tests + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + diff --git a/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/tests/Microsoft/Azure/PostgreSQL/Auth/TestUtilities.cs b/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/tests/Microsoft/Azure/PostgreSQL/Auth/TestUtilities.cs new file mode 100644 index 000000000000..91ddb072e3c7 --- /dev/null +++ b/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/tests/Microsoft/Azure/PostgreSQL/Auth/TestUtilities.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using Azure.Core; + +namespace Microsoft.Azure.PostgreSQL.Auth; + +/// +/// Test user constants used across integration tests +/// +public static class TestUsers +{ + public const string EntraUser = "test@example.com"; + public const string ManagedIdentityPath = "/subscriptions/12345/resourcegroups/mygroup/providers/Microsoft.ManagedIdentity/userAssignedIdentities/managed-identity"; + public const string ManagedIdentityName = "managed-identity"; + public const string FallbackUser = "fallback@example.com"; +} + +public static class TestJwtTokenGenerator +{ + public static string CreateBase64UrlString(string input) + { + var bytes = Encoding.UTF8.GetBytes(input); + var base64 = Convert.ToBase64String(bytes); + return base64.TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + + public static string CreateValidJwtToken(string username) => + string.Join('.', + CreateBase64UrlString("{\"alg\":\"RS256\",\"typ\":\"JWT\"}"), + CreateBase64UrlString($"{{\"upn\":\"{username}\",\"iat\":1234567890,\"exp\":9999999999}}"), + "fake-signature"); + + public static string CreateJwtTokenWithXmsMirid(string xms_mirid) => + string.Join('.', + CreateBase64UrlString("{\"alg\":\"RS256\",\"typ\":\"JWT\"}"), + CreateBase64UrlString($"{{\"xms_mirid\":\"{xms_mirid}\",\"iat\":1234567890,\"exp\":9999999999}}"), + "fake-signature"); + + public class TestTokenCredential : TokenCredential + { + private readonly string _token; + + public TestTokenCredential(string token) + { + _token = token; + } + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new AccessToken(_token, DateTimeOffset.UtcNow.AddHours(1)); + } + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new ValueTask(new AccessToken(_token, DateTimeOffset.UtcNow.AddHours(1))); + } + } +} From f65f966d308c138fb391aa7b7e1b4e882b1193bb Mon Sep 17 00:00:00 2001 From: Gauri Kasar Date: Fri, 30 Jan 2026 13:12:56 -0800 Subject: [PATCH 2/2] Add changes related to packages --- ...ln => Microsoft.Azure.PostgreSQL.Auth.sln} | 0 .../Microsoft.Azure.PostgreSQL.Auth.csproj | 4 +-- .../Microsoft.Azure.PostgreSQL.Auth.csproj | 30 ++++++++++++------- 3 files changed, 22 insertions(+), 12 deletions(-) rename sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/{dotnet.sln => Microsoft.Azure.PostgreSQL.Auth.sln} (100%) diff --git a/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/dotnet.sln b/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/Microsoft.Azure.PostgreSQL.Auth.sln similarity index 100% rename from sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/dotnet.sln rename to sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/Microsoft.Azure.PostgreSQL.Auth.sln diff --git a/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/src/Microsoft/Azure/PostgreSQL/Auth/Microsoft.Azure.PostgreSQL.Auth.csproj b/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/src/Microsoft/Azure/PostgreSQL/Auth/Microsoft.Azure.PostgreSQL.Auth.csproj index 12f7335d1617..c4c86c5fb5b6 100644 --- a/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/src/Microsoft/Azure/PostgreSQL/Auth/Microsoft.Azure.PostgreSQL.Auth.csproj +++ b/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/src/Microsoft/Azure/PostgreSQL/Auth/Microsoft.Azure.PostgreSQL.Auth.csproj @@ -25,8 +25,8 @@ - - + + diff --git a/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/tests/Microsoft/Azure/PostgreSQL/Auth/Microsoft.Azure.PostgreSQL.Auth.csproj b/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/tests/Microsoft/Azure/PostgreSQL/Auth/Microsoft.Azure.PostgreSQL.Auth.csproj index 6cdbe9492050..f337ef1117d1 100644 --- a/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/tests/Microsoft/Azure/PostgreSQL/Auth/Microsoft.Azure.PostgreSQL.Auth.csproj +++ b/sdk/postgresql/Microsoft.Azure.PostgreSQL.Auth/tests/Microsoft/Azure/PostgreSQL/Auth/Microsoft.Azure.PostgreSQL.Auth.csproj @@ -1,3 +1,4 @@ + @@ -6,21 +7,30 @@ enable false true - Microsoft.Azure.PostgreSQL.Entra.Tests + Microsoft.Azure.PostgreSQL.Auth.Tests - - - - runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - + + + + + + + + + + +